Cycles Protocol API
Interactive reference for the active Cycles Protocol runtime endpoints. Tenant-scoped runtime requests use the X-Cycles-API-Key header. The public CyclesEvidence read endpoints do not require authentication.
Since cycles-server 0.1.25.46 the public evidence and JWKS endpoints are rate-limited per client IP (default 300 requests/minute); exceeding the window returns 429 with error=LIMIT_EXCEEDED and a Retry-After header.
Getting started with the API?
- Deploy the Cycles server or use a running instance
- Create a tenant and API key via the Admin API
- Make your first Reserve call below
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 declaresecurity: [], which are PUBLIC and require no API key.
Two such endpoints exist, both in the CyclesEvidence surface:
GET /v1/evidence/{evidence_id}(envelope retrieval — itsevidence_id
is an unguessable content-hash capability and the envelope is
content-addressed and signed) andGET /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:
- During commit: when overage_policy=ALLOW_WITH_OVERDRAFT and (current_debt + delta) > overdraft_limit at commit time
- 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'sdetailsobject with:scope— the canonical scope identifier where the mismatch was detectedrequested_unit— the unit supplied by the clientexpected_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:
- Investigate which reservations caused the over-limit state
- Determine if overdraft_limit should be increased (normal variance) or if this represents anomalous consumption (incident)
- Fund the scope to repay debt below limit
- Monitor that is_over_limit returns to false
- 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").
License
Apache 2.0Servers
Optional preflight policy decision (no reservation created)
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.
Authorizations
Parameters
Header Parameters
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.
1256Request Body
Responses
Decision result
List reservations (optional recovery/debug endpoint)
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 onlistAuditLogs,
listEvents, andlistWebhookDeliveries. Always binds
tocreated_at_msregardless ofsort_by. -
expires_from/expires_to— bound onexpires_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 everyReservationSummary/
ReservationDetail, so this filter applies to every
row regardless ofstatus. -
finalized_from/finalized_to— bound on
finalized_at_ms. Thefinalized_at_msfield is OPTIONAL
onReservationSummary/ReservationDetailand 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 eitherfinalized_fromor
finalized_tois supplied — the predicate naturally fails
on field-absent rows. Callers who want a window over
EXPIRED rows should useexpires_from/expires_to
againstexpires_at_ms, which is required on every row.
Validation (applies to all three pairs):
- For each pair,
from > toMUST 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 theformat: 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.
Authorizations
Parameters
Query Parameters
Lookup handle to recover the reservation_id from a prior createReservation call.
1256Filter by reservation status (e.g., ACTIVE).
"ACTIVE""COMMITTED""RELEASED""EXPIRED"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.
"date-time"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.
"date-time"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.
"date-time"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.
"date-time"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.
"date-time"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.
"date-time"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.
"reservation_id""tenant""scope_path""status""reserved""created_at_ms""expires_at_ms""created_at_ms"Sort direction. Default descending.
"asc""desc""desc"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— populateReservationSummary.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— populateReservationSummary.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, orinclude=) MUST
be ignored, never 400. - PROJECTION-ONLY:
includeselects 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 oneincludevalue remains valid when the
next page is fetched under a different (or absent)
include, and changingincludemid-pagination MUST NOT
return 400. (Contrast the window filters above, which DO
bind the cursor.) committedis NOT gated byinclude; it is always
projected (seeReservationSummary.committed).
Maximum number of results to return
120050Opaque cursor from previous response
Responses
Reservations list
Reserve budget for a planned action (concurrency-safe)
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.
Authorizations
Parameters
Header Parameters
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.
1256Request Body
Responses
Reservation decision (ALLOW/DENY with optional caps)
Get reservation details (optional, for debugging)
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.
Authorizations
Parameters
Path Parameters
1128Responses
Reservation details
Commit actual spend for a reservation (auto-releases delta)
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.
Authorizations
Parameters
Header Parameters
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.
1256Path Parameters
1128Request Body
Responses
Commit succeeded
Release an unused reservation
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
reasonbody field
with a structured tag (e.g. "[INCIDENT_FORCE_RELEASE]")
for grep-ability in the audit log.
Authorizations
Parameters
Header Parameters
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.
1256Path Parameters
1128Request Body
Responses
Release succeeded
Extend reservation TTL (lease refresh / heartbeat)
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.
Authorizations
Parameters
Header Parameters
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.
1256Path Parameters
1128Request Body
Responses
Reservation expiry extended
Fetch a signed CyclesEvidence envelope by content id
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).
Parameters
Path Parameters
sha256 content hash of the CyclesEvidence envelope (64 lowercase hex chars).
"^[0-9a-f]{64}$"Responses
The signed CyclesEvidence envelope.
Fetch the signer's CyclesEvidence JWK Set (signer-key resolution)
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.
Responses
The signer's JWK Set.
Query current budget balances across scopes (nice-to-have)
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.
Authorizations
Parameters
Query Parameters
falseMaximum number of results to return
120050Opaque cursor from previous response
Responses
Balance response
Optional post-only accounting when pre-estimation is not available
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.
Authorizations
Parameters
Header Parameters
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.
1256Request Body
Responses
Event created and atomically applied to balances