openapi: 3.1.0
info:
  title: Cycles Budget Authority API
  version: 0.1.25.16
  license:
    name: Apache 2.0
    url: https://www.apache.org/licenses/LICENSE-2.0
  summary: v0 protocol for deterministic budget governance (reserve/commit) with optional decide + balance queries, plus debt/overdraft support with soft-limit reconciliation.
  x-changelog:
    url: ./changelogs/cycles-protocol-v0.md
    format: keep-a-changelog
  description: |-
    PURPOSE (v0):
      - Provide a minimal, language-agnostic protocol to enforce deterministic spend exposure for agent runtimes
        via concurrency-safe reservations and idempotent commits.
      - Include optional integration endpoints: /decide (soft landing) and /balances (operator visibility).

    NON-GOALS (v0) (NORMATIVE):
      - Budget establishment and funding operations are out of scope for v0.
        v0 defines the reservation/commit/release enforcement plane and balance reporting only.
      - v0 provides no API for budget CRUD (create/update/delete), allocation setting, credit/deposit, or debit/withdrawal.
        Implementations MAY provide these via an operator/admin plane or a separate API; future versions may standardize them.
      - A reservation lifecycle is denominated in exactly one unit (single-unit reserve/commit/release).
        Multi-unit atomic reservation/settlement is a v1+ concern.

    PAYMENT-RAIL TERMINOLOGY BOUNDARY (NORMATIVE):
      - A Cycles reservation holds spending authority in a budget ledger; it does not hold funds
        on a card, bank, blockchain, or other payment rail.
      - A Cycles commit finalizes recorded economic exposure; it does not authorize, capture,
        charge, or settle a payment on a payment rail.
      - A Cycles release returns unused budget authority; it does not void or refund a payment.
      - Implementations that move money MUST execute and reconcile payment-rail operations separately
        and MUST NOT represent a Cycles lifecycle transition as proof that funds moved or were reversed.

    AUTH & TENANCY (NORMATIVE):
      - Requests are authenticated via X-Cycles-API-Key, EXCEPT endpoints that
        explicitly declare `security: []`, which are PUBLIC and require no API key.
        Two such endpoints exist, both in the CyclesEvidence surface:
        `GET /v1/evidence/{evidence_id}` (envelope retrieval — its `evidence_id`
        is an unguessable content-hash capability and the envelope is
        content-addressed and signed) and `GET /v1/.well-known/cycles-jwks.json`
        (the signer's public JWK Set — public keys only, the standard posture for
        a verification key set). See each operation's description for the rationale.
      - Server determines an "effective tenant" from the API key (or other auth context).
      - Subject.tenant is a budgeting dimension and MUST be validated against the effective tenant.
        If mismatched, server MUST return 403 FORBIDDEN.
      - Reservation ownership MUST be enforced: every reservation is bound to the effective tenant at creation.
        Any subsequent GET/commit/release for a reservation that exists but is owned by a different tenant
        MUST return 403 FORBIDDEN.
      - Balance visibility MUST be tenant-scoped: the server MUST only return balances within the effective tenant.
        If a request attempts to query another tenant (e.g., tenant filter mismatches), server MUST return 403 FORBIDDEN.

    EVOLUTION CONTRACT:
      - This API starts at v0.1.0 with /v1 paths to avoid future client churn.
      - v1+ evolution MUST be backward-compatible by default: new fields are additive, existing field meanings MUST NOT change.
      - Breaking changes (e.g., new required fields, semantic changes) require a new major API path (e.g., /v2).

    CORE INVARIANTS:
      - Reserve is atomic across all derived scopes.
      - Commit and release are idempotent.
      - No double-charge on retries (idempotency key enforced).

    ERROR SEMANTICS (NORMATIVE):
      - Budget denials MUST return HTTP 409 with error=BUDGET_EXCEEDED.
      - Overdraft limit exceeded MUST return HTTP 409 with error=OVERDRAFT_LIMIT_EXCEEDED in two cases:
        1. During commit: when overage_policy=ALLOW_WITH_OVERDRAFT and (current_debt + delta) > overdraft_limit at commit time
        2. During reservation: when the scope is in over-limit state (debt > overdraft_limit due to prior concurrent commits)
      - Outstanding debt blocking reservation MUST return HTTP 409 with error=DEBT_OUTSTANDING
        (when debt > 0 and new reservation is attempted).
      - Closed owning tenant MUST return HTTP 409 with error=TENANT_CLOSED on the persisting
        mutation surface — reservation create (POST /v1/reservations with dry_run
        absent or false), commit, release, extend, AND POST /v1/events (createEvent) — when the
        owning tenant's status is CLOSED and the CLOSED flip is durable
        (added to the ErrorCode enum in revision 2026-07-10, mirroring the governance spec's
        code of the same name). POST /v1/events is a persisting BUDGET DEBIT (post-only
        accounting that directly mutates remaining/spent on every budgeted derived scope and
        MAY accrue overdraft debt under overage_policy=ALLOW_WITH_OVERDRAFT, with concurrency
        semantics as for commit) — it is on the same runtime-plane persisting surface as the
        reservation mutations and MUST be guarded identically. This is the runtime-plane half
        of the terminal-owner mutation guard in cycles-governance-admin-v0.1.25.yaml (CASCADE
        SEMANTICS Rule 2); that section enumerates "any reservation create/commit/release/extend"
        on the runtime plane, and createEvent is the remaining runtime-plane persisting budget
        mutation subject to the same Mode B invariant, so the runtime plane guards it on the
        same basis. Rationale: the close
        cascade revokes the tenant's API keys, so a closed tenant usually surfaces on this
        plane as 401 UNAUTHORIZED — but Mode B invariant (a) of that cascade requires that a
        mutation observed AFTER the CLOSED flip MUST NOT succeed even in the window before
        keys are revoked; this binding closes that race on the runtime plane.
        POST /v1/events has no dry_run or decide mode — it always persists — so on a FRESH
        (non-replay) request the closed-tenant outcome there is the 409 TENANT_CLOSED of the
        persisting surface; it never returns a decision=DENY. Everything below that the
        persisting surface inherits — fail-closed on a malformed/undeterminable tenant record
        (500 INTERNAL_ERROR), the not-applicable case when no governance plane exists, and the
        idempotent same-key replay exception (a same-key replay of a pre-close event returns the
        original stored 201 response, taking precedence over the guard exactly as on the
        reservation surface) — applies to createEvent unchanged.
        Non-persisting evaluations: POST /v1/reservations with dry_run=true and POST /v1/decide
        MUST NOT produce HTTP 409 TENANT_CLOSED for a closed owning tenant. A fresh (non-replay)
        evaluation MUST instead reflect the closed tenant as-if-live: decision=DENY with
        reason_code=TENANT_CLOSED. (Same-key replays of pre-close evaluations return the
        original stored response per the IDEMPOTENCY section — replay precedence applies here
        exactly as on the persisting surface.) Rationale: dry-run and /decide outcomes are attestations of
        what live execution would do (and MAY be captured as signed evidence per
        cycles-evidence-v0.2.yaml); an evaluation that ignores a durable CLOSED flip would
        attest ALLOW for a request whose live execution MUST fail.
        Guard evaluation (both surfaces): a tenant record with status CLOSED triggers the
        guard (409 on the persisting surface, decision=DENY on the non-persisting surface); a
        tenant record that exists but whose status cannot be determined (malformed or corrupt
        record) MUST fail closed with HTTP 500 INTERNAL_ERROR — on the non-persisting surface
        too, because the server cannot attest against corrupt governance state; a subject
        tenant with no tenant record is not guarded (there is no status to observe).
        Precedence: for non-replay mutations on a closed tenant's reservations,
        TENANT_CLOSED takes precedence over the reservation-state errors
        (RESERVATION_FINALIZED, RESERVATION_EXPIRED) — Rule 2 rejects "regardless of
        that child's own current status". Idempotent replays are the exception: a
        same-key replay of a mutation that succeeded BEFORE the close retains replay
        precedence and MUST return the original stored response payload per the
        IDEMPOTENCY section (consistent with Rule 2's invariant (b) — the cascade is
        idempotent and does not rewrite already-finalized outcomes).
        Cross-plane applicability: a deployment that operates a governance plane (tenant
        records exist) MUST enforce this guard — either by making the owning tenant's
        CLOSED status observable to the runtime plane, or by enforcing an equivalent
        post-flip mutation guard at a central enforcement point in front of these
        operations. The requirement is behavioral (the 409 TENANT_CLOSED rejection),
        not architectural: choosing not to wire tenant status through to the runtime
        plane does NOT exempt a deployment. Only deployments with NO governance plane
        at all (no tenant records exist anywhere in the deployment) have no tenant
        status to enforce — the rule is not applicable to them.
        Non-mutating reservation reads (GET /v1/reservations, GET /v1/reservations/{id})
        MUST NOT be rejected with TENANT_CLOSED: they remain available (subject to normal
        auth) on reservations of a CLOSED tenant for post-close audit, mirroring Rule 2's
        read-access rule.
      - Finalized reservations MUST return HTTP 409 with error=RESERVATION_FINALIZED.
      - Expired reservations MUST return HTTP 410 with error=RESERVATION_EXPIRED.
        (commit/release: beyond expires_at_ms + grace_period_ms; extend: beyond expires_at_ms;
        getReservation: any reservation whose status is EXPIRED — see that operation's
        EXPIRY note. Clarified in revision 2026-07-03; previously the parenthetical
        enumerated only the mutation endpoints, leaving the GET case ambiguous.)
      - Reservations that never existed MUST return HTTP 404 with error=NOT_FOUND.
      - HTTP 429 is reserved for server-side throttling/rate limiting (optional in v0), not deterministic budget exhaustion.
        429 responses carry error=LIMIT_EXCEEDED (added to the ErrorCode enum in revision 2026-07-04, mirroring the
        governance spec's code of the same name) plus the Retry-After and X-RateLimit-Reset headers.
      - Unit mismatch MUST return HTTP 400 with error=UNIT_MISMATCH in any of these cases:
          (a) reserve — estimate.unit does not match any budget stored for the derived scopes,
              but at least one of those scopes has a budget in a different unit;
          (b) commit — actual.unit differs from the reservation's estimate.unit;
          (c) event — actual.unit does not match the budget stored for the target scope;
          (d) decide — estimate.unit does not match any budget stored for the derived scopes,
              but at least one of those scopes has a budget in a different unit. This is an
              exception to /decide's general "return decision=DENY (200) without 4xx" pattern,
              which applies only to budget-state conditions (debt, overdraft, insufficient
              remaining), not request-validity errors like a wrong unit.
        When the cause is a wrong unit (rather than the absence of any budget at the scope),
        servers SHOULD populate the error response's `details` object with:
          - `scope` — the canonical scope identifier where the mismatch was detected
          - `requested_unit` — the unit supplied by the client
          - `expected_units` — array of units for which a budget does exist at that scope
        so clients can self-correct without a separate lookup. HTTP 404 with error=NOT_FOUND
        is reserved for the case where the target scope has no budget in ANY unit (the
        runtime plane uses the single NOT_FOUND code for all resource-not-found conditions;
        the message field carries the specific reason, e.g. "Budget not found for provided
        scope: ...").
      - For expiry comparisons, “now” refers to server time (not client-provided time).
      - When is_over_limit=true, server MUST return 409 OVERDRAFT_LIMIT_EXCEEDED for new reservations. 
        This takes precedence over DEBT_OUTSTANDING even when debt > 0.

    OVERDRAFT RECONCILIATION (NORMATIVE):
      - When concurrent commits cause debt > overdraft_limit on a scope, the server MUST mark that scope as "over-limit" (is_over_limit=true).
      - Over-limit scopes MUST reject ALL new reservation attempts with 409 OVERDRAFT_LIMIT_EXCEEDED until debt is reduced below overdraft_limit.
      - Operators reconcile over-limit scopes via budget funding operations (out-of-scope for this API). 
        When debt is repaid below overdraft_limit, is_over_limit automatically returns to false.
      - Servers SHOULD provide monitoring/alerting when scopes enter over-limit state:
          * Log events with scope identifier, current debt, and overdraft_limit
          * Optionally emit webhooks or notifications to operators
          * Optionally expose metrics endpoint showing over-limit scope count
      - Clients SHOULD handle 409 OVERDRAFT_LIMIT_EXCEEDED on reservation as a signal to wait/retry with exponential backoff, or escalate to operators.

    IDEMPOTENCY (NORMATIVE):
      - If X-Idempotency-Key header is present and body.idempotency_key is present, they MUST match.
      - Server MUST enforce idempotency per (effective tenant, endpoint, idempotency_key).
      - On replay of an idempotent request that previously succeeded, server MUST return the original successful
        response outcome and payload (including any server-generated identifiers such as reservation_id), except
        for volatile response observations defined below.
      - remaining_ttl_ms on a replayed createReservation or extendReservation success is a volatile response
        observation, not part of the attested CyclesEvidence payload. When emitted, the server MUST recompute it
        while constructing the replay response as
        max(0, original expires_at_ms - current authoritative server time); it MUST NOT replay the originally
        stored remaining_ttl_ms value. It MUST be 0 if the reservation is no longer ACTIVE and MAY
        conservatively understate current lead if a later, separately keyed extension moved expiry outward.
        All other fields replay verbatim as the original outcome.
      - If the same key is reused with a different request payload, server MUST return 409 IDEMPOTENCY_MISMATCH.
      - Servers SHOULD compare idempotency payloads using a canonical JSON representation
        (e.g., RFC 8785 JSON Canonicalization Scheme) or an equivalent stable serialization.

    SCOPE DERIVATION (NORMATIVE):
      - Server derives canonical scope identifiers and a canonical scope_path from Subject fields.
      - Canonical ordering is: tenant → workspace → app → workflow → agent → toolset.
      - Only explicitly provided subject levels are included in scope paths; intermediate gaps are skipped (not filled with "default").
      - Scopes without budgets are skipped during enforcement; at least one derived scope MUST have a budget.
      - affected_scopes returned by the server MUST be in that canonical order.

    RESERVATION LEASING (GUIDANCE):
      - To mitigate "zombie reservations" (client crash after reserve), SDKs SHOULD:
        * keep ttl_ms short (typically 10s–30s),
        * include modest estimation buffers when using overage_policy=REJECT,
        * reserve in small initial leases and increase gradually ("slow start") for long or bursty operations,
        * prefer chunked reserve/commit cycles for long-running actions rather than a single large reservation.

    OVERDRAFT MONITORING (GUIDANCE):
      - Implementations SHOULD provide visibility into over-limit states:
          * Dashboard showing scopes with is_over_limit=true
          * Alerts when debt exceeds overdraft_limit
          * Time-series metrics: debt_utilization = debt / overdraft_limit
      - Recommended alerting thresholds:
          * Warning at 80% of overdraft_limit
          * Critical at 100% (over-limit state)
      - Recommended operator runbook:
          1. Investigate which reservations caused the over-limit state
          2. Determine if overdraft_limit should be increased (normal variance) or if this represents anomalous consumption (incident)
          3. Fund the scope to repay debt below limit
          4. Monitor that is_over_limit returns to false
          5. Resume operations automatically

    CORRELATION AND TRACING (NORMATIVE, cross-plane):
      This section defines the cross-surface correlation contract for the entire Cycles
      spec family. It is normative for every Cycles server operation on every plane
      (runtime, governance-admin, action-kinds, and any extension that layers onto
      these bases). Companion specs SHOULD carry a brief pointer to this section and
      MUST NOT restate a conflicting contract.

      Three-tier correlation model:
        * request_id — one HTTP request grain. Set by the server. Echoed on
          X-Request-Id response header, on ErrorResponse, and on every event / audit
          entry that is causally downstream of the request (including entries emitted
          from queued or deferred work spawned by the request).
        * trace_id — logical-operation grain. W3C Trace Context-compatible. Accepted
          from inbound headers or generated by the server. Echoed on X-Cycles-Trace-Id
          response header and propagated to events, audit entries, and outbound
          webhook deliveries.
        * correlation_id — event-stream cluster grain. Set by the server as a
          deterministic hash over (tenant_id, scope, action_kind_or_risk_class,
          window, window_key) to JOIN threshold-alert → trip → reset chains and
          observed_denied ↔ reservation.denied pairs. Scoped to the event stream only.

      Inbound header precedence (server extracts trace_id by the first matching rule):
        1. `traceparent` header, if present AND parses as a valid W3C Trace Context
           value (version 00, non-all-zero trace-id, non-all-zero span-id) → use its
           trace-id (the leftmost 32-hex segment).
        2. Else `X-Cycles-Trace-Id` header, if present AND matches `^[0-9a-f]{32}$`
           AND is not all-zero → use its value directly.
        3. Else server generates a new trace_id: 16 random bytes encoded as 32
           lowercase hex characters. The all-zero value is invalid per W3C Trace
           Context §3.2.2.3 and MUST be re-rolled.

      Header validation and precedence rules:
        * A malformed `traceparent` OR malformed `X-Cycles-Trace-Id` MUST be treated
          as absent for that header; the server falls through to the next rule. The
          server MUST NOT reject a request for a malformed correlation header.
        * If both `traceparent` and `X-Cycles-Trace-Id` are present, both valid, but
          their trace-ids DISAGREE, `traceparent` wins (OpenTelemetry interop takes
          precedence over the flat convenience header). The server MAY log this
          condition for diagnostics but MUST NOT reject the request.

      Outbound response contract (every plane, every response):
        * Servers MUST echo `X-Cycles-Trace-Id` on every response (2xx, 4xx, 5xx).
          The header is declared as `X-Cycles-Trace-Id` in this document's
          `components.headers` and re-declared in the companion spec's own
          `components.headers` for OpenAPI tooling conformance.
        * ErrorResponse bodies MUST carry `trace_id` on every conformant error.
        * Events and audit-log entries causally downstream of the request MUST
          carry `trace_id`. See each companion spec's Event / AuditLogEntry schema.

      Propagation contract:
        * The server propagates trace_id onto: the audit-log entry for the request
          (one per authenticated request that hits the governance plane), every
          event emitted as a side effect of the request (runtime or governance),
          and every outbound webhook delivery. Propagation across thread, queue, or
          process boundaries is REQUIRED; loss at the request-thread boundary is
          non-compliant.
        * Outbound webhook deliveries carry `X-Cycles-Trace-Id` AND `traceparent`
          headers constructed as documented in the WEBHOOK EVENT GUIDANCE section
          below (including the trace-flags preservation rule).

      Format: `^[0-9a-f]{32}$` — 32 lowercase hex characters (128-bit trace ID).

      Backward compatibility:
        * `trace_id` is declared as an OPTIONAL property on ErrorResponse, Event,
          and AuditLogEntry schemas (no wire-contract break). Servers conformant
          with this section MUST populate it; clients MUST tolerate its absence on
          entries emitted by older servers.
        * Adding `X-Cycles-Trace-Id` as a response header is additive; clients that
          do not read the header are unaffected.
        * Accepting `traceparent` / `X-Cycles-Trace-Id` as inbound request headers
          is additive; clients that do not send them are unaffected.

    WEBHOOK EVENT GUIDANCE (GUIDANCE):
      Implementations MAY emit webhook events when runtime operations produce observable state changes.
      This enables operators and tenant applications to react to budget state transitions in real-time
      without polling. The webhook delivery system is separate from the protocol endpoints — it does not
      add new API paths to the runtime server.

      Event types emitted by the runtime server:
        * reservation.denied — Reserve or decide returned DENY (budget exceeded, overdraft limit, frozen, etc.)
        * reservation.commit_overage — Commit actual amount exceeded estimated amount
        * reservation.expired — Reservation TTL expired without commit or release (via background sweeper)
        * budget.exhausted — Remaining budget reached 0 after a reservation or event
        * budget.debt_incurred — Commit created new debt via ALLOW_WITH_OVERDRAFT policy
        * budget.over_limit_entered — is_over_limit flipped to true (debt > overdraft_limit)
        * budget.over_limit_exited — Debt repaid below overdraft_limit (via admin funding operations)
        * budget.threshold_crossed — Utilization crossed a configured threshold (e.g., 80%, 95%)
        * budget.burn_rate_anomaly — Spend rate exceeded baseline by configured multiplier

      Event types emitted by the admin/operator server:
        * tenant.created/updated/suspended/reactivated/closed — Tenant lifecycle
        * budget.created/updated/funded/debited/reset/debt_repaid/frozen/unfrozen/closed — Budget lifecycle
        * api_key.created/revoked/expired/permissions_changed/auth_failed — API key lifecycle
        * policy.created/updated/deleted — Policy lifecycle
        * system.store_connection_lost/restored, system.high_latency — System health
        * system.webhook_delivery_failed — Meta-alert for persistent delivery failures

      Standard event payload schema (JSON):
        * event_id (string, required) — Globally unique (e.g., "evt_01abc..."). Use for deduplication.
        * event_type (string, required) — Dotted format: "{category}.{action}" (e.g., "reservation.denied")
        * category (string, required) — One of: budget, reservation, tenant, api_key, policy, system
        * timestamp (string, date-time, required) — ISO 8601 UTC
        * tenant_id (string, required) — Tenant context. System events use "__system__".
        * scope (string, optional) — Full scope path affected (e.g., "tenant:acme/agent:bot")
        * actor (object, optional) — Who caused the event:
          { type: admin|api_key|admin_on_behalf_of|system|scheduler, key_id?, source_ip? }
          (admin_on_behalf_of: admin key exercising a tenant-scoped dual-auth
          operation; added in revision 2026-07-04, mirroring the governance
          spec's Event.actor.type enum)
        * source (string, required) — Service that emitted: "cycles-server", "cycles-admin", "expiry-sweeper"
        * data (object, optional) — Event-specific payload (varies by event_type)
        * correlation_id (string, optional) — Links related events for chain reconstruction
        * request_id (string, optional) — X-Request-Id from the originating HTTP request.
          MUST be populated on every event causally downstream of an HTTP request,
          including events emitted from queued, deferred, or otherwise-async work spawned
          by that request. MAY be absent on internal sweeper/expiry-generated events that
          have no originating HTTP request. See CORRELATION AND TRACING section below.
        * trace_id (string, optional, pattern ^[0-9a-f]{32}$) — W3C Trace Context trace-id
          for the logical operation. Populated on every event produced by a server that
          conforms to the CORRELATION AND TRACING contract below.
        * metadata (object, optional) — Operator-defined key-value pairs

      Webhook delivery protocol:
        * Delivery method: HTTP POST to subscriber's URL with JSON event payload as body
        * Delivery semantics: At-least-once. Consumers MUST deduplicate using event_id.
        * Ordering: Events for the same tenant are INITIALLY DISPATCHED in
          order; cross-tenant ordering NOT guaranteed. Clarified in revision
          2026-07-04: the ordering guarantee applies to first delivery
          attempts only. A failed delivery re-enters the queue after its
          retry backoff, so retried deliveries MAY arrive after later events
          for the same tenant — an unavoidable consequence of combining
          per-delivery retry with non-blocking dispatch. Consumers MUST NOT
          assume strict arrival ordering across retry boundaries; reconstruct
          order from the event envelope's `timestamp` (and `correlation_id`
          chains) rather than arrival order.
        * Non-blocking: Webhook delivery MUST NOT block the operation that produced the event.

      Required HTTP headers on webhook delivery:
        * Content-Type: application/json
        * X-Cycles-Event-Id: {event_id} — For deduplication
        * X-Cycles-Event-Type: {event_type} — For routing
        * X-Cycles-Signature: sha256={hex} — HMAC-SHA256 of raw request body using subscription's signing secret
        * X-Cycles-Trace-Id: {trace_id} — W3C Trace Context trace-id (32-hex) for the
          logical operation that produced this event. Always required; the server always
          has a trace_id per the CORRELATION AND TRACING fallback-generate rule.
        * traceparent: 00-{trace_id}-{fresh-span-id-16-hex}-{trace-flags} — W3C Trace
          Context version 00 header. Always required. trace_id MUST equal the value in
          X-Cycles-Trace-Id. span-id MUST be freshly generated for the outbound delivery
          (NOT reused from inbound). trace-flags rules:
            - If the inbound request to Cycles carried a valid `traceparent`, the server
              MUST preserve the inbound trace-flags byte on the outbound `traceparent`
              (so a `sampled=0` upstream is not silently flipped to `sampled=1`).
            - If the trace was derived from `X-Cycles-Trace-Id` (no inbound W3C
              `traceparent`) OR generated fresh by the server, the server uses a default
              trace-flags value of `01` (sampled).
          The `trace_id` field also appears in the event envelope body so subscribers
          unfamiliar with W3C Trace Context can still correlate via the JSON payload.
        * User-Agent: {service-name}/{version}
        * Custom headers from subscription configuration (e.g., Authorization)

      Signature verification (X-Cycles-Signature):
        * Algorithm: HMAC-SHA256
        * Input: Raw JSON request body (bytes, not parsed)
        * Key: Subscription's signing_secret (UTF-8 encoded)
        * Format: "sha256=" + lowercase hex encoding of HMAC digest
        * Consumers SHOULD verify the signature before processing the event.
        * Use constant-time comparison (e.g., hmac.compare_digest) to prevent timing attacks.

      Retry and failure handling:
        * On non-2xx response: exponential backoff retry (default: 5 retries, 1s/2s/4s/8s/16s, max 60s)
        * After all retries exhausted: delivery marked FAILED, system.webhook_delivery_failed event emitted
        * After N consecutive failures (default 10): subscription auto-disabled (status → DISABLED)
        * Disabled subscriptions can be re-enabled via admin API (resets failure counter)

      Retention:
        * Event records: 90 days hot storage (recommended). TTL enforced via Redis EXPIRE.
        * Delivery records: 14 days (operational debugging data).
        * ZSET index entries: Trimmed hourly by background cleanup job.
        * Stale deliveries: Deliveries older than 24h (configurable) are auto-failed on pickup
          to prevent delivering ancient webhooks after prolonged service outage.

      Extensibility:
        * New event types MAY be added in future versions without a breaking change.
        * Consumers MUST ignore unrecognized event types gracefully.
        * Custom event types MUST use a "custom." prefix (e.g., "custom.billing.invoice_sent").

servers:
  - url: https://api.cycles.local
    description: Replace with your implementation endpoint

tags:
  - name: Decisions
    description: Optional preflight checks (no reservation created)
  - name: Reservations
    description: Reservation, commit, release, and (optional) get-by-id operations
  - name: Balances
    description: Query balances for operator visibility
  - name: Events
    description: Optional post-only accounting for non-estimable actions
  - name: Evidence
    description: Public retrieval of signed CyclesEvidence envelopes by content id

security:
  - ApiKeyAuth: []

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-Cycles-API-Key
    # Added 2026-04-13 for admin-on-behalf-of access on a small set of
    # ops-critical reservation endpoints (list / get / release). Same
    # header the governance-admin spec already uses, so admin operators
    # can authenticate against the runtime plane with one key.
    AdminKeyAuth:
      type: apiKey
      in: header
      name: X-Admin-API-Key

  headers:
    X-Request-Id:
      description: Unique request identifier for debugging
      schema:
        type: string
    X-Cycles-Trace-Id:
      description: >-
        W3C Trace Context-compatible correlation identifier echoed on every
        response (2xx, 4xx, 5xx) on every plane. 32 lowercase hex characters
        (128-bit trace ID, matching the W3C Trace Context trace-id field).
        Links the request, its audit entry, all side-effect events, and any
        outbound webhook deliveries produced by the request. See the
        "CORRELATION AND TRACING" section of this document's `info.description`
        for inbound-header precedence, fallback-generation rules, and
        cross-surface propagation contract.
      schema:
        type: string
        pattern: ^[0-9a-f]{32}$
    X-RateLimit-Remaining:
      description: Number of requests remaining in current window (optional in v0)
      schema:
        type: integer
    X-RateLimit-Reset:
      description: Unix timestamp (seconds) when rate limit resets (optional in v0)
      schema:
        type: integer
        format: int64
    Cache-Control:
      description: >-
        Caching directive. For content-addressed responses (e.g. getEvidence)
        the server SHOULD return `public, immutable` — the body never changes.
      schema:
        type: string
    Retry-After:
      description: >-
        On 429, the non-negative delta-seconds a client SHOULD wait before
        retrying (optional in v0). This API intentionally supports only the
        delta-seconds form of HTTP Retry-After; the HTTP-date form is invalid
        for this contract because converting it to a safe monotonic delay
        would require wall-clock assumptions that lease scheduling forbids.
      schema:
        type: integer
        format: int64
        minimum: 0
    X-Cycles-Tenant:
      description: Effective tenant identifier derived from auth context (optional in v0)
      schema:
        type: string
    Date:
      description: >-
        Standard HTTP origination timestamp (RFC 9110 §6.6.1, IMF-fixdate per
        §5.6.7). Declared here — OPTIONAL — purely as documentation of standard
        HTTP behavior on the reservation responses. It plays NO role in the
        HEARTBEAT GUIDANCE on extendReservation and MUST NOT be used for lease
        arithmetic or heartbeat scheduling: Date has whole-second resolution,
        is a best-effort timestamp that may be generated at any point during
        message origination or replaced by intermediaries, and a server may
        derive `expires_at_ms` and Date from DIFFERENT clocks (e.g. a data
        store's clock vs the HTTP container's), so `expires_at_ms − Date` is
        not a same-frame measurement.
      schema:
        type: string

  parameters:
    IdempotencyKeyHeader:
      name: X-Idempotency-Key
      in: header
      required: false
      description: >-
        Optional idempotency key header. If both header and body idempotency_key are provided, they MUST match.
        Server MUST enforce idempotency per endpoint by (effective tenant, endpoint, idempotency_key).
        On replay of an idempotent request that previously succeeded, server MUST return the original successful
        response outcome and payload (including any server-generated identifiers such as reservation_id), except
        that remaining_ttl_ms on a replayed createReservation or extendReservation success is a volatile response
        observation and is not part of the attested CyclesEvidence payload. When emitted, it MUST be recomputed
        from the original expires_at_ms and the current authoritative server time, never copied from the stored
        response; it MUST be 0 if the reservation is no longer ACTIVE. All other fields replay verbatim.
      schema:
        $ref: '#/components/schemas/IdempotencyKey'

    ReservationId:
      name: reservation_id
      in: path
      required: true
      schema:
        type: string
        minLength: 1
        maxLength: 128

    EvidenceId:
      name: evidence_id
      in: path
      required: true
      description: sha256 content hash of the CyclesEvidence envelope (64 lowercase hex chars).
      schema:
        type: string
        pattern: '^[0-9a-f]{64}$'

    Limit:
      name: limit
      in: query
      required: false
      schema:
        type: integer
        minimum: 1
        maximum: 200
        default: 50
      description: Maximum number of results to return

    Cursor:
      name: cursor
      in: query
      required: false
      schema:
        type: string
      description: Opaque cursor from previous response

  responses:
    ErrorResponse:
      description: Error response
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        X-Cycles-Trace-Id:
          $ref: '#/components/headers/X-Cycles-Trace-Id'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

  schemas:
    CyclesEvidenceEnvelope:
      type: object
      additionalProperties: true
      description: >-
        A signed CyclesEvidence envelope returned by `getEvidence`. The
        NORMATIVE definition — field semantics and the JCS content-hash +
        Ed25519 signature derivation — lives in
        `cycles-evidence-v0.2.yaml`; this schema mirrors its required
        shape for the serving endpoint. The envelope is content-addressed
        (`evidence_id` = sha256 of the JCS-canonical bytes with `evidence_id`
        and `signature` emptied) and self-verifying, so a consumer can check
        it offline without trusting this server.
      required:
        - schema_version
        - artifact_type
        - server_id
        - signer_did
        - issued_at_ms
        - payload
        - evidence_id
        - signature
      properties:
        schema_version:
          type: string
          const: cycles-evidence/v0.1
        artifact_type:
          type: string
          enum: [decide, reserve, commit, release, error]
        server_id:
          type: string
          description: Stable URI of the issuing Cycles server.
        signer_did:
          type: string
          description: Ed25519 public key (v0.1 hex) the signature verifies against.
        issued_at_ms:
          type: integer
          format: int64
        trace_id:
          type: string
        payload:
          type: object
          description: One key matching artifact_type (decide/reserve/commit/release/error).
        evidence_id:
          type: string
          pattern: '^[0-9a-f]{64}$'
        signature:
          type: string
          pattern: '^[0-9a-f]{128}$'

    CyclesEvidenceJwks:
      type: object
      additionalProperties: true
      description: >-
        The issuing server's signer key set, returned by `getEvidenceJwks`. An
        RFC 7517 JWK Set (extra top-level members allowed and ignored). The
        NORMATIVE resolution algorithm — `did:cycles` binding, the
        validity-window key selection (pick the key whose
        `[cycles_nbf_ms, cycles_exp_ms)` covers the envelope's `issued_at_ms`,
        never "the current key"), the deterministic-selection rules, and the
        verification dispositions — lives in `cycles-evidence-v0.2.yaml`;
        this schema mirrors its required shape for the serving endpoint.
      required: [keys]
      properties:
        keys:
          type: array
          items: {$ref: '#/components/schemas/CyclesEvidenceJwk'}

    CyclesEvidenceJwk:
      type: object
      additionalProperties: true
      description: >-
        One Ed25519 signing key with a Cycles validity window. RFC 7517 /
        RFC 8037 (OKP/Ed25519) JWK plus the `cycles_*` members. A retired key
        MUST remain in the set so envelopes signed before a rotation still
        verify. See `cycles-evidence-v0.2.yaml` for full semantics.
      required: [kty, crv, x, kid, cycles_nbf_ms]
      properties:
        kty:
          type: string
          const: OKP
        crv:
          type: string
          const: Ed25519
        alg:
          type: string
          const: EdDSA
          description: >-
            Optional (RFC 7517 §4.4); if present MUST be `EdDSA`. A
            present-but-different `alg` makes the JWK invalid (excluded).
        x:
          type: string
          description: >-
            base64url (unpadded) of the raw 32-byte Ed25519 public key. Equals
            `base64url(hex-decode(signer_did))` for a raw-hex `signer_did`.
        kid:
          type: string
          description: >-
            Stable key id; equals the `#<kid>` fragment of a `did:cycles`
            `signer_did`.
        cycles_nbf_ms:
          type: integer
          format: int64
          description: Valid-from, epoch ms, INCLUSIVE.
        cycles_exp_ms:
          type: [integer, "null"]
          format: int64
          description: Valid-until, epoch ms, EXCLUSIVE. Absent/null ⇒ active (open-ended).
        status:
          type: string
          enum: [active, retired]
          description: >-
            Advisory only. Selection is by validity WINDOW, never by `status`.

    CyclesEvidenceRef:
      type: object
      additionalProperties: false
      description: >-
        Reference to the CyclesEvidence envelope emitted for an operation. The
        `evidence_id` is the sha256 content hash of the JCS-canonical envelope
        and is computed SYNCHRONOUSLY at decision time, so it is returned on the
        operation response even though the envelope is Ed25519-signed and stored
        ASYNCHRONOUSLY. A caller (e.g. an APS gateway) binds its own signed
        receipt to this evidence by recording `evidence_id`, then fetches the
        envelope at `cycles_evidence_url` (see `getEvidence`) to verify it
        offline. Because signing/storage is async, the envelope MAY be served
        shortly after this response — consumers SHOULD treat a transient `404`
        from `getEvidence` as not-yet-available and retry.


        TRANSPORT METADATA, NOT ATTESTED: `cycles_evidence` is added to the
        response for the caller's convenience and is NOT part of the evidence
        the envelope attests. `remaining_ttl_ms` on ReservationCreateResponse
        is likewise a volatile transport observation rather than attested
        decision data, so a same-key replay can safely recompute it. The
        `evidence_id` is computed over the operation response WITHOUT either
        transport-only field — the envelope's `payload.<artifact>.response`
        mirror (`cycles-evidence-v0.2.yaml`) keeps `additionalProperties:
        false` and omits them. Implementations MUST compute `evidence_id`
        before stamping transport-only fields onto the response, and MUST NOT
        include either field in the attested payload.
      required: [evidence_id, cycles_evidence_url]
      properties:
        evidence_id:
          type: string
          pattern: '^[0-9a-f]{64}$'
          description: sha256 content id of the emitted envelope; resolve via getEvidence.
        cycles_evidence_url:
          type: string
          format: uri
          description: >-
            Absolute URL of the signed envelope, formed as
            `{server_id}/evidence/{evidence_id}`. Note `server_id` is already the
            canonical deployment base INCLUDING the `/v1` prefix (e.g.
            `https://cycles.example.com/v1`), so the join adds only
            `/evidence/{evidence_id}` — it MUST NOT re-add `/v1`.

    ReservationEvidence:
      type: object
      additionalProperties: false
      description: >-
        Map of artifact type to the CyclesEvidence reference emitted for that
        operation on a reservation. Lets a consumer jump from a reservation
        straight to its signed envelope(s) via `getEvidence` without having
        captured the `evidence_id` off the original reserve / commit / release
        response. Keys are the reservation-applicable artifact types: a
        reservation has at most a `reserve` entry plus one terminal entry
        (`commit` XOR `release`). TRANSPORT METADATA, NOT ATTESTED (see
        `CyclesEvidenceRef`) — each entry is recorded after its artifact's
        `evidence_id` was computed and is not part of any attested payload.
      properties:
        reserve:
          $ref: '#/components/schemas/CyclesEvidenceRef'
          description: Evidence emitted for the reserve operation, if any.
        commit:
          $ref: '#/components/schemas/CyclesEvidenceRef'
          description: Evidence emitted for the commit operation (COMMITTED reservations).
        release:
          $ref: '#/components/schemas/CyclesEvidenceRef'
          description: Evidence emitted for the release operation (RELEASED reservations).

    IdempotencyKey:
      type: string
      minLength: 1
      maxLength: 256

    ErrorCode:
      type: string
      enum:
        - INVALID_REQUEST
        - UNAUTHORIZED
        - FORBIDDEN
        - NOT_FOUND
        - BUDGET_EXCEEDED
        - BUDGET_FROZEN
        - BUDGET_CLOSED
        - RESERVATION_EXPIRED
        - RESERVATION_FINALIZED
        - IDEMPOTENCY_MISMATCH
        - UNIT_MISMATCH
        - OVERDRAFT_LIMIT_EXCEEDED
        - DEBT_OUTSTANDING
        - MAX_EXTENSIONS_EXCEEDED
        - LIMIT_EXCEEDED
        - TENANT_CLOSED
        - INTERNAL_ERROR

    ErrorResponse:
      type: object
      required: [error, message, request_id]
      additionalProperties: false
      example:
        error: BUDGET_EXCEEDED
        message: Insufficient remaining budget for scope tenant:acme-corp
        request_id: req_abc123def456
        trace_id: 4bf92f3577b34da6a3ce929d0e0e4736
      properties:
        error:
          $ref: '#/components/schemas/ErrorCode'
        message:
          type: string
        request_id:
          type: string
        trace_id:
          type: string
          pattern: ^[0-9a-f]{32}$
          description: >-
            W3C Trace Context-compatible correlation identifier. 32 lowercase
            hex characters (128-bit trace ID, matching the W3C Trace Context
            trace-id field in the `traceparent` header). Links this error
            response to the request that triggered it and to any events /
            audit entries emitted before the error was returned. Optional on
            the wire for forward compatibility with historical responses
            emitted by servers older than the first release that populated
            this field, but servers conformant with the CORRELATION AND
            TRACING contract MUST populate it on every error response. See
            the "CORRELATION AND TRACING" section of this document's
            `info.description` for header formats and propagation rules.
        details:
          type: object
          additionalProperties: true
        cycles_evidence:
          $ref: '#/components/schemas/CyclesEvidenceRef'
          description: >-
            Reference to the CyclesEvidence envelope emitted for this error
            (artifact_type `error`). Present when the server emitted an `error`
            CyclesEvidence record for this response — most importantly the
            non-dry reserve denials that surface as HTTP 409 `BUDGET_EXCEEDED`
            (and the other live denial codes), which are the highest-signal
            evidence an APS receipt can bind to. A denied caller reads
            `evidence_id` to bind its own signed receipt to this denial, then
            fetches the envelope at `cycles_evidence_url` (see `getEvidence`).
            Absent when evidence emission is disabled on the server, or for
            errors raised before evidence could be emitted (e.g. request
            validation / auth failures). TRANSPORT METADATA, NOT ATTESTED — as
            with the success responses, `evidence_id` is computed over the
            `ErrorResponse` body WITHOUT this field (the `error` artifact's
            `payload.error.response` mirror omits it); see `CyclesEvidenceRef`.

    DecisionEnum:
      type: string
      enum: [ALLOW, ALLOW_WITH_CAPS, DENY]

    DecisionReasonCode:
      type: string
      maxLength: 128
      description: >-
        Stable machine-readable reason for why a /decide call or a reservation
        returned decision=DENY. Populated on DecisionResponse.reason_code and on
        ReservationCreateResponse.reason_code. The human-readable `message` field
        on the parent response (when present) may carry additional context.

        EXTENSIBILITY (NORMATIVE):
          DecisionReasonCode is intentionally an OPEN string (not a closed enum)
          so that:
            (a) future minor versions of this base spec can add new values without
                a breaking change, and
            (b) companion extension specs (e.g., cycles-protocol-extensions-v0.1.26.yaml)
                can define additional reason codes without modifying the base schema.
          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").

        KNOWN VALUES (v0.1.25 base):
          - BUDGET_EXCEEDED — remaining amount insufficient on at least one derived scope
            (evaluated against the requested estimate.amount).
          - BUDGET_FROZEN — a derived scope has a budget in FROZEN status
            (operator-set, no mutations allowed).
          - BUDGET_CLOSED — a derived scope has a budget in CLOSED status
            (permanently closed).
          - BUDGET_NOT_FOUND — no budget exists at any derived scope in the requested unit.
            On non-dry reserve and /v1/events paths this same condition surfaces as HTTP
            404 with error=NOT_FOUND instead.
          - OVERDRAFT_LIMIT_EXCEEDED — either (a) debt + delta > overdraft_limit on commit,
            or (b) the scope is in over-limit state (is_over_limit=true) and no new
            reservations are permitted until reconciled.
          - DEBT_OUTSTANDING — a derived scope has debt > 0 and overdraft_limit == 0
            (no policy permits further debt accrual).
          - TENANT_CLOSED — the owning tenant's status is CLOSED (deployments with a
            governance plane; added in revision 2026-07-10). Populated on /decide and
            dry_run=true evaluations; the persisting mutation surface reports the same
            condition as HTTP 409 error=TENANT_CLOSED instead (see the closed-tenant
            binding in ERROR SEMANTICS).

        KNOWN VALUES (v0.1.26 runtime extension):
          - ACTION_QUOTA_EXCEEDED — a per-kind or risk-class action quota rule was
            exceeded for the target scope and window.
          - ACTION_KIND_DENIED — the action kind is in the matching policy's
            denied_action_kinds list.
          - ACTION_KIND_NOT_ALLOWED — the matching policy has a non-empty
            allowed_action_kinds list and the action kind is not in it.
          See cycles-protocol-extensions-v0.1.26.yaml for full semantics,
          evaluation order, and DenyDetail structure (populated alongside
          reason_code for v0.1.26 denials).

        Distinct from the `ErrorCode` enum: reason codes appear only on 200 OK
        responses with decision=DENY; ErrorCodes appear only on 4xx/5xx responses
        with an `error` field. Some labels overlap (e.g. BUDGET_EXCEEDED appears
        in both sets) because the same underlying condition is reported two ways
        depending on the endpoint: /decide and dry_run surface it as a non-4xx
        DENY decision, while non-dry reserve surfaces it as a 409 error.

        SDK GENERATION GUIDANCE:
          Codegen tools SHOULD generate a string type (not a strict enum) and
          expose the known values above as constants or string literal union
          members for ergonomic use. SDK code MUST NOT reject unknown values
          at the deserialization boundary.

    UnitEnum:
      type: string
      description: >
        Standard units.

        USD_MICROCENTS preserves precision for per-call and batched accounting (int64).
          - 1 USD_MICROCENTS = 10^-6 cents = 10^-8 dollars
          - 1 USD = 100 cents = 10^8 USD_MICROCENTS
          - Max int64 ≈ 9.22e18 USD_MICROCENTS ≈ $92.2B

        TOKENS are integer token counts.
        CREDITS/RISK_POINTS are generic integer units (optional in v0 implementations).
      enum: [USD_MICROCENTS, TOKENS, CREDITS, RISK_POINTS]

    SortDirection:
      type: string
      enum: [asc, desc]
      default: desc
      description: >-
        Sort direction for list endpoints. Used with per-endpoint
        `sort_by` parameters (introduced in revision 2026-04-16 on
        listReservations). When `sort_by` is provided but `sort_dir`
        is omitted, servers MUST default to "desc". Servers that do
        not recognize the parameter MUST ignore it without error
        (additive-parameter guarantee). This schema is carried
        inline in each spec of the family (protocol + governance);
        the companion-specs publication model does not use cross-spec
        $ref.

    Amount:
      type: object
      required: [unit, amount]
      additionalProperties: false
      example:
        unit: USD_MICROCENTS
        amount: 500000
      properties:
        unit:
          $ref: '#/components/schemas/UnitEnum'
        amount:
          type: integer
          format: int64
          minimum: 0

    SignedAmount:
      type: object
      required: [unit, amount]
      additionalProperties: false
      description: >-
        Like Amount, but allows negative values. Used for Balance.remaining which can be negative in overdraft scenarios.
      example:
        unit: USD_MICROCENTS
        amount: -120000
      properties:
        unit:
          $ref: '#/components/schemas/UnitEnum'
        amount:
          type: integer
          format: int64
          description: >-
            Signed integer amount. Can be negative when representing remaining balance in overdraft state
            (debt > allocated - spent - reserved).

    Subject:
      type: object
      description: >
        Dimension bag for hierarchical budgets. At least one standard field (tenant, workspace, app, workflow, agent, or toolset) MUST be provided.
        A subject containing only `dimensions` is invalid; server MUST return 400 INVALID_REQUEST. 
        Hierarchy: tenant → workspace → app → workflow → agent → toolset.

        CHARSET (NORMATIVE, revision 2026-07-03):
        - Standard-field values (tenant/workspace/app/workflow/agent/toolset)
          SHOULD match ^[a-zA-Z0-9_.-]+$. Canonical scope identifiers and scope
          paths use ":" and "/" as structural delimiters, so delimiter,
          whitespace, and control characters have no stable canonical encoding.
        - Servers MAY reject standard-field values outside this pattern with
          400 INVALID_REQUEST (the reference implementation does). Clients
          MUST NOT rely on out-of-pattern values being accepted, and portable
          clients SHOULD restrict themselves to the pattern above.

        EXTENSIBILITY (v0):
        - dimensions is an optional, user-defined map for alternative taxonomies
          (e.g., cost_center/department/project) and policy/reporting.
        - v0 servers MAY ignore dimensions for budgeting decisions; if they do, they still MUST accept and round-trip it.
        - Keys SHOULD be lowercase and match ^[a-z0-9_.-]+$ for stable canonicalization; values are opaque strings.
      additionalProperties: false
      minProperties: 1
      anyOf:
        - required: [tenant]
        - required: [workspace]
        - required: [app]
        - required: [workflow]
        - required: [agent]
        - required: [toolset]
      properties:
        tenant:
          type: string
          maxLength: 128
        workspace:
          type: string
          maxLength: 128
        app:
          type: string
          maxLength: 128
        workflow:
          type: string
          maxLength: 128
        agent:
          type: string
          maxLength: 128
        toolset:
          type: string
          maxLength: 128
        dimensions:
          type: object
          description: Optional custom dimensions for enterprise taxonomies and policy/reporting.
          additionalProperties:
            type: string
            maxLength: 256
          maxProperties: 16

    Action:
      type: object
      required: [kind, name]
      additionalProperties: false
      properties:
        kind:
          type: string
          maxLength: 64
          description: >
            Action type identifier. Recommended format: <category>.<operation>
            Examples: llm.completion, llm.embedding, tool.search, tool.calculator,
            db.query, db.write, http.get, http.post, file.upload, message.email, webhook.outbound
        name:
          type: string
          maxLength: 256
          description: Provider/model/tool identifier (e.g., "openai:gpt-4o-mini", "web.search")
        tags:
          type: array
          maxItems: 10
          items:
            type: string
            maxLength: 64
          description: Optional policy tags (e.g., ["prod","customer-facing"])

    Caps:
      type: object
      description: >
        Optional soft-landing constraints returned by /decide or reservation ALLOW_WITH_CAPS.
        v0 intentionally keeps caps simple and concrete (no condition language).

        PRECEDENCE RULES:
        - If tool_allowlist is non-empty, ONLY those tools are allowed (denylist ignored).
        - Otherwise, if tool_denylist is non-empty, all tools EXCEPT those are allowed.
        - If both are empty/null, no tool restrictions apply.
        - Tool names are case-sensitive and match Action.name exactly.
      additionalProperties: false
      properties:
        max_tokens:
          type: integer
          minimum: 0
        max_steps_remaining:
          type: integer
          minimum: 0
        tool_allowlist:
          type: array
          items:
            type: string
            maxLength: 256
        tool_denylist:
          type: array
          items:
            type: string
            maxLength: 256
        cooldown_ms:
          type: integer
          minimum: 0

    # ---- Decide (optional) ----
    DecisionRequest:
      type: object
      required: [idempotency_key, subject, action, estimate]
      additionalProperties: false
      properties:
        idempotency_key:
          $ref: '#/components/schemas/IdempotencyKey'
        subject:
          $ref: '#/components/schemas/Subject'
        action:
          $ref: '#/components/schemas/Action'
        estimate:
          $ref: '#/components/schemas/Amount'
        metadata:
          type: object
          additionalProperties: true

    DecisionResponse:
      type: object
      required: [decision]
      additionalProperties: false
      example:
        decision: ALLOW
        affected_scopes:
          - "tenant:acme-corp"
          - "tenant:acme-corp/workspace:prod"
      properties:
        decision:
          $ref: '#/components/schemas/DecisionEnum'
          description: >
            ALLOW indicates sufficient budget exists. ALLOW_WITH_CAPS indicates budget exists 
            but soft constraints apply. DENY indicates insufficient budget or policy block.
            Note: /decide does not create a reservation; a subsequent reservation call may 
            still fail if concurrent activity depletes budget between the two calls.
        caps:
          $ref: '#/components/schemas/Caps'
          description: Present only when decision=ALLOW_WITH_CAPS; MUST be absent otherwise.
        reason_code:
          $ref: '#/components/schemas/DecisionReasonCode'
        retry_after_ms:
          type: integer
          minimum: 0
        affected_scopes:
          type: array
          items:
            type: string
          description: Canonical scope identifiers impacted by this decision, in canonical order.
        cycles_evidence:
          $ref: '#/components/schemas/CyclesEvidenceRef'
          description: >-
            Reference to the CyclesEvidence envelope emitted for this decision
            (artifact_type `decide`, present on ALLOW / ALLOW_WITH_CAPS / DENY).
            Absent only when evidence emission is disabled on the server.

    # ---- Reservations (core) ----
    CommitOveragePolicy:
      type: string
      description: >
        How server handles commits where actual > reserved.
        
        REJECT: reject commit (client must reserve a buffer or over-estimate).
          Recommended: add 10-20% buffer to estimates when using REJECT.
          If the action already happened externally, this creates an unaccounted gap.
        
        ALLOW_IF_AVAILABLE (default): commit always succeeds when actual > reserved.
        If remaining budget supports the full delta, charge it atomically across all derived scopes.
        Otherwise, cap the delta to available remaining (minimum across all affected scopes, floor 0),
        charge estimate + capped_delta, and set is_over_limit=true on scopes where the full delta
        could not be covered. This blocks future reservations until reconciled.
        Never creates debt. Never rejects a commit — the action already happened.
        
        ALLOW_WITH_OVERDRAFT: if remaining budget supports the delta, commit normally.
        Otherwise, check if (debt + delta) <= overdraft_limit across all affected scopes.
          If yes: commit succeeds, add delta to debt, remaining can go negative.
          If no: commit fails with 409 OVERDRAFT_LIMIT_EXCEEDED.
        This policy ensures the ledger always reflects reality even when budget is exhausted at commit time.
        
        CONCURRENCY (NORMATIVE):
          - The overdraft_limit check is performed per-commit and is NOT atomic across concurrent commits to the same scope.
          - Multiple commits may each individually pass the (debt + delta) <= limit check but collectively cause debt to exceed overdraft_limit.
          - This is acceptable: all commits succeed (actions already happened), and the scope enters "over-limit" state, 
            blocking future reservations until reconciled.
          - Servers SHOULD use optimistic locking or compare-and-swap on debt updates to ensure individual commit atomicity, 
            but cross-commit atomicity is not required.
      enum: [REJECT, ALLOW_IF_AVAILABLE, ALLOW_WITH_OVERDRAFT]

    ReservationStatus:
      type: string
      description: Reservation lifecycle state (v1+ may add additional terminal states).
      enum: [ACTIVE, COMMITTED, RELEASED, EXPIRED]

    ReservationCreateRequest:
      type: object
      required: [idempotency_key, subject, action, estimate]
      additionalProperties: false
      example:
        idempotency_key: "idem_20260412_run42_step1"
        subject:
          tenant: acme-corp
          workspace: prod
          agent: summarizer
        action:
          kind: llm.completion
          name: summarize-document
        estimate:
          unit: USD_MICROCENTS
          amount: 500000
        ttl_ms: 30000
      properties:
        idempotency_key:
          $ref: '#/components/schemas/IdempotencyKey'
        subject:
          $ref: '#/components/schemas/Subject'
        action:
          $ref: '#/components/schemas/Action'
        estimate:
          $ref: '#/components/schemas/Amount'
        ttl_ms:
          type: integer
          minimum: 1000
          maximum: 86400000
          default: 60000
        grace_period_ms:
          type: integer
          minimum: 0
          maximum: 60000
          default: 5000
          description: >
            Grace window after TTL for in-flight commits. Default 5s.

            OPERATIONAL GUIDANCE:
            - For high-latency actions (streaming LLM, slow APIs), consider setting higher (10-30s).

            INTEROP / SAFETY RATIONALE:
            - The 60s maximum is an interoperability ceiling to limit long-lived "in-flight" commit windows and reduce
              zombie-reservation and abuse surface (grace extends the time that a crashed client can keep budget locked).

            LONG-RUNNING WORKFLOWS:
            - For multi-minute operations, clients SHOULD keep ttl_ms relatively short and use /v1/reservations/{reservation_id}/extend
              as a heartbeat, and/or use chunked reserve/commit cycles rather than relying on large grace_period_ms.
        overage_policy:
          $ref: '#/components/schemas/CommitOveragePolicy'
          default: ALLOW_IF_AVAILABLE
        dry_run:
          type: boolean
          default: false
          description: >
            Shadow-mode evaluation. If true, the server MUST evaluate the reservation request and return
            decision/caps/affected_scopes as if it would reserve, but MUST NOT modify balances, persist a
            reservation, or require commit/release. Intended for safe rollout and testing of full reserve-path logic.
        metadata:
          type: object
          additionalProperties: true

    Balance:
      type: object
      required: [scope, scope_path, remaining]
      additionalProperties: false
      example:
        scope: "tenant:acme-corp/workspace:prod"
        scope_path: "tenant:acme-corp/workspace:prod"
        remaining:
          unit: USD_MICROCENTS
          amount: 4577000
        reserved:
          unit: USD_MICROCENTS
          amount: 500000
        spent:
          unit: USD_MICROCENTS
          amount: 423000
        allocated:
          unit: USD_MICROCENTS
          amount: 5500000
      description: >-
        Ledger state for a single (scope, unit) balance.

        UNIT CONSISTENCY (NORMATIVE):
          - All Amount fields within a Balance (remaining/reserved/spent/allocated/debt/overdraft_limit), if present, MUST share the same unit.
          - Servers MUST NOT emit a Balance with mixed units; clients MAY treat such responses as invalid.

        LEDGER INVARIANT (NORMATIVE):
           - If allocated, spent, reserved, and debt are all present, then remaining MUST satisfy: 
             remaining.amount = allocated.amount - spent.amount - reserved.amount - debt.amount (same unit).
           - Note that remaining.amount can be negative when debt exceeds available budget.
           - If allocated is absent, remaining is authoritative and clients MUST treat it as opaque (i.e., MUST NOT derive it from other fields).
           - Servers MUST NOT emit a Balance where the invariant is violated when all fields are present.
        
        DEBT SEMANTICS (NORMATIVE):
           - debt represents actual consumption that occurred when insufficient budget was available to cover a commit overage.
           - debt is created only when overage_policy=ALLOW_WITH_OVERDRAFT and (remaining + released_reservation) < actual at commit time.
           - When debt > 0, new reservations MUST be rejected with 409 DEBT_OUTSTANDING (unless explicitly allowed by policy).
           - debt MUST be repaid via budget funding operations (out-of-scope for this API) before new reservations are permitted.
           - When budget is added to a scope with debt > 0, debt MUST be repaid first, then remaining funds added to remaining.
        
        OVERDRAFT LIMIT (NORMATIVE):
           - overdraft_limit defines the target maximum debt for this scope.
           - If overdraft_limit is absent or amount=0, no overdraft is permitted (behaves as ALLOW_IF_AVAILABLE).
           - The overdraft_limit is enforced per-commit but is NOT atomic across concurrent commits.
             Multiple concurrent commits MAY cause cumulative debt to briefly exceed overdraft_limit.
           - Individual commits are rejected with 409 OVERDRAFT_LIMIT_EXCEEDED only if (current_debt + delta) > overdraft_limit 
             at the moment of that specific commit.
           - When debt > overdraft_limit after reconciliation (due to concurrent commits), the scope enters an "over-limit" state:
               * The server MUST set is_over_limit=true on the Balance
               * ALL new reservations against that scope MUST be rejected with 409 OVERDRAFT_LIMIT_EXCEEDED (not DEBT_OUTSTANDING)
               * Existing active reservations MAY be committed or released normally
               * The server SHOULD emit alerts/events for operator intervention
           - Once debt is repaid below overdraft_limit, is_over_limit returns to false and normal reservation operation resumes.
      properties:
        scope:
          type: string
          description: Canonical scope identifier (server-derived), e.g. "tenant:t1", "workflow:run123"
        scope_path:
          type: string
          description: Canonical hierarchical path (server-derived)
        remaining:
          $ref: '#/components/schemas/SignedAmount'
          description: >-
            Available budget for new reservations. Can be negative when debt exceeds allocated budget.
            Formula: remaining = allocated - spent - reserved - debt
        reserved:
          $ref: '#/components/schemas/Amount'
          description: Amount currently locked by active reservations
        spent:
          $ref: '#/components/schemas/Amount'
          description: Amount successfully committed through reservations
        debt:
          $ref: '#/components/schemas/Amount'
          description: >-
            Overdraft amount from commits where actual > reserved and insufficient budget existed to cover the delta.
            Represents consumption that happened but couldn't be paid from available budget at commit time.
            Must be repaid before new reservations are allowed (unless policy permits otherwise).
        allocated:
          $ref: '#/components/schemas/Amount'
          description: Optional total budget cap if a fixed allocation exists.
        overdraft_limit:
          $ref: '#/components/schemas/Amount'
          description: >-
            Optional maximum debt allowed for this scope. If absent or amount=0, no overdraft is permitted.
            When present and amount > 0, commits can succeed by creating debt up to this limit.
        is_over_limit:
          type: boolean
          description: >-
            True when debt > overdraft_limit due to concurrent commits creating cumulative debt beyond the limit,
            or when ALLOW_IF_AVAILABLE could not cover the full overage delta and capped the charge.
            When true, ALL new reservations against this scope are blocked with 409 OVERDRAFT_LIMIT_EXCEEDED
            until reconciled (debt repaid below overdraft_limit, or is_over_limit cleared by operator).
            Defaults to false when absent.

    ReservationCreateResponse:
      type: object
      required: [decision, affected_scopes]
      additionalProperties: false
      example:
        decision: ALLOW
        reservation_id: "rsv_a1b2c3d4"
        reserved:
          unit: USD_MICROCENTS
          amount: 500000
        expires_at_ms: 1712956830000
        remaining_ttl_ms: 600000
        scope_path: "tenant:acme-corp/workspace:prod/agent:summarizer"
        affected_scopes:
          - "tenant:acme-corp"
          - "tenant:acme-corp/workspace:prod"
          - "tenant:acme-corp/workspace:prod/agent:summarizer"
      properties:
        decision:
          $ref: '#/components/schemas/DecisionEnum'
          description: >
            For dry_run=true, decision MAY be DENY. For dry_run=false, insufficient budget MUST be expressed via 409 BUDGET_EXCEEDED (not decision=DENY).
        reservation_id:
          type: string
          description: Present if decision is ALLOW or ALLOW_WITH_CAPS and dry_run is false. MUST be absent when dry_run is true.
        reserved:
          $ref: '#/components/schemas/Amount'
        expires_at_ms:
          type: integer
          format: int64
          minimum: 0
        remaining_ttl_ms:
          type: integer
          format: int64
          minimum: 0
          description: >
            Observation of remaining reservation lifetime in milliseconds while
            this response payload is constructed, computed as
            max(0, expires_at_ms - current authoritative server time) using
            the same authoritative clock used for reservation expiry decisions.
            Servers SHOULD emit it on successful responses that return a live
            reservation; it MUST be absent on dry-run and DENY responses.
            This field is volatile transport metadata and is not part of the
            attested CyclesEvidence payload. On a same-key idempotent replay,
            a server that emits it MUST recompute it at replay-response
            construction time from the original expires_at_ms and MUST NOT
            copy its originally stored value; it MUST be 0 if the reservation
            is no longer ACTIVE and MAY conservatively understate current lead
            if a later, separately keyed extension moved expiry outward. All
            other fields replay verbatim. Added in 0.1.25.16 (additive);
            clients MUST treat it as optional for backward compatibility. When
            present it is the NORMATIVE input to heartbeat scheduling — see
            HEARTBEAT GUIDANCE on extendReservation.
        scope_path:
          type: string
          description: Canonical scope path (server-derived)
        affected_scopes:
          type: array
          items:
            type: string
          description: Canonical scope identifiers impacted by this reservation, in canonical order.
        caps:
          $ref: '#/components/schemas/Caps'
          description: Present only when decision=ALLOW_WITH_CAPS; MUST be absent otherwise.
        balances:
          type: array
          items:
            $ref: '#/components/schemas/Balance'
        reason_code:
          $ref: '#/components/schemas/DecisionReasonCode'
        retry_after_ms:
          type: integer
          minimum: 0
        cycles_evidence:
          $ref: '#/components/schemas/CyclesEvidenceRef'
          description: >-
            Reference to the CyclesEvidence envelope emitted for this reserve
            (present on both ALLOW and DENY outcomes). Absent only when evidence
            emission is disabled on the server.

    StandardMetrics:
      type: object
      description: >
        Optional standard metrics for commit/event. All fields are optional.
        Recommended for consistency across clients. For custom fields, use `custom`.
      additionalProperties: false
      properties:
        tokens_input:
          type: integer
          minimum: 0
          description: Input tokens consumed
        tokens_output:
          type: integer
          minimum: 0
          description: Output tokens generated
        latency_ms:
          type: integer
          minimum: 0
          description: Total operation latency in milliseconds
        model_version:
          type: string
          maxLength: 128
          description: Actual model/tool version used
        custom:
          type: object
          additionalProperties: true
          description: Arbitrary additional metrics

    CommitRequest:
      type: object
      required: [idempotency_key, actual]
      additionalProperties: false
      example:
        idempotency_key: "idem_20260412_run42_step1_commit"
        actual:
          unit: USD_MICROCENTS
          amount: 423000
        metrics:
          tokens_input: 1500
          tokens_output: 800
          latency_ms: 2340
          model_version: claude-sonnet-4-20250514
      properties:
        idempotency_key:
          $ref: '#/components/schemas/IdempotencyKey'
        actual:
          $ref: '#/components/schemas/Amount'
        metrics:
          $ref: '#/components/schemas/StandardMetrics'
        metadata:
          type: object
          additionalProperties: true

    CommitResponse:
      type: object
      required: [status, charged]
      additionalProperties: false
      properties:
        status:
          type: string
          enum: [COMMITTED]
        charged:
          $ref: '#/components/schemas/Amount'
          description: >-
            Amount actually charged to the budget. Equals actual when the full amount could be covered.
            With ALLOW_IF_AVAILABLE, charged may be less than actual when the overage delta is capped
            to available remaining (charged = estimate + capped_delta).
        released:
          $ref: '#/components/schemas/Amount'
          description: Amount returned from over-reservation (if any)
        balances:
          type: array
          items:
            $ref: '#/components/schemas/Balance'
        cycles_evidence:
          $ref: '#/components/schemas/CyclesEvidenceRef'
          description: >-
            Reference to the CyclesEvidence envelope emitted for this commit
            (artifact_type `commit`). Absent only when evidence emission is
            disabled on the server.

    ReleaseRequest:
      type: object
      required: [idempotency_key]
      additionalProperties: false
      properties:
        idempotency_key:
          $ref: '#/components/schemas/IdempotencyKey'
        reason:
          type: string
          maxLength: 256

    ReleaseResponse:
      type: object
      required: [status, released]
      additionalProperties: false
      properties:
        status:
          type: string
          enum: [RELEASED]
        released:
          $ref: '#/components/schemas/Amount'
        balances:
          type: array
          items:
            $ref: '#/components/schemas/Balance'
        cycles_evidence:
          $ref: '#/components/schemas/CyclesEvidenceRef'
          description: >-
            Reference to the CyclesEvidence envelope emitted for this release
            (artifact_type `release`). Absent only when evidence emission is
            disabled on the server.

    ReservationDetail:
      type: object
      description: >
        Reservation details returned by GET /v1/reservations/{reservation_id}. Useful for debugging.
      required:
        - reservation_id
        - status
        - subject
        - action
        - reserved
        - created_at_ms
        - expires_at_ms
        - scope_path
        - affected_scopes
      additionalProperties: false
      properties:
        reservation_id:
          type: string
        status:
          $ref: '#/components/schemas/ReservationStatus'
        idempotency_key:
          $ref: '#/components/schemas/IdempotencyKey'
          description: Present if the server persists reservation creation idempotency keys for debugging/recovery.
        subject:
          $ref: '#/components/schemas/Subject'
        action:
          $ref: '#/components/schemas/Action'
        reserved:
          $ref: '#/components/schemas/Amount'
        committed:
          $ref: '#/components/schemas/Amount'
        created_at_ms:
          type: integer
          format: int64
        expires_at_ms:
          type: integer
          format: int64
        finalized_at_ms:
          type: integer
          format: int64
          description: >-
            Wall-clock time at which the reservation reached a terminal
            state. Populated on COMMITTED and RELEASED rows only;
            absent on ACTIVE and EXPIRED rows. The `finalized_from` /
            `finalized_to` window filter on `listReservations`
            (revision 2026-05-22) operates against this same field
            with the same population semantics.
        scope_path:
          type: string
          description: Canonical scope path (server-derived)
        affected_scopes:
          type: array
          items:
            type: string
          description: Canonical scope identifiers impacted by this reservation, in canonical order.
        metadata:
          type: object
          additionalProperties: true
          description: >-
            Metadata supplied at RESERVE time (on the create-reservation
            request), if any. For metadata attached at COMMIT time see
            `committed_metadata`.
        committed_metadata:
          type: object
          additionalProperties: true
          description: >-
            Metadata supplied on the COMMIT request (`CommitRequest.metadata`),
            preserved on the reservation record. Present on COMMITTED rows whose
            commit carried metadata; absent otherwise. Distinct from `metadata`,
            which is captured at reserve time.
        evidence:
          $ref: '#/components/schemas/ReservationEvidence'
          description: >-
            CyclesEvidence references for this reservation's operations
            (reserve / commit / release), keyed by artifact type, so a consumer
            can resolve the signed envelope(s) via `getEvidence` without having
            captured the `evidence_id` off the original response. Present when
            the reservation has recorded evidence; absent when evidence emission
            is disabled on the server or the reservation predates evidence
            support. Added in revision 2026-06-22.

    BalanceResponse:
      type: object
      required: [balances]
      additionalProperties: false
      properties:
        balances:
          type: array
          items:
            $ref: '#/components/schemas/Balance'
        next_cursor:
          type: string
          description: Opaque cursor for next page (if any).
        has_more:
          type: boolean
          description: True if next_cursor is present and more results may be available.

    # ---- Events (optional) ----
    EventCreateRequest:
      type: object
      required: [idempotency_key, subject, action, actual]
      additionalProperties: false
      properties:
        idempotency_key:
          $ref: '#/components/schemas/IdempotencyKey'
        subject:
          $ref: '#/components/schemas/Subject'
        action:
          $ref: '#/components/schemas/Action'
        actual:
          $ref: '#/components/schemas/Amount'
        overage_policy:
          $ref: '#/components/schemas/CommitOveragePolicy'
          default: ALLOW_IF_AVAILABLE
          description: >
            How server handles events where actual would exceed remaining budget.
            REJECT: server MUST return 409 BUDGET_EXCEEDED if actual > remaining across any derived scope.
            ALLOW_IF_AVAILABLE (default): event always succeeds. If remaining budget supports the full actual,
            charge it atomically. Otherwise, cap the charge to available remaining (minimum across all derived
            scopes, floor 0) and set is_over_limit=true on scopes where the full amount could not be covered.
            Never creates debt. Never rejects an event.
            ALLOW_WITH_OVERDRAFT: if remaining budget supports actual, apply normally. Otherwise, check if
            (current_debt + actual) <= overdraft_limit across all derived scopes. If yes: apply succeeds,
            add actual to debt, remaining can go negative. If no: server MUST return 409 OVERDRAFT_LIMIT_EXCEEDED.
            Concurrency semantics are identical to commit: the overdraft_limit check is per-event and NOT
            atomic across concurrent events; scopes may enter over-limit state if concurrent events collectively
            exceed overdraft_limit.
        metrics:
          $ref: '#/components/schemas/StandardMetrics'
        client_time_ms:
          type: integer
          format: int64
          minimum: 0
          description: >
            Optional client-observed timestamp (ms). This value is advisory only and MUST NOT be used for authorization, expiry, 
            idempotency or budget enforcement decisions. The server's receipt time governs all budget comparisons and invariants.
        metadata:
          type: object
          additionalProperties: true
      description: >
        NOTE: Events MUST be applied atomically across derived scopes (same as reservations), or rejected.

    EventCreateResponse:
      type: object
      required: [status, event_id]
      additionalProperties: false
      properties:
        status:
          type: string
          enum: [APPLIED]
        event_id:
          type: string
        charged:
          description: >-
            The amount actually applied.  Present when overage_policy is
            ALLOW_IF_AVAILABLE and the actual was capped to the remaining
            budget, so the client can see the effective charge.
          $ref: '#/components/schemas/Amount'
        balances:
          type: array
          items:
            $ref: '#/components/schemas/Balance'

    ReservationSummary:
      type: object
      additionalProperties: false
      required:
        - reservation_id
        - status
        - subject
        - action
        - reserved
        - created_at_ms
        - expires_at_ms
        - scope_path
        - affected_scopes
      properties:
        reservation_id:
          type: string
        status:
          $ref: '#/components/schemas/ReservationStatus'
        idempotency_key:
          $ref: '#/components/schemas/IdempotencyKey'
          description: Present if the server persists reservation creation idempotency keys.
        subject:
          $ref: '#/components/schemas/Subject'
        action:
          $ref: '#/components/schemas/Action'
        reserved:
          $ref: '#/components/schemas/Amount'
        created_at_ms:
          type: integer
          format: int64
          minimum: 0
        expires_at_ms:
          type: integer
          format: int64
          minimum: 0
        finalized_at_ms:
          type: integer
          format: int64
          minimum: 0
          description: >-
            Wall-clock time at which the reservation reached a terminal
            state. Populated on COMMITTED and RELEASED rows only;
            absent on ACTIVE and EXPIRED rows. OPTIONAL per the
            `additionalProperties: false` invariant — older summaries
            sent without this field remain valid. Mirrors the same field
            on `ReservationDetail`. Added in revision 2026-05-22 to
            support the `finalized_from` / `finalized_to` window filter
            on `listReservations`.
        scope_path:
          type: string
        affected_scopes:
          type: array
          items:
            type: string
        committed:
          $ref: '#/components/schemas/Amount'
          description: >-
            The amount charged at COMMIT, mirrored from
            `ReservationDetail.committed`. Present on COMMITTED rows only;
            absent on ACTIVE, EXPIRED, and RELEASED rows. Returned
            UNCONDITIONALLY on list rows (no `include` opt-in required) —
            it is a small scalar core to a reservation listing, on the
            same footing as `finalized_at_ms`. OPTIONAL per the
            `additionalProperties: false` invariant — older summaries
            sent without this field remain valid. Added in revision
            2026-06-19.
        metadata:
          type: object
          additionalProperties: true
          description: >-
            RESERVE-time metadata (see `ReservationDetail.metadata`). On
            `listReservations` responses this is OMITTED BY DEFAULT and
            populated ONLY when the caller opts in with `include=metadata`
            — the map is arbitrary-size and may carry application PII, so
            it is not projected onto every list row unconditionally.
            Always present (when the reservation carried reserve-time
            metadata) on the single-row `ReservationDetail`. OPTIONAL;
            absent when not requested or when there was no reserve-time
            metadata. Added in revision 2026-06-19.
        committed_metadata:
          type: object
          additionalProperties: true
          description: >-
            COMMIT-time metadata (see `ReservationDetail.committed_metadata`).
            On `listReservations` responses this is OMITTED BY DEFAULT and
            populated ONLY when the caller opts in with
            `include=committed_metadata` — same payload-size / PII
            rationale as `metadata`. Always present (on COMMITTED rows
            whose commit carried metadata) on the single-row
            `ReservationDetail`. OPTIONAL; absent when not requested, on
            non-COMMITTED rows, or when the commit carried no metadata.
            Added in revision 2026-06-19.
        evidence:
          $ref: '#/components/schemas/ReservationEvidence'
          description: >-
            CyclesEvidence references for the operations performed on this
            reservation (reserve / commit / release), keyed by artifact type.
            On `listReservations` this is OMITTED BY DEFAULT and populated ONLY
            when the caller opts in with `include=evidence`. Always present
            (when the reservation has recorded evidence) on the single-row
            `ReservationDetail`. OPTIONAL; absent when not requested, when
            evidence emission is disabled, or for reservations that predate
            evidence support. Added in revision 2026-06-22.

    ReservationListResponse:
      type: object
      additionalProperties: false
      required: [reservations]
      properties:
        reservations:
          type: array
          items:
            $ref: '#/components/schemas/ReservationSummary'
        next_cursor:
          type: string
          description: Opaque cursor for next page (if any).
        has_more:
          type: boolean
          description: True if next_cursor is present and more results may be available.

    ReservationExtendRequest:
      type: object
      required: [idempotency_key, extend_by_ms]
      additionalProperties: false
      properties:
        idempotency_key:
          $ref: '#/components/schemas/IdempotencyKey'
        extend_by_ms:
          type: integer
          minimum: 1
          maximum: 86400000
          description: >
            Amount of time (ms) to extend the reservation expiry relative to its current expires_at_ms
            (i.e., not relative to request time). This does NOT change reserved amount. Server MAY clamp
            the effective extension to policy limits; the returned expires_at_ms is authoritative.
            Keep-alive clients SHOULD NOT extend by ttl_ms on every sub-TTL beat — because extension is
            relative to current expires_at_ms, that drifts expiry unboundedly outward; see HEARTBEAT
            GUIDANCE on the extendReservation operation.
        metadata:
          type: object
          additionalProperties: true
          description: Optional debugging/audit metadata.

    ReservationExtendResponse:
      type: object
      required: [status, expires_at_ms]
      additionalProperties: false
      properties:
        status:
          type: string
          enum: [ACTIVE]
          description: Reservation remains ACTIVE after a successful extension.
        expires_at_ms:
          type: integer
          format: int64
          minimum: 0
          description: New server-authoritative expiry timestamp (ms).
        remaining_ttl_ms:
          type: integer
          format: int64
          minimum: 0
          description: >
            Volatile observation of remaining reservation lifetime in
            milliseconds while this response payload is constructed, computed
            as max(0, expires_at_ms - current authoritative server time) using
            the same authoritative clock used for reservation expiry decisions.
            Servers SHOULD emit it on every successful extension response. On a
            same-key idempotent replay, a server that emits this field MUST
            recompute it at replay-response construction time and MUST NOT copy
            its originally stored value, while ALL OTHER fields replay verbatim
            from the original response. Rationale: a heartbeat retrying a lost
            extend with the same idempotency key schedules its next beat from
            the replayed body, and a cached remaining_ttl_ms would be stale by
            at least the retry delay — scheduling from it would overshoot the
            real lease. It MAY therefore be 0 even though the
            original extension succeeded, and MUST be 0 if the reservation is
            no longer ACTIVE. It MAY conservatively understate current lead if
            a later, separately keyed extension moved expiry outward. Added in
            0.1.25.16 (additive); clients MUST treat it as optional for backward
            compatibility. When present it is the NORMATIVE input to heartbeat
            scheduling — see HEARTBEAT GUIDANCE on extendReservation.
        balances:
          type: array
          items:
            $ref: '#/components/schemas/Balance'
          description: Optional updated balances snapshot after extension (nice-to-have).

paths:
  /v1/decide:
    post:
      tags: [Decisions]
      operationId: decide
      x-conformance: normative
      summary: Optional preflight policy decision (no reservation created)
      description: >-
        Returns ALLOW / DENY, optionally with Caps for soft landing. This endpoint does not reserve budget. Clients that require
        concurrency safety MUST use /v1/reservations.

        IDEMPOTENCY (NORMATIVE): 
        - On replay with the same idempotency_key, the server MUST return the original successful response payload.

        TENANCY (NORMATIVE): 
        - subject.tenant MUST match the effective tenant derived from auth; otherwise the server MUST return 403 FORBIDDEN.
        
        DEBT/OVERDRAFT STATE (NORMATIVE):
        - If the subject scope has debt > 0 or is_over_limit=true, server SHOULD return
        decision=DENY with reason_code=DEBT_OUTSTANDING or reason_code=OVERDRAFT_LIMIT_EXCEEDED
        respectively. Server MUST NOT return 409 for these conditions on /decide.

        CLOSED TENANT (NORMATIVE):
        - If the owning tenant's status is CLOSED (deployments with a governance plane),
          a fresh (non-replay) evaluation MUST return decision=DENY with
          reason_code=TENANT_CLOSED, reflecting as-if-live the 409 TENANT_CLOSED the
          persisting mutation surface returns (same-key replays of pre-close decisions
          follow the IDEMPOTENCY rule above and the replayed-decision caveat below).
          Server MUST NOT return 409 for this condition on /decide. A tenant record whose
          status cannot be determined (malformed record) MUST fail closed with 500
          INTERNAL_ERROR — see the closed-tenant binding in ERROR SEMANTICS.

        Idempotency on /decide is for request deduplication only. A replayed ALLOW response reflects budget state at the time of 
        the original call; clients MUST NOT treat a replayed decision as current budget authorization.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/DecisionRequest'}
      responses:
        "200":
          description: Decision result
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
            X-Cycles-Trace-Id: {$ref: '#/components/headers/X-Cycles-Trace-Id'}
            X-Cycles-Tenant: {$ref: '#/components/headers/X-Cycles-Tenant'}
            X-RateLimit-Remaining: {$ref: '#/components/headers/X-RateLimit-Remaining'}
            X-RateLimit-Reset: {$ref: '#/components/headers/X-RateLimit-Reset'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/DecisionResponse'}
        "400": {$ref: '#/components/responses/ErrorResponse'}
        "401": {$ref: '#/components/responses/ErrorResponse'}
        "403": {$ref: '#/components/responses/ErrorResponse'}
        "409": {$ref: '#/components/responses/ErrorResponse'}
        "500": {$ref: '#/components/responses/ErrorResponse'}

  /v1/reservations:
    post:
      tags: [Reservations]
      operationId: createReservation
      x-conformance: normative
      summary: Reserve budget for a planned action (concurrency-safe)
      description: >-
        Atomically reserves the estimated amount across server-derived scopes and returns a reservation_id. Reservations expire
        at expires_at_ms; commits are accepted through (expires_at_ms + grace_period_ms). 

        If dry_run=true, server MUST evaluate the full reservation request and return decision/caps/affected_scopes/balances
        as if the reservation were live, but MUST NOT modify balances, persist a reservation, or require commit/release.

        DRY-RUN RESPONSE RULES (NORMATIVE):
        - reservation_id and expires_at_ms MUST be absent.
        - affected_scopes MUST be populated regardless of decision outcome (ALLOW / ALLOW_WITH_CAPS / DENY).
        - If decision=ALLOW_WITH_CAPS, caps MUST be present; otherwise caps MUST be absent.
        - If decision=DENY, reason_code SHOULD be populated; it is the primary diagnostic signal for why the dry_run was denied.
        - If the owning tenant's status is CLOSED (deployments with a governance plane),
          dry_run MUST NOT return 409 TENANT_CLOSED; a fresh (non-replay) evaluation MUST
          return decision=DENY with reason_code=TENANT_CLOSED, reflecting as-if-live the
          409 the persisting create returns (same-key replays of pre-close evaluations
          follow the IDEMPOTENCY rule below). A tenant record whose status cannot be determined (malformed record)
          MUST fail closed with 500 INTERNAL_ERROR even on dry_run — see the closed-tenant
          binding in ERROR SEMANTICS.
        - balances MAY be populated (recommended for operator visibility), but MUST reflect a non-mutating evaluation.

        OVER-LIMIT BLOCKING (NORMATIVE):
        - If ANY affected scope has debt > overdraft_limit (is_over_limit=true), the reservation MUST be rejected 
          with 409 OVERDRAFT_LIMIT_EXCEEDED, regardless of available remaining budget.
        - This blocks new work when overdraft reconciliation is needed.

        IDEMPOTENCY (NORMATIVE):
        - On replay with the same idempotency_key, the server MUST return the original successful response
          outcome and payload, including the original reservation_id (if any), except that remaining_ttl_ms is a
          volatile transport observation not included in the attested CyclesEvidence payload. When emitted, it
          MUST be recomputed from the original expires_at_ms and current authoritative server time while
          constructing the replay response, MUST NOT be copied from the stored response, and MUST be 0 if the
          reservation is no longer ACTIVE. All other fields replay verbatim.

        TENANCY (NORMATIVE):
        - subject.tenant MUST match the effective tenant derived from auth; otherwise the server MUST return 403 FORBIDDEN.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ReservationCreateRequest'}
      responses:
        "200":
          description: Reservation decision (ALLOW/DENY with optional caps)
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
            X-Cycles-Trace-Id: {$ref: '#/components/headers/X-Cycles-Trace-Id'}
            X-Cycles-Tenant: {$ref: '#/components/headers/X-Cycles-Tenant'}
            Date: {$ref: '#/components/headers/Date'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ReservationCreateResponse'}
        "400": {$ref: '#/components/responses/ErrorResponse'}
        "401": {$ref: '#/components/responses/ErrorResponse'}
        "403": {$ref: '#/components/responses/ErrorResponse'}
        "404":
          description: >-
            NOT_FOUND — no budget exists at any of the reservation's derived scopes in any unit.
            The message field carries the specific "Budget not found for provided scope: ..." detail.
            Distinct from 400 UNIT_MISMATCH (a budget exists at the scope but in a different unit).
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
            X-Cycles-Trace-Id: {$ref: '#/components/headers/X-Cycles-Trace-Id'}
            X-Cycles-Tenant: {$ref: '#/components/headers/X-Cycles-Tenant'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "409": {$ref: '#/components/responses/ErrorResponse'}
        "500": {$ref: '#/components/responses/ErrorResponse'}
    get:
      tags: [Reservations]
      operationId: listReservations
      x-conformance: normative
      summary: List reservations (optional recovery/debug endpoint)
      description: >-
        Lists reservations visible to the effective tenant. This endpoint is OPTIONAL in v0 deployments.


        RECOVERY (NORMATIVE):
          - If a client loses reservation_id, it MAY recover it by querying with idempotency_key and/or subject filters.
          - If idempotency_key is provided, the server SHOULD return at most one matching reservation (uniqueness is expected per (effective tenant, endpoint, idempotency_key)).
          - Servers SHOULD support filtering by status=ACTIVE to identify "stuck" reservations.

        SUBJECT FILTERS (GUIDANCE):
          - Query parameters tenant/workspace/app/workflow/agent/toolset filter on the canonical Subject fields.
          - Filtering on Subject.dimensions is out of scope for v0 unless explicitly implemented by the server.

        TIME-RANGE FILTERS (NORMATIVE, ADDITIVE):
          Three independent inclusive time-window filters are available,
          each bound to a specific timestamp field on the reservation
          entity. All bounds are ISO 8601 date-time strings. All are
          additive parameters: servers that don't recognize them MUST
          ignore without error.

          - `from` / `to` (revision 2026-05-21) — bound on
            `created_at_ms`. The original window filter; matches
            the family-wide convention on `listAuditLogs`,
            `listEvents`, and `listWebhookDeliveries`. Always binds
            to `created_at_ms` regardless of `sort_by`.

          - `expires_from` / `expires_to` — bound on `expires_at_ms`.
            Primary use case: locate reservations that have expired
            (or will expire) within a window — e.g. cleanup sweepers
            that need to discover abandoned ACTIVE reservations. The
            field is required on every `ReservationSummary` /
            `ReservationDetail`, so this filter applies to every
            row regardless of `status`.

          - `finalized_from` / `finalized_to` — bound on
            `finalized_at_ms`. The `finalized_at_ms` field is OPTIONAL
            on `ReservationSummary` / `ReservationDetail` and is
            populated ONLY on COMMITTED and RELEASED rows (absent on
            ACTIVE and EXPIRED). Rows where the field is absent MUST
            be excluded from results when either `finalized_from` or
            `finalized_to` is supplied — the predicate naturally fails
            on field-absent rows. Callers who want a window over
            EXPIRED rows should use `expires_from` / `expires_to`
            against `expires_at_ms`, which is required on every row.

          Validation (applies to all three pairs):
          - For each pair, `from > to` MUST return 400 INVALID_REQUEST
            (e.g., `expires_from > expires_to`).
          - Either side may be supplied alone (open interval).
          - Blank-string values for any window bound MUST be treated
            as unset (NORMATIVE). A client sending
            `?expires_from=&finalized_to=` MUST be handled identically
            to one omitting both parameters entirely. This applies to
            all six bounds (`from`, `to`, `expires_from`, `expires_to`,
            `finalized_from`, `finalized_to`). Servers MUST NOT 400 on
            empty-string values despite the `format: date-time`
            declaration. Rationale: clients commonly emit unconditional
            query strings whose values come from possibly-unset
            variables (e.g., `?from=${maybeUnset}&to=${maybeUnset}`),
            and an unset variable rendering as `""` is the common
            failure mode; rejecting these as malformed surfaces a
            cryptic 400 that adds nothing over treating them as unset.
          - The three pairs combine with AND semantics: a row must
            satisfy every supplied window predicate to be returned.

          Cursor invalidation: sorted-path cursors fold the supplied
          window bounds into the canonical filter hash. Reusing a
          sorted cursor under a different `(from, to, expires_from,
          expires_to, finalized_from, finalized_to)` tuple returns
          400 INVALID_REQUEST. Legacy SCAN cursors do not carry filter
          state; callers paginating without `sort_by` must keep all
          window bounds stable across pages, matching the legacy
          path's treatment of every other filter.

        TENANCY (NORMATIVE):
          - Under ApiKeyAuth: the server MUST scope results to the effective
            tenant derived from auth. If the tenant query parameter is
            provided, it is validation-only and MUST match the effective
            tenant; otherwise the server MUST return 403 FORBIDDEN. If
            tenant is omitted, the effective tenant is used.
          - Under AdminKeyAuth (added 2026-04-13): the admin caller has
            no effective tenant, so the tenant query parameter is REQUIRED
            and used as a FILTER (not validation). Omitting it MUST return
            400 INVALID_REQUEST with message "tenant query parameter is
            required when using admin key authentication". This matches
            the existing dual-auth pattern on listBudgets / listPolicies
            in the governance-admin spec — same single param, semantics
            keyed on auth type.
      security:
        - ApiKeyAuth: []
        - AdminKeyAuth: []
      parameters:
        - name: idempotency_key
          in: query
          required: false
          schema:
            $ref: '#/components/schemas/IdempotencyKey'
          description: Lookup handle to recover the reservation_id from a prior createReservation call.
        - name: status
          in: query
          required: false
          schema:
            $ref: '#/components/schemas/ReservationStatus'
          description: Filter by reservation status (e.g., ACTIVE).
        - name: tenant
          in: query
          required: false
          schema: {type: string}
        - name: workspace
          in: query
          required: false
          schema: {type: string}
        - name: app
          in: query
          required: false
          schema: {type: string}
        - name: workflow
          in: query
          required: false
          schema: {type: string}
        - name: agent
          in: query
          required: false
          schema: {type: string}
        - name: toolset
          in: query
          required: false
          schema: {type: string}
        - name: from
          in: query
          required: false
          description: >-
            Inclusive lower bound on reservation creation time. ISO 8601
            date-time. When set, the server MUST return only reservations
            whose `created_at_ms` is greater than or equal to this
            timestamp. The filter ALWAYS binds to `created_at_ms`,
            independent of `sort_by`. May be supplied alone (no upper
            bound) or paired with `to`. Servers MUST reject `from > to`
            with HTTP 400 INVALID_REQUEST.

            Additive parameter — servers that don't recognize it MUST
            ignore without error (additive-parameter guarantee). Matches
            the `from` / `to` convention on `listAuditLogs`,
            `listEvents`, and `listWebhookDeliveries` in the governance
            spec family.
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          required: false
          description: >-
            Inclusive upper bound on reservation creation time. ISO 8601
            date-time. When set, the server MUST return only reservations
            whose `created_at_ms` is less than or equal to this
            timestamp. The filter ALWAYS binds to `created_at_ms`,
            independent of `sort_by`. May be supplied alone (no lower
            bound) or paired with `from`. Servers MUST reject `from > to`
            with HTTP 400 INVALID_REQUEST.

            Additive parameter — servers that don't recognize it MUST
            ignore without error.
          schema:
            type: string
            format: date-time
        - name: expires_from
          in: query
          required: false
          description: >-
            Inclusive lower bound on reservation expiry time. ISO 8601
            date-time. When set, the server MUST return only reservations
            whose `expires_at_ms` is greater than or equal to this
            timestamp. The filter ALWAYS binds to `expires_at_ms`,
            independent of `sort_by` and independent of the `from`/`to`
            window on `created_at_ms`. May be supplied alone (no upper
            bound) or paired with `expires_to`. Servers MUST reject
            `expires_from > expires_to` with HTTP 400 INVALID_REQUEST.

            Use case: cleanup sweepers locating reservations that have
            expired or will expire within a window. Applies to all rows
            regardless of `status` since `expires_at_ms` is required.

            Additive parameter — servers that don't recognize it MUST
            ignore without error.
          schema:
            type: string
            format: date-time
        - name: expires_to
          in: query
          required: false
          description: >-
            Inclusive upper bound on reservation expiry time. ISO 8601
            date-time. When set, the server MUST return only reservations
            whose `expires_at_ms` is less than or equal to this
            timestamp. Same binding and open-interval rules as
            `expires_from`. Servers MUST reject `expires_from >
            expires_to` with HTTP 400 INVALID_REQUEST.

            Additive parameter — servers that don't recognize it MUST
            ignore without error.
          schema:
            type: string
            format: date-time
        - name: finalized_from
          in: query
          required: false
          description: >-
            Inclusive lower bound on reservation finalization time.
            ISO 8601 date-time. When set, the server MUST return only
            reservations whose `finalized_at_ms` is greater than or
            equal to this timestamp. The filter ALWAYS binds to
            `finalized_at_ms`, independent of `sort_by`. May be supplied
            alone (no upper bound) or paired with `finalized_to`.
            Servers MUST reject `finalized_from > finalized_to` with
            HTTP 400 INVALID_REQUEST.

            Behavior on rows without `finalized_at_ms` (NORMATIVE):
            the field is OPTIONAL on reservation responses and is
            populated ONLY on COMMITTED and RELEASED rows (absent on
            ACTIVE and EXPIRED). Rows where the field is absent MUST
            be excluded from results when either `finalized_from` or
            `finalized_to` is supplied. Callers who want a window over
            EXPIRED rows should use `expires_from` / `expires_to`
            against `expires_at_ms`.

            Additive parameter — servers that don't recognize it MUST
            ignore without error.
          schema:
            type: string
            format: date-time
        - name: finalized_to
          in: query
          required: false
          description: >-
            Inclusive upper bound on reservation finalization time.
            ISO 8601 date-time. When set, the server MUST return only
            reservations whose `finalized_at_ms` is less than or equal
            to this timestamp. Same binding, open-interval, and
            ACTIVE-row-exclusion rules as `finalized_from`. Servers MUST
            reject `finalized_from > finalized_to` with HTTP 400
            INVALID_REQUEST.

            Additive parameter — servers that don't recognize it MUST
            ignore without error.
          schema:
            type: string
            format: date-time
        - name: sort_by
          in: query
          required: false
          description: >-
            Sort key. When provided, results are returned in the
            requested order and the returned cursor encodes the sort
            key so subsequent pages continue in sort order. When
            omitted, servers use their default ordering (unchanged
            pre-revision behavior). The `reserved` key sorts by the
            integer `amount` within each row; the single-unit-per-
            reservation invariant makes this comparison well-defined.
            The `scope_path` key sorts lexicographically over the
            server-derived canonical scope path string (e.g.
            "tenant:acme/workspace:prod/agent:x"); the `tenant` key
            sorts over the Subject.tenant field. Servers that don't
            recognize the parameter MUST ignore it without error.
          schema:
            type: string
            enum: [reservation_id, tenant, scope_path, status, reserved, created_at_ms, expires_at_ms]
            default: created_at_ms
        - name: sort_dir
          in: query
          required: false
          description: Sort direction. Default descending.
          schema:
            $ref: '#/components/schemas/SortDirection'
        - name: include
          in: query
          required: false
          description: >-
            FIELD PROJECTION (NORMATIVE, ADDITIVE). Comma-separated list
            of OPTIONAL heavy fields to project onto each
            `ReservationSummary` in the response. By default the list
            projection omits the arbitrary-size, possibly-PII metadata
            maps to keep list payloads lean; a caller that needs them
            (e.g. an aggregate audit / export view) opts in explicitly.
            Added in revision 2026-06-19.

            Recognized tokens:
              - `metadata` — populate `ReservationSummary.metadata`
                (RESERVE-time metadata) on rows that carry it.
              - `committed_metadata` — populate
                `ReservationSummary.committed_metadata` (COMMIT-time
                metadata) on COMMITTED rows whose commit carried metadata.
              - `evidence` — populate `ReservationSummary.evidence` (the
                CyclesEvidence references emitted for this reservation's
                reserve / commit / release operations) on rows that have
                recorded evidence. Added in revision 2026-06-22.

            Semantics (NORMATIVE):
              - Unrecognized tokens MUST be ignored without error
                (forward/backward compatible — a client MAY request a
                field a given server version does not know, and an
                additive-parameter-unaware server simply never populates
                these maps).
              - Tokens are comma-separated; surrounding whitespace and
                empty tokens (e.g. a trailing comma, or `include=`) MUST
                be ignored, never 400.
              - PROJECTION-ONLY: `include` selects which fields are
                serialized; it does NOT affect which rows match, their
                ordering, or pagination. It therefore MUST NOT participate
                in cursor / canonical-filter-hash binding — a cursor
                minted under one `include` value remains valid when the
                next page is fetched under a different (or absent)
                `include`, and changing `include` mid-pagination MUST NOT
                return 400. (Contrast the window filters above, which DO
                bind the cursor.)
              - `committed` is NOT gated by `include`; it is always
                projected (see `ReservationSummary.committed`).
          style: form
          explode: false
          schema:
            type: array
            items:
              type: string
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        "200":
          description: Reservations list
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
            X-Cycles-Trace-Id: {$ref: '#/components/headers/X-Cycles-Trace-Id'}
            X-Cycles-Tenant: {$ref: '#/components/headers/X-Cycles-Tenant'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ReservationListResponse'}
        "400": {$ref: '#/components/responses/ErrorResponse'}
        "401": {$ref: '#/components/responses/ErrorResponse'}
        "403": {$ref: '#/components/responses/ErrorResponse'}
        "500": {$ref: '#/components/responses/ErrorResponse'}

  /v1/reservations/{reservation_id}:
    get:
      tags: [Reservations]
      operationId: getReservation
      x-conformance: normative
      summary: Get reservation details (optional, for debugging)
      description: >-
        Retrieve current status and details of a reservation by ID. Useful for debugging and monitoring long-running operations.

        EXPIRY (NORMATIVE, revision 2026-07-03):
          - If the reservation exists but its status is EXPIRED, the server MUST
            return 410 with error=RESERVATION_EXPIRED (this operation's declared
            410 response). A reservation that never existed returns 404 NOT_FOUND.
          - EXPIRED reservations remain discoverable via listReservations, which
            returns them as normal 200 rows with status=EXPIRED — the 410 applies
            only to this single-resource GET. This codifies the reference
            implementation's settled behavior; the pre-revision text enumerated
            only commit/release/extend in the 410 rule, leaving GET ambiguous.

        TENANCY (NORMATIVE):
          - Under ApiKeyAuth: if the reservation exists but is owned by a
            different effective tenant, the server MUST return 403 FORBIDDEN.
          - Under AdminKeyAuth (added 2026-04-13): admin operators can
            read any reservation regardless of owning tenant; reservation_id
            already pins the owner so no extra parameter is needed.
      security:
        - ApiKeyAuth: []
        - AdminKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/ReservationId'
      responses:
        "200":
          description: Reservation details
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
            X-Cycles-Trace-Id: {$ref: '#/components/headers/X-Cycles-Trace-Id'}
            X-Cycles-Tenant: {$ref: '#/components/headers/X-Cycles-Tenant'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ReservationDetail'}
        "400": {$ref: '#/components/responses/ErrorResponse'}
        "401": {$ref: '#/components/responses/ErrorResponse'}
        "403": {$ref: '#/components/responses/ErrorResponse'}
        "404": {$ref: '#/components/responses/ErrorResponse'}
        "410":
          description: >-
            RESERVATION_EXPIRED — the reservation exists but its status is
            EXPIRED. See the EXPIRY note in this operation's description
            (revision 2026-07-03); EXPIRED rows remain visible via
            listReservations.
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
            X-Cycles-Trace-Id: {$ref: '#/components/headers/X-Cycles-Trace-Id'}
            X-Cycles-Tenant: {$ref: '#/components/headers/X-Cycles-Tenant'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "500": {$ref: '#/components/responses/ErrorResponse'}

  /v1/reservations/{reservation_id}/commit:
    post:
      tags: [Reservations]
      operationId: commitReservation
      x-conformance: normative
      summary: Commit actual spend for a reservation (auto-releases delta)
      description: >-
        Commits actual spend. If actual < reserved, delta is released automatically. If actual > reserved, behavior is controlled
        by the reservation's overage_policy.

        IDEMPOTENCY (NORMATIVE):
        - On replay with the same idempotency_key, the server MUST return the original successful response payload.

        TENANCY (NORMATIVE): 
        - If the reservation exists but is owned by a different effective tenant, the server MUST return 403 FORBIDDEN.
      parameters:
        - $ref: '#/components/parameters/ReservationId'
        - $ref: '#/components/parameters/IdempotencyKeyHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/CommitRequest'}
      responses:
        "200":
          description: Commit succeeded
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
            X-Cycles-Trace-Id: {$ref: '#/components/headers/X-Cycles-Trace-Id'}
            X-Cycles-Tenant: {$ref: '#/components/headers/X-Cycles-Tenant'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/CommitResponse'}
        "400": {$ref: '#/components/responses/ErrorResponse'}
        "401": {$ref: '#/components/responses/ErrorResponse'}
        "403": {$ref: '#/components/responses/ErrorResponse'}
        "404": {$ref: '#/components/responses/ErrorResponse'}
        "409": {$ref: '#/components/responses/ErrorResponse'}
        "410": {$ref: '#/components/responses/ErrorResponse'}
        "500": {$ref: '#/components/responses/ErrorResponse'}

  /v1/reservations/{reservation_id}/release:
    post:
      tags: [Reservations]
      operationId: releaseReservation
      x-conformance: normative
      summary: Release an unused reservation
      description: >-
        Releases reserved amount back to remaining budget.

        IDEMPOTENCY (NORMATIVE):
        - On replay with the same idempotency_key, the server MUST return the original successful response payload.

        TENANCY (NORMATIVE):
          - Under ApiKeyAuth: if the reservation exists but is owned by
            a different effective tenant, the server MUST return 403 FORBIDDEN.
          - Under AdminKeyAuth (added 2026-04-13): admin operators can
            release any reservation regardless of owning tenant — the
            ops use case is "force-expire a hung reservation" during
            incident response. reservation_id pins the owner so no
            extra parameter is needed.

        AUDIT (NORMATIVE, AdminKeyAuth path):
          - The audit-log entry for an admin-driven release MUST record
            actor_type=admin_on_behalf_of (existing audit field, value
            already used on createBudget / createPolicy / updatePolicy
            in the governance-admin spec). This lets security review
            distinguish admin-driven releases from tenant self-service
            without joining to the keys table.
          - The entry MUST be discoverable via the governance plane's
            audit-query surface (GET /v1/admin/audit/logs in the
            governance-admin spec). Writing the entry to a store the
            governance audit-query endpoint cannot read from does not
            satisfy this requirement — the operational intent is that
            admin-driven release actions surface in the same admin-
            facing audit view that shows governance-plane audit
            entries. How servers achieve this is an implementation
            concern and out of scope for the spec.
          - Callers SHOULD populate the optional `reason` body field
            with a structured tag (e.g. "[INCIDENT_FORCE_RELEASE]")
            for grep-ability in the audit log.
      security:
        - ApiKeyAuth: []
        - AdminKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/ReservationId'
        - $ref: '#/components/parameters/IdempotencyKeyHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ReleaseRequest'}
      responses:
        "200":
          description: Release succeeded
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
            X-Cycles-Trace-Id: {$ref: '#/components/headers/X-Cycles-Trace-Id'}
            X-Cycles-Tenant: {$ref: '#/components/headers/X-Cycles-Tenant'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ReleaseResponse'}
        "400": {$ref: '#/components/responses/ErrorResponse'}
        "401": {$ref: '#/components/responses/ErrorResponse'}
        "403": {$ref: '#/components/responses/ErrorResponse'}
        "404": {$ref: '#/components/responses/ErrorResponse'}
        "409": {$ref: '#/components/responses/ErrorResponse'}
        "410": {$ref: '#/components/responses/ErrorResponse'}
        "500": {$ref: '#/components/responses/ErrorResponse'}

  /v1/reservations/{reservation_id}/extend:
    post:
      tags: [Reservations]
      operationId: extendReservation
      x-conformance: normative
      summary: Extend reservation TTL (lease refresh / heartbeat)
      description: >-
        Extends the expiry of an ACTIVE reservation to support long-running agent workflows.

        SEMANTICS (NORMATIVE):
        - Extension updates expires_at_ms only; it MUST NOT change reserved amount, unit, subject, action, scope_path, or affected_scopes.
        - Extensions MUST be applied in a concurrency-safe way.
        - Server MUST accept extend only when status is ACTIVE and the reservation has not yet expired
          (i.e., server time ≤ expires_at_ms). If the reservation is expired, server MUST return 410 with error=RESERVATION_EXPIRED.

        IDEMPOTENCY (NORMATIVE):
        - On replay with the same idempotency_key, the server MUST return the original successful response outcome
          and payload — all fields VERBATIM — except that remaining_ttl_ms is a volatile observation and, when
          emitted, MUST be recomputed from the original expires_at_ms and current authoritative server time while
          constructing the replay response. It MUST NOT be copied from the originally stored response and MUST be
          0 if the reservation is no longer ACTIVE. (Rationale: a heartbeat retrying a lost response with the same
          idempotency key schedules from the replayed body; a cached value is stale by the retry delay.)

        TENANCY (NORMATIVE):
        - If the reservation exists but is owned by a different effective tenant, the server MUST return 403 FORBIDDEN.

        ERROR SEMANTICS (NORMATIVE):
        - If the owning tenant's status is CLOSED (deployments with a governance plane),
          server MUST return 409 with error=TENANT_CLOSED, taking precedence over the
          reservation-state errors below for non-replay requests — see the closed-tenant
          binding in this document's top-level ERROR SEMANTICS.
        - If the reservation is COMMITTED or RELEASED, server MUST return 409 with error=RESERVATION_FINALIZED.
        - If the reservation is expired (server time > expires_at_ms), server MUST return 410 with error=RESERVATION_EXPIRED.
        - If the reservation never existed, server MUST return 404 with error=NOT_FOUND.

        HEARTBEAT GUIDANCE:
        - extend_by_ms is RELATIVE to the reservation's current expires_at_ms, not to request time. A keep-alive
          client that extends by ttl_ms on every sub-TTL beat (e.g. every ttl/2) therefore drifts expiry outward
          by ttl/2 per beat — an effectively unbounded zombie-reservation window (bounded only by a server's
          extension limits) that a crashed client can leave holding budget, working against the same
          zombie-reservation rationale as the grace_period_ms ceiling — and it consumes any server-side
          extension budget (MAX_EXTENSIONS_EXCEEDED) twice as fast as necessary.
        - Servers MAY constrain extensions in two DISTINCT ways with different client-visible
          signatures. (1) Per-extend GRANT clamping: the granted TTL is capped below the requested
          ttl_ms (e.g. a max_reservation_ttl_ms ceiling in deployments with a governance plane: a 24h
          request may be silently capped to 1h — or to 1s). (2) MAXIMUM-LEAD clamping: the effective
          new expiry is clamped to at most a policy lead L beyond server time, so the server holds
          expiry ≈ now + L no matter what is requested. The returned expires_at_ms is AUTHORITATIVE in
          both cases. This revision adds the OPTIONAL remaining_ttl_ms field to both
          ReservationCreateResponse and ReservationExtendResponse precisely because, when it is
          absent, a client can compute neither its true initial lead nor which clamp regime it faces
          (see the FALLBACK limits below).
        - PRIMARY ALGORITHM — NORMATIVE whenever the response carries remaining_ttl_ms (servers
          SHOULD emit it; clients MUST treat it as optional for backward compatibility). On EVERY
          successful create or extend response, recompute the schedule from that response alone —
          never accumulate expiry differences:
          Clients MUST record monotonic attempt_sent immediately before each HTTP attempt and
          response_received after its complete, schema-valid response body is received.
          rtt = monotonic(response_received) − monotonic(attempt_sent);
          lead_floor = max(0, remaining_ttl_ms − rtt);
          request_timeout_budget = the client's enforced finite upper bound for one complete extend attempt
          (connect, write, read, and failure detection);
          attempt_budget = max(request_timeout_budget, 1000 ms, 2 × maximum observed rtt);
          safety_margin = max(1000 ms, 2 × maximum observed rtt);
          retry_reserve = 2 × attempt_budget + safety_margin;
          next_delay = max(0, lead_floor − retry_reserve);
          All duration arithmetic MUST use milliseconds and MUST be overflow-safe; positive overflow
          is treated as positive infinity, never wraparound. When clock or timer resolution requires
          rounding, clients MUST round attempt budgets and safety margins up, and lead lower bounds
          and scheduling/retry delays down, so rounding cannot consume the reserved margin.
          schedule the next extension next_delay after response receipt. retry_reserve covers one
          failed attempt, one same-key retry, and scheduling/network margin; when the lease is too
          short to hold that reserve, next_delay is 0 rather than an unsafe positive delay. A zero
          delay starts a best-effort extension immediately; it does not by itself prove that a
          recovery retry will still fit if that attempt consumes its full budget.
          A positive next_delay is possible only when lead_floor > retry_reserve. Clients SHOULD
          therefore configure both timeout and expected-network-latency bounds so
          attempt_budget < (minimum expected lead_floor − safety_margin) / 2; in particular, the
          enforced per-extend request_timeout_budget SHOULD satisfy that inequality whenever it
          dominates attempt_budget.
          For example, with a returned lead near the default requested ttl_ms of 60000 ms, a
          10000 ms request timeout and 1500 ms maximum observed rtt give attempt_budget = 10000 ms,
          safety_margin = 3000 ms, retry_reserve = 23000 ms, and next_delay ≈ 37000 ms. A 30000 ms
          timeout instead makes retry_reserve at least 61000 ms, so a 60000 ms lead produces
          next_delay = 0. An additive-delta server MAY establish positive lead after the immediate
          extension, but a server clamped to a 60000 ms maximum lead cannot; client timeout defaults
          SHOULD be chosen accordingly.
          If attempt timing is unavailable or unreliable, the client MUST set lead_floor and
          next_delay to 0. If the request timeout is unknown or unbounded, treat attempt_budget as
          positive infinity, which likewise makes next_delay 0. Unknown timing MUST NOT be treated as
          zero elapsed time. A client without reliable monotonic attempt timing therefore cannot
          establish a safe primary-path delay: the two-consecutive-zero-delay guard below permits at
          most one immediate fresh extension after a successful response and then requires the client
          to stop. It MUST NOT switch to the fieldless fallback merely because its local timing is
          unavailable. The first beat derives from the CREATE response's
          remaining_ttl_ms in exactly the same way, so no immediate priming is needed when a positive
          safe delay can be established. Extend by the requested ttl_ms; the returned expires_at_ms
          and remaining_ttl_ms are authoritative.
          Replay awareness: same-key CREATE and EXTEND retries are safe to schedule from because the
          server recomputes remaining_ttl_ms at replay-response construction time (see IDEMPOTENCY
          above). The client still measures rtt for the individual HTTP attempt that produced the
          response; it MUST NOT substitute timing from an earlier attempt.
          Only a schema-valid HTTP 200 ReservationCreateResponse (for the initial create) or
          ReservationExtendResponse (for an extension) counts as an observed success. A different or
          malformed 2xx response is ambiguous and MUST NOT be used to schedule from stale state.
          This response-success predicate applies on BOTH the primary and fallback paths; the fallback
          changes only how the next delay is chosen after a valid success, not what proves success.
          After a CREATE timeout, connection error, 5xx, or ambiguous 2xx, where no valid lead_floor
          exists, the client SHOULD make one immediate retry with the SAME idempotency_key and then
          stop and surface the ambiguity if that retry also fails to produce a schema-valid result.
          On an EXTEND transient failure (timeout, connection error, 5xx, 429 LIMIT_EXCEEDED, or an
          ambiguous 2xx), recompute current_lead_estimate = max(0, last lead_floor − monotonic elapsed
          since the schema-valid response that established last lead_floor was received). Define
          retry_window = current_lead_estimate − attempt_budget − safety_margin, WITHOUT clamping.
          If retry_window < 0, no complete retry plus margin is provably safe: the client MUST NOT
          retry and MUST stop and surface the failure. Otherwise, for timeout, connection error, 5xx,
          and ambiguous 2xx, retry with the SAME idempotency_key after
          retry_delay = min(30000 ms, current_lead_estimate/4, retry_window). For 429, first convert
          the non-negative Retry-After seconds to retry_after_ms = 1000 × Retry-After using
          overflow-safe arithmetic; retry_delay equals retry_after_ms only when retry_after_ms ≤
          retry_window. If Retry-After is missing, invalid, overflows, or exceeds retry_window after
          conversion, the client MUST NOT invent an earlier retry that violates throttling and SHOULD
          stop and surface that the lease cannot be safely renewed. The retry_window cap leaves one
          complete retry plus margin; retry_window = 0 means retry immediately, while a negative value
          means stop. After EVERY failed or ambiguous recovery attempt, the client MUST recompute
          current_lead_estimate and retry_window from the same last schema-valid response and the new
          monotonic elapsed time before deciding again. It MAY continue recovery with the SAME
          idempotency_key while the freshly recomputed retry_window is positive; elapsed time from all
          prior attempts has already been deducted, and each decision reserves one complete
          attempt_budget plus safety_margin for the prospective retry. If retry_window = 0, the client
          MAY make one immediate recovery retry; if that retry also fails or is ambiguous before an
          intervening success, it MUST stop even if coarse clock resolution still reports zero. The
          client MUST also stop if neither monotonic elapsed nor retry_window decreases between
          consecutive failures, preventing a zero-time recovery loop.
          On any other 4xx response, the client SHOULD stop and surface the request or authorization
          failure; it MUST NOT automatically rotate the idempotency key and retry an unchanged request.
          A client MUST NOT enter an unbounded zero-delay loop. When a schema-valid success produces
          next_delay = 0, it MAY make one immediate fresh extension attempt with a new idempotency_key.
          If that success also produces next_delay = 0, the client MUST stop and surface that the lease
          is shorter than its retry-safety budget; this still permits an additive-delta server's first
          immediate extension to establish a larger positive lead without burning a maximum-lead
          server's extension budget in a tight loop.
        - FALLBACK (NON-NORMATIVE) — only for servers that do not emit remaining_ttl_ms. A
          measured-grant scheme, with its limits stated honestly below; no refinement of it removes
          them.
          Maintain a conservative LOWER BOUND on lead from same-frame differences (no cross-clock
          arithmetic anywhere): lead_min = (sum of measured grants) −
          monotonic_elapsed_since_reserve_response, starting at 0 at the reserve response, where each
          measured grant is the difference between successive returned expires_at_ms values (the SAME
          server frame, so differencing them is legitimate); lead_min never overstates the true lead.
          The FIRST extension MAY fire immediately — as a TRADEOFF, not a free action. No
          create-response field bounds the effective initial lease, so immediate priming minimizes
          the lapse risk of an unknown small initial lease (a 24h request silently capped to 1s is
          long expired when a 30s first beat arrives). But under maximum-lead clamping the value of
          an extension is SCHEDULE-DEPENDENT: with a 60s maximum lead, an immediate extend measures a
          grant ≈ 0 where the same extend fired 30s later would have measured ≈ 30s — so total
          protected runtime and the sum of grants are NOT schedule-invariant, and immediate priming
          can spend one extension for zero added lifetime. (An earlier revision of this guidance
          claimed "nothing is lost but the count"; that claim is false under maximum-lead clamping.)
          Cadence heuristic: compare each measured grant to the monotonic elapsed time since the
          previous successful extension. Treat grant ≥ ~0.9×requested ttl_ms as the normal regime and
          beat at ~grant/2 (bounded sensibly, e.g. no shorter than 1s) — against a per-extend-DELTA
          clamp a smaller grant is a genuinely smaller lease, so tightening is correct. Treat
          grant ≤ 0, or a grant inside a band around elapsed (e.g. 0.75×elapsed ≤ grant ≤
          1.25×elapsed), as maximum-lead clamping: hold a fixed bounded cadence (e.g. min(requested
          ttl_ms/2, 30s)), do not tighten toward a floor, surface that the extension budget (default
          10) will deplete, and rely on MAX_EXTENSIONS_EXCEEDED as the terminal signal. Skip a beat
          when lead_min ≥ 1.5×last_grant.
        - LIMITS of the fallback, stated plainly:
          (a) It is only sound against servers that clamp the per-extend DELTA; under maximum-lead
          clamping successive expires_at_ms differences measure the elapsed time BETWEEN calls, not
          the lease size, so every grant-derived signal merely echoes the client's own call spacing.
          (b) Regime detection from the observables (grant, elapsed) is UNDECIDABLE in general — the
          band heuristic above is best-effort, not sound. Counterexample: requested ttl 24s,
          per-extend grant cap 10s. After a lead_min skip the grant is measured across a doubled gap
          (ratio ≈ 1, inside the band), the client falls back to the held cadence min(24s/2, 30s) =
          12s, and at that cadence the ratio is 10/12 ≈ 0.833 — inside [0.75, 1.25] FOREVER while
          the lease erodes 2s per cycle to a lapse. Any true per-extend grant in
          [0.75×min(requested ttl_ms/2, 30s), 0.9×requested ttl_ms) stays misclassified permanently
          once the held cadence is adopted; and grant ≥ 0.9×requested ttl_ms does not prove the
          normal regime either — a maximum-lead clamp echoes ANY call spacing, including one that
          approaches the requested ttl.
          (c) Consequently NO portable, safe, extension-efficient heartbeat exists when servers may
          both cap the initial TTL and clamp extensions to a maximum lead without returning the
          remaining lifetime. That is WHY remaining_ttl_ms exists. Fallback clients SHOULD prefer
          over-beating (bounded cadence, budget burn, MAX_EXTENSIONS_EXCEEDED as the stop) over
          risking lease lapse.
          The primary and fallback prescriptions are intentionally asymmetric. A field-bearing client
          with reliable timing has a conservative lead lower bound and stops when it can prove that no
          complete attempt plus margin fits. A fieldless client cannot make that proof in either
          direction, so bounded over-beating is a best-effort choice that favors avoiding an
          unobservable lapse at the acknowledged cost of extension-budget burn.
        - A blind alternate-beat cadence (extend on every SECOND beat WITHOUT lead tracking) leaves zero
          margin after any single failed beat — at steady state the retry lands at the expiry instant — and
          cannot keep up when the beat interval exceeds ttl/2.
        - On both paths, whenever a failed or ambiguous attempt is retried, the client MUST use the
          SAME idempotency_key so a lost-response extend is not applied twice. Before every additional
          primary-path recovery, the client MUST apply the freshly recomputed retry_window and progress
          guards above.
        - On RESERVATION_EXPIRED, RESERVATION_FINALIZED, MAX_EXTENSIONS_EXCEEDED, TENANT_CLOSED (tenant
          closure is irreversible), or NOT_FOUND the heartbeat SHOULD stop — these conditions are permanent
          for the reservation.
      parameters:
        - $ref: '#/components/parameters/ReservationId'
        - $ref: '#/components/parameters/IdempotencyKeyHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReservationExtendRequest'
      responses:
        "200":
          description: Reservation expiry extended
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
            X-Cycles-Trace-Id: {$ref: '#/components/headers/X-Cycles-Trace-Id'}
            X-Cycles-Tenant: {$ref: '#/components/headers/X-Cycles-Tenant'}
            Date: {$ref: '#/components/headers/Date'}
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReservationExtendResponse'
        "400": {$ref: '#/components/responses/ErrorResponse'}
        "401": {$ref: '#/components/responses/ErrorResponse'}
        "403": {$ref: '#/components/responses/ErrorResponse'}
        "404": {$ref: '#/components/responses/ErrorResponse'}
        "409": {$ref: '#/components/responses/ErrorResponse'}
        "410": {$ref: '#/components/responses/ErrorResponse'}
        "429":
          description: Rate limited; retry only after the indicated delay if the reservation can remain live.
          headers:
            Retry-After: {$ref: '#/components/headers/Retry-After'}
            X-RateLimit-Reset: {$ref: '#/components/headers/X-RateLimit-Reset'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "500": {$ref: '#/components/responses/ErrorResponse'}

  /v1/evidence/{evidence_id}:
    get:
      tags: [Evidence]
      operationId: getEvidence
      x-conformance: normative
      summary: Fetch a signed CyclesEvidence envelope by content id
      description: >-
        Retrieve the signed CyclesEvidence envelope identified by `evidence_id`
        (the sha256 content hash carried on a receipt's `cycles_evidence`
        reference). Returns the envelope exactly as emitted — JCS-canonicalizable
        and Ed25519-signed per cycles-evidence-v0.2 — so any holder can verify it
        offline without trusting this server.

        AUTH (NORMATIVE): this endpoint is PUBLIC (no ApiKeyAuth). The
        `evidence_id` is an unguessable sha256 content hash that acts as a
        capability — only a party already holding the receipt (which carries the
        id) can fetch the envelope. Because the envelope is content-addressed and
        signature-verifiable, public read cannot forge or alter it. Servers
        SHOULD rate-limit this endpoint and SHOULD serve it with
        `Cache-Control: public, immutable` (the content never changes).
      security: []
      parameters:
        - $ref: '#/components/parameters/EvidenceId'
      responses:
        "200":
          description: The signed CyclesEvidence envelope.
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
            X-Cycles-Trace-Id: {$ref: '#/components/headers/X-Cycles-Trace-Id'}
            Cache-Control: {$ref: '#/components/headers/Cache-Control'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/CyclesEvidenceEnvelope'}
        "400": {$ref: '#/components/responses/ErrorResponse'}
        "404": {$ref: '#/components/responses/ErrorResponse'}
        "429":
          description: >-
            Rate limited. Public endpoints SHOULD be throttled; clients retry
            after the indicated delay.
          headers:
            Retry-After: {$ref: '#/components/headers/Retry-After'}
            X-RateLimit-Reset: {$ref: '#/components/headers/X-RateLimit-Reset'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "500": {$ref: '#/components/responses/ErrorResponse'}

  /v1/.well-known/cycles-jwks.json:
    get:
      tags: [Evidence]
      operationId: getEvidenceJwks
      x-conformance: normative
      summary: Fetch the signer's CyclesEvidence JWK Set (signer-key resolution)
      description: >-
        Return the issuing server's Ed25519 verification keys as a JWK Set, so a
        consumer can resolve a `did:cycles` `signer_did` (or confirm a raw-hex
        one) to a public key and establish signer AUTHORITY — not merely
        signature validity. This is the publication half of the ADDITIVE
        signer-key-resolution layer (cycles-evidence v0.2); the resolvable
        `signer_did` form, the NORMATIVE key-selection + validity-window rules,
        and the verification dispositions live in `cycles-evidence-v0.2.yaml`
        (`CyclesEvidenceJwks`).

        Located API-base-relative: the spec path is
        `{server_id}/.well-known/cycles-jwks.json`, and `server_id` already
        includes `/v1` (e.g. `https://cycles.example.com/v1`), so it resolves to
        `…/v1/.well-known/cycles-jwks.json` — deliberately NOT origin-rooted, so
        key authority stays anchored to the exact base the `did:cycles` hash
        commits to. The literal `.well-known` segment marks this as metadata, not
        a routed API resource.

        OPTIONAL TO PUBLISH: a server that does not participate in signer-key
        resolution need not serve this (it returns 404, and consumers fall back
        to raw-hex `signer_did` + `expected_signer` pinning — the `binding_only`
        posture). A server that DOES publish MUST serve it at this path with this
        shape and the selection/window semantics in `cycles-evidence-v0.2.yaml`.

        AUTH (NORMATIVE): PUBLIC (no ApiKeyAuth). A JWK Set is public keys only —
        the standard posture for a verification key set — and is itself the trust
        anchor consumers resolve, so it must be reachable without credentials.
        The private signing key is NEVER served by this or any endpoint. Servers
        SHOULD rate-limit and SHOULD serve with a short `Cache-Control: public`
        max-age (the set changes only on key rotation), NOT `immutable`.
      security: []
      responses:
        "200":
          description: The signer's JWK Set.
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
            X-Cycles-Trace-Id: {$ref: '#/components/headers/X-Cycles-Trace-Id'}
            Cache-Control: {$ref: '#/components/headers/Cache-Control'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/CyclesEvidenceJwks'}
        "404":
          description: >-
            This server does not publish a JWK Set (signer-key resolution not
            enabled, or the evidence signing identity is unconfigured).
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "429":
          description: >-
            Rate limited. Public endpoints SHOULD be throttled; clients retry
            after the indicated delay.
          headers:
            Retry-After: {$ref: '#/components/headers/Retry-After'}
            X-RateLimit-Reset: {$ref: '#/components/headers/X-RateLimit-Reset'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "500": {$ref: '#/components/responses/ErrorResponse'}

  /v1/balances:
    get:
      tags: [Balances]
      operationId: getBalances
      x-conformance: normative
      summary: Query current budget balances across scopes (nice-to-have)
      description: >
        Returns balances for scopes matching the provided subject filter.
        include_children MAY be ignored by v0 implementations.

        SUBJECT FILTER REQUIREMENT (NORMATIVE):
        - At least one of tenant/workspace/app/workflow/agent/toolset MUST be provided.
        - If all of these filters are omitted, server MUST return 400 with error=INVALID_REQUEST.

        TENANCY (NORMATIVE):
        - The server MUST scope results to the effective tenant derived from auth.
        - If the tenant query parameter is provided, it is validation-only and MUST match the effective tenant; otherwise the server MUST return 403 FORBIDDEN.
        - If tenant is omitted, the effective tenant is used. 

      parameters:
        - name: tenant
          in: query
          schema: {type: string}
        - name: workspace
          in: query
          schema: {type: string}
        - name: app
          in: query
          schema: {type: string}
        - name: workflow
          in: query
          schema: {type: string}
        - name: agent
          in: query
          schema: {type: string}
        - name: toolset
          in: query
          schema: {type: string}
        - name: include_children
          in: query
          schema: {type: boolean, default: false}
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        "200":
          description: Balance response
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
            X-Cycles-Trace-Id: {$ref: '#/components/headers/X-Cycles-Trace-Id'}
            X-Cycles-Tenant: {$ref: '#/components/headers/X-Cycles-Tenant'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/BalanceResponse'}
        "400": {$ref: '#/components/responses/ErrorResponse'}
        "401": {$ref: '#/components/responses/ErrorResponse'}
        "403": {$ref: '#/components/responses/ErrorResponse'}
        "500": {$ref: '#/components/responses/ErrorResponse'}

  /v1/events:
    post:
      tags: [Events]
      operationId: createEvent
      x-conformance: normative
      summary: Optional post-only accounting when pre-estimation is not available
      description: >-
        Records an accounting event without a reservation. This endpoint is optional in v0 deployments. 
        The event MUST be applied atomically across all derived scopes before the server returns 201.

        IDEMPOTENCY (NORMATIVE): 
        - On replay with the same idempotency_key, the server MUST return the original successful response payload.

        TENANCY (NORMATIVE): 
        - subject.tenant MUST match the effective tenant derived from auth; otherwise the server MUST return 403 FORBIDDEN.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/EventCreateRequest'}
      responses:
        "201":
          description: Event created and atomically applied to balances
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
            X-Cycles-Trace-Id: {$ref: '#/components/headers/X-Cycles-Trace-Id'}
            X-Cycles-Tenant: {$ref: '#/components/headers/X-Cycles-Tenant'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/EventCreateResponse'}
        "400": {$ref: '#/components/responses/ErrorResponse'}
        "401": {$ref: '#/components/responses/ErrorResponse'}
        "403": {$ref: '#/components/responses/ErrorResponse'}
        "404":
          description: >-
            NOT_FOUND — no budget exists at any of the event's derived scopes in any unit.
            The message field carries the specific "Budget not found for provided scope: ..." detail.
            Distinct from 400 UNIT_MISMATCH (a budget exists at the scope but in a different unit).
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
            X-Cycles-Trace-Id: {$ref: '#/components/headers/X-Cycles-Trace-Id'}
            X-Cycles-Tenant: {$ref: '#/components/headers/X-Cycles-Tenant'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "409":
          description: >-
            Persisting-debit denial. error=TENANT_CLOSED when the owning tenant's status is
            CLOSED (createEvent is a persisting budget debit on the same runtime-plane surface
            as commit; see ERROR SEMANTICS — there is no dry_run/decide mode on /v1/events, so
            on a fresh (non-replay) request the closed-tenant outcome is this 409, never a
            decision=DENY; a same-key replay of a pre-close event still returns its original
            stored 201 per IDEMPOTENCY). Plus the two 409 codes explicitly specified by
            EventCreateRequest.overage_policy: error=BUDGET_EXCEEDED under
            overage_policy=REJECT, and error=OVERDRAFT_LIMIT_EXCEEDED under
            overage_policy=ALLOW_WITH_OVERDRAFT.
          headers:
            X-Request-Id: {$ref: '#/components/headers/X-Request-Id'}
            X-Cycles-Trace-Id: {$ref: '#/components/headers/X-Cycles-Trace-Id'}
            X-Cycles-Tenant: {$ref: '#/components/headers/X-Cycles-Tenant'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "500": {$ref: '#/components/responses/ErrorResponse'}
