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 AUDIT.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Protocol Conformance Audit — Rust Client

- **Date:** 2026-05-21 (v0.2.5 — `from` / `to` ISO-8601 window-filter fields added to `ListReservationsParams` per `cycles-protocol-v0.yaml` revision 2026-05-21; closes the Rust-client side of runcycles/cycles-server#159. Both `Option<String>`, both inclusive bounds on `created_at_ms`, both serialize via `#[serde(rename = "...")]` to land on the wire under the spec-mandated names. Pure additive struct change — callers using `Default::default()` or struct-update syntax stay compile-clean. Wire-format regression test added using wiremock's `query_param` matcher. 134 tests pass; clippy + doc-tests clean.), 2026-04-10 (protocol conformance), 2026-04-19 (supply-chain coverage — cargo-audit workflow added), 2026-05-08 (crates.io metadata refresh — description and keywords broadened to cover spend / risk / audit, no behavioral changes)
- **Date:** 2026-05-22 (v0.2.6 — `expires_*` / `finalized_*` ISO-8601 window-filter fields added to `ListReservationsParams` plus optional `finalized_at_ms` field added to `ReservationSummary` per `cycles-protocol-v0.yaml` revision 2026-05-22 (runcycles/cycles-protocol#98); closes the Rust-client side of runcycles/cycles-server#162. Four new `Option<String>` fields on the params struct (`expires_from`, `expires_to`, `finalized_from`, `finalized_to`), one new `Option<u64>` field on the response struct (`finalized_at_ms`, with `#[serde(default)]` for back-compat with pre-v0.1.25.21 servers). Wire-format regression tests + finalized_at_ms deserialization tests added. 134 tests pass; clippy + doc-tests clean.), 2026-05-21 (v0.2.5 — `from` / `to` ISO-8601 window-filter fields added to `ListReservationsParams` per `cycles-protocol-v0.yaml` revision 2026-05-21; closes the Rust-client side of runcycles/cycles-server#159. Both `Option<String>`, both inclusive bounds on `created_at_ms`, both serialize via `#[serde(rename = "...")]` to land on the wire under the spec-mandated names. Pure additive struct change — callers using `Default::default()` or struct-update syntax stay compile-clean. Wire-format regression test added using wiremock's `query_param` matcher. 134 tests pass; clippy + doc-tests clean.), 2026-04-10 (protocol conformance), 2026-04-19 (supply-chain coverage — cargo-audit workflow added), 2026-05-08 (crates.io metadata refresh — description and keywords broadened to cover spend / risk / audit, no behavioral changes)
- **Spec:** `cycles-protocol-v0.yaml` v0.1.25 (OpenAPI 3.1.0)
- **Client:** Rust 1.88+ (MSRV), reqwest 0.12, serde 1, tokio 1, bon 3
- **Cross-reference:** [cycles-server AUDIT.md](https://github.com/runcycles/cycles-server/blob/main/AUDIT.md)
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/).

## [0.2.6] - 2026-05-22

`expires_*` / `finalized_*` ISO-8601 window-filter fields on `ListReservationsParams`, plus optional `finalized_at_ms` on `ReservationSummary`. Implements `cycles-protocol-v0.yaml` revision 2026-05-22 ([runcycles/cycles-protocol#98](https://github.com/runcycles/cycles-protocol/pull/98)) on the client side; runcycles/cycles-server#163 ships the server impl. Closes the Rust-client side of runcycles/cycles-server#162.

### Added

- `ListReservationsParams::expires_from`, `::expires_to`, `::finalized_from`, `::finalized_to` (`Option<String>`, ISO 8601 date-time). Each pair binds to its target field (`expires_at_ms`, `finalized_at_ms`) independent of `from`/`to` and of any `sort_by`. The three windows compose with AND semantics. `finalized_*` excludes ACTIVE and EXPIRED rows per the spec (field absent → predicate fails).
- `ReservationSummary::finalized_at_ms` (`Option<u64>`). Populated by servers on COMMITTED and RELEASED rows; absent (deserialized as `None`) on ACTIVE/EXPIRED and on pre-v0.1.25.21 servers regardless of status. `#[serde(default)]` keeps deserialization back-compatible.
- Three regression tests under `tests/client_test.rs`:
- `list_reservations_forwards_expires_and_finalized_windows`: wiremock `query_param` matchers assert all four new fields land on the wire under their spec-mandated names.
- `list_reservations_deserializes_finalized_at_ms_on_summary`: confirms the field deserializes to `Some(value)` when the server emits it.
- `list_reservations_deserializes_absent_finalized_at_ms_as_none`: confirms back-compat with servers that don't emit the field.

### Notes

- Pure additive struct change for callers using `ListReservationsParams::default()` or `..Default::default()`.
- **Source-level breakage for exhaustive constructors.** `ListReservationsParams` is not `#[non_exhaustive]`, so downstream callers who construct it field-by-field will need to add `expires_from: None, expires_to: None, finalized_from: None, finalized_to: None` or switch to `..Default::default()`. Mirrors the v0.2.5 additive bump.
- `ReservationSummary` is `#[non_exhaustive]` (and `Deserialize`-only — callers can't construct it directly), so the new field is fully transparent.
- 134 tests pass across the integration + unit suites; doc-tests + clippy clean.

## [0.2.5] - 2026-05-21

`from` / `to` ISO-8601 window-filter fields on `ListReservationsParams`. Implements `cycles-protocol-v0.yaml` revision 2026-05-21 ([runcycles/cycles-protocol#97](https://github.com/runcycles/cycles-protocol/pull/97)) on the client side; runcycles/cycles-server#160 ships the server impl. Closes the Rust-client side of runcycles/cycles-server#159.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "runcycles"
version = "0.2.5"
version = "0.2.6"
edition = "2021"
rust-version = "1.88"
license = "Apache-2.0"
Expand Down
27 changes: 27 additions & 0 deletions src/models/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,33 @@ pub struct ListReservationsParams {
/// query-string key.
#[serde(skip_serializing_if = "Option::is_none")]
pub to: Option<String>,
/// Inclusive lower bound on `expires_at_ms`. ISO 8601 date-time.
/// Per cycles-protocol-v0.yaml revision 2026-05-22: the filter binds
/// to `expires_at_ms` independent of [`from`] / [`to`] and of any
/// `sort_by`. Applies to every row since `expires_at_ms` is required.
/// May be supplied alone or paired with [`expires_to`]. Servers
/// reject `expires_from > expires_to` with HTTP 400 INVALID_REQUEST.
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_from: Option<String>,
/// Inclusive upper bound on `expires_at_ms`. ISO 8601 date-time.
/// Same binding and open-interval rules as [`expires_from`].
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_to: Option<String>,
/// Inclusive lower bound on `finalized_at_ms`. ISO 8601 date-time.
/// Per cycles-protocol-v0.yaml revision 2026-05-22: the filter binds
/// to `finalized_at_ms`, which is populated only on COMMITTED and
/// RELEASED rows. ACTIVE and EXPIRED rows are normatively excluded
/// from results when this is set (their `finalized_at_ms` is absent
/// so the predicate fails). Callers who want a window over EXPIRED
/// rows should use [`expires_from`] / [`expires_to`] instead.
/// Servers reject `finalized_from > finalized_to` with HTTP 400.
#[serde(skip_serializing_if = "Option::is_none")]
pub finalized_from: Option<String>,
/// Inclusive upper bound on `finalized_at_ms`. ISO 8601 date-time.
/// Same binding, open-interval, and ACTIVE/EXPIRED exclusion rules
/// as [`finalized_from`].
#[serde(skip_serializing_if = "Option::is_none")]
pub finalized_to: Option<String>,
/// Cursor for pagination.
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
Expand Down
12 changes: 12 additions & 0 deletions src/models/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,18 @@ pub struct ReservationSummary {
pub created_at_ms: u64,
/// When the reservation expires (Unix ms).
pub expires_at_ms: u64,
/// When the reservation reached a terminal state (Unix ms).
/// Per cycles-protocol-v0.yaml revision 2026-05-22: populated on
/// COMMITTED and RELEASED rows only; absent on ACTIVE and EXPIRED.
/// Added to ReservationSummary in revision 2026-05-22 to support
/// the `finalized_from` / `finalized_to` window filter — callers
/// filtering on finalization time can see the timestamp directly
/// in list results without a follow-up `get_reservation` call.
/// Servers older than v0.1.25.21 do not emit this field; the
/// `Option` + `#[serde(default)]` make the deserialization
/// back-compatible.
#[serde(default)]
pub finalized_at_ms: Option<u64>,
/// The fully qualified scope path.
pub scope_path: String,
/// Scopes affected.
Expand Down
106 changes: 106 additions & 0 deletions tests/client_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,112 @@ async fn list_reservations_forwards_from_to_window() {
assert_eq!(resp.reservations.len(), 0);
}

#[tokio::test]
async fn list_reservations_forwards_expires_and_finalized_windows() {
// cycles-protocol-v0.yaml revision 2026-05-22: new `expires_from`/
// `expires_to` and `finalized_from`/`finalized_to` query params on
// listReservations. Mock matchers assert all four land on the wire
// under the spec-mandated names, distinct from `from`/`to`.
let (server, client) = setup().await;

Mock::given(method("GET"))
.and(path("/v1/reservations"))
.and(query_param("tenant", "acme"))
.and(query_param("expires_from", "2026-05-22T00:00:00Z"))
.and(query_param("expires_to", "2026-05-23T00:00:00Z"))
.and(query_param("finalized_from", "2026-05-15T00:00:00Z"))
.and(query_param("finalized_to", "2026-05-22T00:00:00Z"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"reservations": [],
"has_more": false
})))
.mount(&server)
.await;

let params = ListReservationsParams {
tenant: Some("acme".to_string()),
expires_from: Some("2026-05-22T00:00:00Z".to_string()),
expires_to: Some("2026-05-23T00:00:00Z".to_string()),
finalized_from: Some("2026-05-15T00:00:00Z".to_string()),
finalized_to: Some("2026-05-22T00:00:00Z".to_string()),
..Default::default()
};
let resp = client.list_reservations(&params).await.unwrap();
assert_eq!(resp.reservations.len(), 0);
}

#[tokio::test]
async fn list_reservations_deserializes_finalized_at_ms_on_summary() {
// cycles-protocol-v0.yaml revision 2026-05-22: ReservationSummary gains
// an optional finalized_at_ms field. Verify the client deserializes it
// correctly on rows where the server emits it.
let (server, client) = setup().await;

Mock::given(method("GET"))
.and(path("/v1/reservations"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"reservations": [
{
"reservation_id": "rsv_1",
"status": "COMMITTED",
"subject": {"tenant": "acme"},
"action": {"kind": "llm.completion", "name": "gpt-4o"},
"reserved": {"unit": "USD_MICROCENTS", "amount": 5000},
"created_at_ms": 1700000000000_u64,
"expires_at_ms": 1700000060000_u64,
"finalized_at_ms": 1700000050000_u64,
"scope_path": "tenant:acme",
"affected_scopes": ["tenant:acme"]
}
],
"has_more": false
})))
.mount(&server)
.await;

let params = ListReservationsParams::default();
let resp = client.list_reservations(&params).await.unwrap();
assert_eq!(resp.reservations.len(), 1);
assert_eq!(
resp.reservations[0].finalized_at_ms,
Some(1700000050000_u64)
);
}

#[tokio::test]
async fn list_reservations_deserializes_absent_finalized_at_ms_as_none() {
// Back-compat: pre-v0.1.25.21 servers (and ACTIVE/EXPIRED rows on
// current servers) don't emit finalized_at_ms. #[serde(default)]
// must deserialize the absent field to None.
let (server, client) = setup().await;

Mock::given(method("GET"))
.and(path("/v1/reservations"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"reservations": [
{
"reservation_id": "rsv_1",
"status": "ACTIVE",
"subject": {"tenant": "acme"},
"action": {"kind": "llm.completion", "name": "gpt-4o"},
"reserved": {"unit": "USD_MICROCENTS", "amount": 5000},
"created_at_ms": 1700000000000_u64,
"expires_at_ms": 1700000060000_u64,
"scope_path": "tenant:acme",
"affected_scopes": ["tenant:acme"]
}
],
"has_more": false
})))
.mount(&server)
.await;

let params = ListReservationsParams::default();
let resp = client.list_reservations(&params).await.unwrap();
assert_eq!(resp.reservations.len(), 1);
assert_eq!(resp.reservations[0].finalized_at_ms, None);
}

// ─── get_reservation ─────────────────────────────────────────────

#[tokio::test]
Expand Down