From 9f218f143fd2d6d7092f3006eabd609741406b9e Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Fri, 22 May 2026 07:23:57 -0400 Subject: [PATCH 1/3] feat(listReservations): add expires_* / finalized_* range filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes runcycles/cycles-server#162. Follow-up to runcycles/cycles-protocol#97 (the 2026-05-21 from/to revision) addressing the use case that revision intentionally left out: cleanup sweepers that need to locate reservations expiring or already finalized within a window. Adds four optional query parameters to GET /v1/reservations, mirroring the shape of the from/to window filter: * expires_from / expires_to — bound on expires_at_ms. * finalized_from / finalized_to — bound on finalized_at_ms. All ISO 8601 date-time strings. Either side of each pair may be supplied alone (open interval) or paired (closed window). The three window filters (from/to + expires_* + finalized_*) compose with AND semantics; a row must satisfy every supplied predicate to be returned. Each pair always binds to its target field regardless of sort_by, matching the 2026-05-21 sort-key-independence rule for from/to. finalized_at_ms is OPTIONAL on ReservationSummary / ReservationDetail. Rows where the field is absent (typically ACTIVE reservations not yet finalized) 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. Validation mirrors the 2026-05-21 contract: from > to within each pair → 400 INVALID_REQUEST, malformed date-time → 400, either alone is valid, additive-parameter guarantee preserved. Sorted-path cursor invalidation extends to all six window-bound values via FilterHasher (server-side impl concern). Legacy 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 and the AND-composition rule. Spec changes: - cycles-protocol-v0.yaml: 4 new query params on listReservations plus the expanded TIME-RANGE FILTERS (NORMATIVE, ADDITIVE) prose block. - changelogs/cycles-protocol-v0.md: new v0.1.25 — 2026-05-22 entry. - CONFORMANCE.md: listReservations SHOULD bullet updated. - merged/cycles-openapi-protocol-merged.yaml: mechanically regenerated by scripts/merge_specs.py. info.version stays at 0.1.25 per the existing revision convention (multiple dated v0.1.25 entries coexist; the validator only requires the topmost heading to match info.version). Verification: - npx spectral lint cycles-protocol-v0.yaml --fail-severity=error → 0 errors. 20 pre-existing warnings on top-level schema descriptions, unchanged from main. - python scripts/validate_changelogs.py → all 5 specs OK. - python scripts/merge_specs.py → merged artifact regenerated cleanly (no merge-check drift). --- CONFORMANCE.md | 2 +- changelogs/cycles-protocol-v0.md | 64 ++++++++++ cycles-protocol-v0.yaml | 135 +++++++++++++++++++-- merged/cycles-openapi-protocol-merged.yaml | 93 ++++++++++++-- 4 files changed, 269 insertions(+), 25 deletions(-) diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 24ad123..e50e787 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -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). diff --git a/changelogs/cycles-protocol-v0.md b/changelogs/cycles-protocol-v0.md index 8f5304b..9dd947e 100644 --- a/changelogs/cycles-protocol-v0.md +++ b/changelogs/cycles-protocol-v0.md @@ -6,6 +6,70 @@ 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`. Rows where the field is absent (typically + ACTIVE reservations that have not yet reached a terminal state) + MUST be excluded from results when either `finalized_from` or + `finalized_to` is supplied. The predicate naturally fails on + field-absent rows; this is normative so all conformant servers + agree on the behavior. +- 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. +- 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. No request or response + schema changes. 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)_ diff --git a/cycles-protocol-v0.yaml b/cycles-protocol-v0.yaml index d8cbea7..e8b250d 100644 --- a/cycles-protocol-v0.yaml +++ b/cycles-protocol-v0.yaml @@ -1471,18 +1471,49 @@ paths: - 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 by servers when a reservation reaches a terminal + state (commit, release, expiry). Rows where the field is + absent (e.g. ACTIVE) MUST be excluded from results when + either `finalized_from` or `finalized_to` is supplied. + + 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). + - 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 @@ -1570,6 +1601,86 @@ paths: 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 by servers when a reservation reaches a terminal + state (commit, release, expiry). Rows where the field is + absent (typically ACTIVE) MUST be excluded from results + when either `finalized_from` or `finalized_to` is supplied. + + 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: diff --git a/merged/cycles-openapi-protocol-merged.yaml b/merged/cycles-openapi-protocol-merged.yaml index 0f2101c..baee371 100644 --- a/merged/cycles-openapi-protocol-merged.yaml +++ b/merged/cycles-openapi-protocol-merged.yaml @@ -2187,18 +2187,49 @@ paths: - 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 by servers when a reservation reaches a terminal + state (commit, release, expiry). Rows where the field is + absent (e.g. ACTIVE) MUST be excluded from results when + either `finalized_from` or `finalized_to` is supplied. + + 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). + - 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 @@ -2278,6 +2309,44 @@ paths: 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 by servers when a reservation reaches a terminal state (commit, release, expiry). Rows where the field is absent (typically ACTIVE) MUST be excluded from results when either `finalized_from` or `finalized_to` is supplied. + 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 From 7b58c7624a0dcaabd5b80eefe541e99370389288 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Fri, 22 May 2026 07:32:43 -0400 Subject: [PATCH 2/3] fix(listReservations): address reviewer P2/P3 findings on #98 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three real findings from the review pass: P2 — ReservationSummary had no `finalized_at_ms` field. The prose in the operation description and the changelog claimed it was OPTIONAL on the summary, but the schema has `additionalProperties: false` and didn't declare the field at all. listReservations returns ReservationSummary, so conformant servers could not have included the timestamp in list results and the filter would have been useful only via a follow-up getReservation call per row. Added `finalized_at_ms` as an OPTIONAL int64 property on ReservationSummary with the same shape as on ReservationDetail plus a normative description of population semantics. Old clients with strict schemas remain compatible because the field is OPTIONAL (absent in pre-revision responses, valid under the new schema when present). P2 — finalized_at_ms EXPIRED-row semantics were ambiguous. The prose said the field was populated when a reservation reached a terminal state "commit, release, expiry" — but issue #162 (and the current cycles-server runtime impl in RedisReservationRepository.buildReservationSummary) only writes finalized_at_ms from committed_at or released_at, never from an expired-sweep timestamp. EXPIRED rows have the field absent. Narrowed the contract: finalized_at_ms is populated ONLY on COMMITTED and RELEASED rows. ACTIVE and EXPIRED rows have it absent and are naturally excluded from `finalized_*` filter results. Added a normative pointer telling callers who want a window over EXPIRED rows to use `expires_*` (which works on every row since `expires_at_ms` is required). Updated three places in lockstep so the contract is consistent: - The TIME-RANGE FILTERS prose block on the listReservations operation - The `finalized_from` / `finalized_to` parameter descriptions - The `finalized_at_ms` field descriptions on both ReservationSummary and ReservationDetail P3 — changelog wording "No request or response schema changes" was wrong, even before this fix-up (the PR adds four query parameters), and is now further wrong because the ReservationSummary fix above adds an OPTIONAL response-body property. Reworded to: "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." Spec changes: - cycles-protocol-v0.yaml: ReservationSummary.finalized_at_ms added; ReservationDetail.finalized_at_ms gains a description; TIME-RANGE FILTERS prose + finalized_from / finalized_to descriptions tightened. - changelogs/cycles-protocol-v0.md: finalized_* bullet and the backward-compat bullet rewritten to reflect the new contract and the ReservationSummary addition. - merged/cycles-openapi-protocol-merged.yaml: mechanically regenerated by scripts/merge_specs.py. Verification: - npx spectral lint cycles-protocol-v0.yaml --fail-severity=error → 0 errors. 20 pre-existing warnings on top-level schema descriptions, unchanged from main. - python scripts/validate_changelogs.py → all 5 specs OK. - python scripts/merge_specs.py → merged artifact regenerated; git diff --exit-code --quiet merged/ now clean post-commit. No version bump — staying at info.version 0.1.25 with the revision-2026-05-22 entry. --- changelogs/cycles-protocol-v0.md | 36 ++++++++++++++----- cycles-protocol-v0.yaml | 41 +++++++++++++++++----- merged/cycles-openapi-protocol-merged.yaml | 26 +++++++++++--- 3 files changed, 81 insertions(+), 22 deletions(-) diff --git a/changelogs/cycles-protocol-v0.md b/changelogs/cycles-protocol-v0.md index 9dd947e..6aaa1fc 100644 --- a/changelogs/cycles-protocol-v0.md +++ b/changelogs/cycles-protocol-v0.md @@ -34,12 +34,26 @@ _(revision 2026-05-22 — `expires_*` / `finalized_*` time-range filters on list `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`. Rows where the field is absent (typically - ACTIVE reservations that have not yet reached a terminal state) - MUST be excluded from results when either `finalized_from` or - `finalized_to` is supplied. The predicate naturally fails on - field-absent rows; this is normative so all conformant servers - agree on the behavior. + `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 @@ -64,9 +78,13 @@ _(revision 2026-05-22 — `expires_*` / `finalized_*` time-range filters on list 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. No request or response - schema changes. Both ApiKeyAuth and AdminKeyAuth callers see - the new parameters. +- 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. --- diff --git a/cycles-protocol-v0.yaml b/cycles-protocol-v0.yaml index e8b250d..77a07a8 100644 --- a/cycles-protocol-v0.yaml +++ b/cycles-protocol-v0.yaml @@ -1146,6 +1146,13 @@ components: 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) @@ -1275,6 +1282,19 @@ components: 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: @@ -1494,10 +1514,13 @@ paths: - `finalized_from` / `finalized_to` — bound on `finalized_at_ms`. The `finalized_at_ms` field is OPTIONAL on `ReservationSummary` / `ReservationDetail` and is - populated by servers when a reservation reaches a terminal - state (commit, release, expiry). Rows where the field is - absent (e.g. ACTIVE) MUST be excluded from results when - either `finalized_from` or `finalized_to` is supplied. + 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 @@ -1659,10 +1682,12 @@ paths: Behavior on rows without `finalized_at_ms` (NORMATIVE): the field is OPTIONAL on reservation responses and is - populated by servers when a reservation reaches a terminal - state (commit, release, expiry). Rows where the field is - absent (typically ACTIVE) MUST be excluded from results - when either `finalized_from` or `finalized_to` is supplied. + 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. diff --git a/merged/cycles-openapi-protocol-merged.yaml b/merged/cycles-openapi-protocol-merged.yaml index baee371..42134f6 100644 --- a/merged/cycles-openapi-protocol-merged.yaml +++ b/merged/cycles-openapi-protocol-merged.yaml @@ -765,6 +765,10 @@ components: 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) @@ -883,6 +887,15 @@ components: 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: @@ -2210,10 +2223,13 @@ paths: - `finalized_from` / `finalized_to` — bound on `finalized_at_ms`. The `finalized_at_ms` field is OPTIONAL on `ReservationSummary` / `ReservationDetail` and is - populated by servers when a reservation reaches a terminal - state (commit, release, expiry). Rows where the field is - absent (e.g. ACTIVE) MUST be excluded from results when - either `finalized_from` or `finalized_to` is supplied. + 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 @@ -2333,7 +2349,7 @@ paths: 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 by servers when a reservation reaches a terminal state (commit, release, expiry). Rows where the field is absent (typically ACTIVE) MUST be excluded from results when either `finalized_from` or `finalized_to` is supplied. + 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 From a5076f3f39ce5cc50a9290a44839eba7b5587a0e Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Fri, 22 May 2026 07:36:41 -0400 Subject: [PATCH 3/3] docs(listReservations): make blank-string-as-unset normative for window bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real P2 from the review pass: issue cycles-server#162 specified that blank-string window-bound values are treated as unset, but the spec PR didn't carry that contract through normatively. With `format: date-time` declared on each bound, strict implementers could reasonably 400 on `?expires_from=&finalized_to=` as malformed — diverging from the cycles-server reference implementation that's shipped this behavior since the original from/to revision (v0.1.25.20). Same gap existed in the v0.1.25.20 spec: the original from/to contract didn't explicitly call out blanks either; the server just handled it via `parseIsoToEpochMs` returning null on `raw.isBlank()`. This PR adds the normative rule once, in the shared TIME-RANGE FILTERS validation block, covering all six bounds — both the new `expires_*` / `finalized_*` pairs from this revision and the v0.1.25.20 `from` / `to` pair. So the rule becomes retroactively explicit for the original revision too, which closes a latent divergence hazard. Rationale included in the spec prose: clients commonly emit unconditional query strings whose values come from possibly-unset variables (e.g. `?from=${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 additive-parameter guarantee is designed to avoid exactly this class of paper-cut divergence. Spec changes: - cycles-protocol-v0.yaml: new normative bullet in the TIME-RANGE FILTERS (NORMATIVE, ADDITIVE) validation sub-list. Servers MUST treat blank-string bounds as unset and MUST NOT 400 on them despite the format: date-time declaration. - changelogs/cycles-protocol-v0.md: matching bullet under the Validation section. - merged/cycles-openapi-protocol-merged.yaml: mechanically regenerated. The per-param descriptions are intentionally not duplicated — the shared block is normative, and a six-fold prose copy would just be drift bait. Verification: - npx spectral lint cycles-protocol-v0.yaml --fail-severity=error → 0 errors. 20 pre-existing warnings unchanged. - python scripts/validate_changelogs.py → all 5 specs OK. - python scripts/merge_specs.py → clean. --- changelogs/cycles-protocol-v0.md | 13 +++++++++++++ cycles-protocol-v0.yaml | 13 +++++++++++++ merged/cycles-openapi-protocol-merged.yaml | 13 +++++++++++++ 3 files changed, 39 insertions(+) diff --git a/changelogs/cycles-protocol-v0.md b/changelogs/cycles-protocol-v0.md index 6aaa1fc..3f9bac5 100644 --- a/changelogs/cycles-protocol-v0.md +++ b/changelogs/cycles-protocol-v0.md @@ -61,6 +61,19 @@ _(revision 2026-05-22 — `expires_*` / `finalized_*` time-range filters on list * 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 diff --git a/cycles-protocol-v0.yaml b/cycles-protocol-v0.yaml index 77a07a8..8b5cc9e 100644 --- a/cycles-protocol-v0.yaml +++ b/cycles-protocol-v0.yaml @@ -1526,6 +1526,19 @@ paths: - 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. diff --git a/merged/cycles-openapi-protocol-merged.yaml b/merged/cycles-openapi-protocol-merged.yaml index 42134f6..3981b47 100644 --- a/merged/cycles-openapi-protocol-merged.yaml +++ b/merged/cycles-openapi-protocol-merged.yaml @@ -2235,6 +2235,19 @@ paths: - 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.