diff --git a/AUDIT.md b/AUDIT.md index c62d600..b7555f0 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -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`, 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` fields on the params struct (`expires_from`, `expires_to`, `finalized_from`, `finalized_to`), one new `Option` 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`, 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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 608d07a..cffa9c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`, 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`). 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. diff --git a/Cargo.lock b/Cargo.lock index c3a58a7..11ee43a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1542,7 +1542,7 @@ dependencies = [ [[package]] name = "runcycles" -version = "0.2.5" +version = "0.2.6" dependencies = [ "async-openai", "bon", diff --git a/Cargo.toml b/Cargo.toml index 4f47fa9..9f2b245 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/models/request.rs b/src/models/request.rs index 360c453..ba59548 100644 --- a/src/models/request.rs +++ b/src/models/request.rs @@ -167,6 +167,33 @@ pub struct ListReservationsParams { /// query-string key. #[serde(skip_serializing_if = "Option::is_none")] pub to: Option, + /// 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, + /// 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, + /// 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, + /// 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, /// Cursor for pagination. #[serde(skip_serializing_if = "Option::is_none")] pub cursor: Option, diff --git a/src/models/response.rs b/src/models/response.rs index f352044..95bb2f3 100644 --- a/src/models/response.rs +++ b/src/models/response.rs @@ -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, /// The fully qualified scope path. pub scope_path: String, /// Scopes affected. diff --git a/tests/client_test.rs b/tests/client_test.rs index 744ea02..6420169 100644 --- a/tests/client_test.rs +++ b/tests/client_test.rs @@ -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(¶ms).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(¶ms).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(¶ms).await.unwrap(); + assert_eq!(resp.reservations.len(), 1); + assert_eq!(resp.reservations[0].finalized_at_ms, None); +} + // ─── get_reservation ───────────────────────────────────────────── #[tokio::test]