Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ A conformant implementation SHOULD:
- Emit events for budget-state changes (reservation.*, budget.*, quota.*) matching the `EventType` enum. Implementations MAY sample or filter which events they emit, but emitted events MUST follow the schema.
- Propagate `X-Cycles-Trace-Id` and W3C `traceparent` headers per `cycles-protocol-v0.yaml` §CORRELATION AND TRACING. Trace correlation is central to multi-service debugging.
- Implement `POST /v1/decide` — marked OPTIONAL in the v0 spec, but agent frameworks need soft-landing signals for graceful degradation.
- Implement `GET /v1/reservations` (**listReservations**) — marked OPTIONAL in v0; useful for reservation recovery (re-discover a lost `reservation_id` via `idempotency_key`), for identifying stuck `ACTIVE` reservations, and for time-window queries via the additive `from`/`to` parameters (v0.1.25, revision 2026-05-21).
- Implement `GET /v1/reservations` (**listReservations**) — marked OPTIONAL in v0; useful for reservation recovery (re-discover a lost `reservation_id` via `idempotency_key`), for identifying stuck `ACTIVE` reservations, and for time-window queries via the additive `from`/`to` (v0.1.25, revision 2026-05-21), `expires_from`/`expires_to`, and `finalized_from`/`finalized_to` (v0.1.25, revision 2026-05-22) parameters.
- Implement `GET /v1/reservations/{reservation_id}` (**getReservation**) — marked "optional, for debugging" in v0; valuable for support / monitoring of long-running reservations.
- Implement `POST /v1/events` (**createEvent**) — marked OPTIONAL in v0; the post-only accounting path for cases where pre-estimation is unavailable (bills-later providers, receipt ingestion).

Expand Down
95 changes: 95 additions & 0 deletions changelogs/cycles-protocol-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,101 @@ New entries are added directly to this file. See `scripts/validate_changelogs.py

---

## v0.1.25 — 2026-05-22

_(revision 2026-05-22 — `expires_*` / `finalized_*` time-range filters on listReservations)_

- Adds four optional query parameters to `listReservations`
(`GET /v1/reservations`), mirroring the shape of the
`from`/`to` window filter shipped in revision 2026-05-21:
* `expires_from`: ISO 8601 date-time. Inclusive lower bound
on `expires_at_ms`. May be supplied alone (open upper bound)
or paired with `expires_to`.
* `expires_to`: ISO 8601 date-time. Inclusive upper bound on
`expires_at_ms`. May be supplied alone (open lower bound)
or paired with `expires_from`.
* `finalized_from`: ISO 8601 date-time. Inclusive lower bound
on `finalized_at_ms`.
* `finalized_to`: ISO 8601 date-time. Inclusive upper bound
on `finalized_at_ms`.
- Closes the use case left out of the 2026-05-21 revision: cleanup
sweepers that need to locate reservations expiring (or already
expired/finalized) within a window. The original `from`/`to`
binds to `created_at_ms`, which is unhelpful for "find what's
expiring in the next hour" or "find all rows finalized today".
- All three windows compose with AND semantics. A row must satisfy
every supplied window predicate to be returned. Each pair is
independently bound to its target field — `expires_*` binds to
`expires_at_ms` regardless of `sort_by` (just like `from`/`to`
binds to `created_at_ms`), and `finalized_*` likewise.
- `finalized_at_ms` 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; making the exclusion
normative ensures all conformant servers agree on the behavior.
Callers who want a window over EXPIRED rows should use
`expires_from` / `expires_to` against `expires_at_ms`, which
is required on every row.
- `finalized_at_ms` added as an OPTIONAL property to
`ReservationSummary`. Pre-revision the field was declared only
on `ReservationDetail` while `ReservationSummary` carried
`additionalProperties: false` — meaning servers could not
legally include it in list results, and the proposed filter
would have produced rows whose timestamps callers could not
see without a follow-up `getReservation` call. The summary now
carries the same field with the same population semantics as
the detail; existing clients with strict schemas remain
compatible because the field is OPTIONAL (absent in pre-revision
responses, valid under the new schema when present).
- Validation (mirrors revision 2026-05-21):
* Servers MUST reject `expires_from > expires_to` and
`finalized_from > finalized_to` with HTTP 400
INVALID_REQUEST.
* Either side of each pair may be supplied alone.
* Malformed date-time values MUST be rejected with HTTP 400
INVALID_REQUEST.
* Blank-string values for any window bound MUST be treated
as unset (NORMATIVE; applies to all six bounds — `from`,
`to`, `expires_from`, `expires_to`, `finalized_from`,
`finalized_to`). A client sending `?expires_from=` MUST
be handled identically to one omitting the param. This
makes normative the behavior the cycles-server reference
implementation has shipped since v0.1.25.20 (the original
`from`/`to` revision) — strict implementers would
otherwise reasonably 400 on `""` per the `format: date-time`
declaration, and divergence between conformant servers on
this common client-side pattern (`?from=${maybeUnset}`)
is the kind of cryptic-400 the additive-parameter
guarantee is designed to avoid.
- Additive-parameter guarantee: servers that don't recognize the
new parameters MUST ignore them without error and return the
unfiltered set. Older clients that never send them get the
pre-revision wire behavior byte-for-byte.
- Cursor invalidation extends the v0.1.25.20 `FilterHasher` shape:
sorted-path cursors fold all six window-bound values into the
canonical filter hash, so reusing a sorted cursor under a
different `(from, to, expires_from, expires_to, finalized_from,
finalized_to)` tuple returns HTTP 400 INVALID_REQUEST. Legacy
Redis-SCAN cursors do not carry filter state and are not
window-validated (matching how the legacy path already treats
every other filter).
- TIME-RANGE FILTERS prose block in the listReservations
operation description rewritten to cover all three field
bindings (`from`/`to` on `created_at_ms`, `expires_*` on
`expires_at_ms`, `finalized_*` on `finalized_at_ms`) and the
AND-composition rule.
- Backward compatible: purely additive. Four new query parameters
on `listReservations`; one new OPTIONAL property
(`finalized_at_ms`) on `ReservationSummary` mirroring the same
field already on `ReservationDetail`. No request-body schema
changes. Old clients ignore the new query params and tolerate
the new response field (absent on pre-revision servers). Both
ApiKeyAuth and AdminKeyAuth callers see the new parameters.

---

## v0.1.25 — 2026-05-21

_(revision 2026-05-21 — `from`/`to` created-time range filters on listReservations)_
Expand Down
173 changes: 161 additions & 12 deletions cycles-protocol-v0.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -421,12 +421,12 @@
$ref: '#/components/schemas/ErrorResponse'

schemas:
IdempotencyKey:

Check warning on line 424 in cycles-protocol-v0.yaml

View workflow job for this annotation

GitHub Actions / Lint sources, check merge drift, lint merged artifacts

schema-must-have-description Top-level schemas in components should have a description
type: string
minLength: 1
maxLength: 256

ErrorCode:

Check warning on line 429 in cycles-protocol-v0.yaml

View workflow job for this annotation

GitHub Actions / Lint sources, check merge drift, lint merged artifacts

schema-must-have-description Top-level schemas in components should have a description
type: string
enum:
- INVALID_REQUEST
Expand All @@ -445,7 +445,7 @@
- MAX_EXTENSIONS_EXCEEDED
- INTERNAL_ERROR

ErrorResponse:

Check warning on line 448 in cycles-protocol-v0.yaml

View workflow job for this annotation

GitHub Actions / Lint sources, check merge drift, lint merged artifacts

schema-must-have-description Top-level schemas in components should have a description
type: object
required: [error, message, request_id]
additionalProperties: false
Expand Down Expand Up @@ -480,7 +480,7 @@
type: object
additionalProperties: true

DecisionEnum:

Check warning on line 483 in cycles-protocol-v0.yaml

View workflow job for this annotation

GitHub Actions / Lint sources, check merge drift, lint merged artifacts

schema-must-have-description Top-level schemas in components should have a description
type: string
enum: [ALLOW, ALLOW_WITH_CAPS, DENY]

Expand Down Expand Up @@ -573,7 +573,7 @@
the companion-specs publication model does not use cross-spec
$ref.

Amount:

Check warning on line 576 in cycles-protocol-v0.yaml

View workflow job for this annotation

GitHub Actions / Lint sources, check merge drift, lint merged artifacts

schema-must-have-description Top-level schemas in components should have a description
type: object
required: [unit, amount]
additionalProperties: false
Expand Down Expand Up @@ -655,7 +655,7 @@
maxLength: 256
maxProperties: 16

Action:

Check warning on line 658 in cycles-protocol-v0.yaml

View workflow job for this annotation

GitHub Actions / Lint sources, check merge drift, lint merged artifacts

schema-must-have-description Top-level schemas in components should have a description
type: object
required: [kind, name]
additionalProperties: false
Expand Down Expand Up @@ -713,7 +713,7 @@
minimum: 0

# ---- Decide (optional) ----
DecisionRequest:

Check warning on line 716 in cycles-protocol-v0.yaml

View workflow job for this annotation

GitHub Actions / Lint sources, check merge drift, lint merged artifacts

schema-must-have-description Top-level schemas in components should have a description
type: object
required: [idempotency_key, subject, action, estimate]
additionalProperties: false
Expand All @@ -730,7 +730,7 @@
type: object
additionalProperties: true

DecisionResponse:

Check warning on line 733 in cycles-protocol-v0.yaml

View workflow job for this annotation

GitHub Actions / Lint sources, check merge drift, lint merged artifacts

schema-must-have-description Top-level schemas in components should have a description
type: object
required: [decision]
additionalProperties: false
Expand Down Expand Up @@ -798,7 +798,7 @@
description: Reservation lifecycle state (v1+ may add additional terminal states).
enum: [ACTIVE, COMMITTED, RELEASED, EXPIRED]

ReservationCreateRequest:

Check warning on line 801 in cycles-protocol-v0.yaml

View workflow job for this annotation

GitHub Actions / Lint sources, check merge drift, lint merged artifacts

schema-must-have-description Top-level schemas in components should have a description
type: object
required: [idempotency_key, subject, action, estimate]
additionalProperties: false
Expand Down Expand Up @@ -955,7 +955,7 @@
until reconciled (debt repaid below overdraft_limit, or is_over_limit cleared by operator).
Defaults to false when absent.

ReservationCreateResponse:

Check warning on line 958 in cycles-protocol-v0.yaml

View workflow job for this annotation

GitHub Actions / Lint sources, check merge drift, lint merged artifacts

schema-must-have-description Top-level schemas in components should have a description
type: object
required: [decision, affected_scopes]
additionalProperties: false
Expand Down Expand Up @@ -1146,6 +1146,13 @@
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)
Expand Down Expand Up @@ -1275,6 +1282,19 @@
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:
Expand Down Expand Up @@ -1471,18 +1491,65 @@
- Filtering on Subject.dimensions is out of scope for v0 unless explicitly implemented by the server.

TIME-RANGE FILTERS (NORMATIVE, ADDITIVE):
- Query parameters `from` and `to` (ISO 8601 date-time strings)
bound the `created_at_ms` of returned reservations, inclusive
on both ends.
- The filter is fixed to `created_at_ms` regardless of `sort_by`;
sorting by `expires_at_ms` while filtering by `from`/`to` is
valid and well-defined.
- Either may be supplied alone (open interval) or together
(closed window). `from > to` MUST return 400 INVALID_REQUEST.
- Both are additive parameters: servers that don't recognize
them MUST ignore without error and return the unfiltered set.
This matches the family-wide `from`/`to` convention on
`listAuditLogs`, `listEvents`, and `listWebhookDeliveries`.
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
Expand Down Expand Up @@ -1570,6 +1637,88 @@
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:
Expand Down
Loading