fields, Long fromMs, Long toMs) {
+ if (fromMs == null && toMs == null) return true;
+ String expiresAtStr = fields.get("expires_at");
+ if (expiresAtStr == null) return false;
+ long expiresAt;
+ try {
+ expiresAt = Long.parseLong(expiresAtStr);
+ } catch (NumberFormatException e) {
+ return false;
+ }
+ if (fromMs != null && expiresAt < fromMs) return false;
+ if (toMs != null && expiresAt > toMs) return false;
+ return true;
+ }
+
+ /**
+ * Inclusive time-window predicate for listReservations finalized_from /
+ * finalized_to filters (cycles-protocol-v0.yaml revision 2026-05-22).
+ *
+ * Per the spec, {@code finalized_at_ms} is populated ONLY on COMMITTED and
+ * RELEASED rows; absent on ACTIVE and EXPIRED. Mirrors the same projection logic
+ * as {@link #buildReservationSummary}: {@code committed_at} populates the
+ * timestamp for COMMITTED rows, {@code released_at} for RELEASED rows. Rows
+ * missing both hash fields (ACTIVE, EXPIRED, malformed) MUST be excluded from
+ * results when either bound is supplied — the spec makes this exclusion
+ * normative so conformant servers agree on the contract.
+ *
+ *
Returns true when both bounds are null (filter inactive); this case
+ * preserves the row regardless of whether {@code finalized_at_ms} is
+ * present, matching the v0.1.25.20 unfiltered behavior byte-for-byte.
+ */
+ private static boolean finalizedAtInWindow(Map fields, Long fromMs, Long toMs) {
+ if (fromMs == null && toMs == null) return true;
+ String finalizedAtStr = resolveFinalizedAtStr(fields);
+ if (finalizedAtStr == null) return false;
+ long finalizedAt;
+ try {
+ finalizedAt = Long.parseLong(finalizedAtStr);
+ } catch (NumberFormatException e) {
+ return false;
+ }
+ if (fromMs != null && finalizedAt < fromMs) return false;
+ if (toMs != null && finalizedAt > toMs) return false;
+ return true;
+ }
+
+ /**
+ * Centralized resolver for the projected {@code finalized_at_ms} timestamp,
+ * shared between the {@link #finalizedAtInWindow} predicate and the
+ * {@link #buildReservationSummary} projection. Both call sites MUST agree
+ * on which Redis hash field is the source of truth, otherwise a malformed
+ * row with both {@code committed_at} and {@code released_at} populated
+ * could be filtered using one timestamp and returned with another —
+ * violating the spec's contract that the filter operates against the
+ * returned {@code finalized_at_ms}.
+ *
+ * Released-wins: {@code released_at} dominates when both are set, matching
+ * the projection's last-write-wins assignment order in
+ * {@link #buildReservationSummary}. In the normal lifecycle the two fields
+ * are mutually exclusive (commit XOR release), so this rule is a defensive
+ * tie-breaker for malformed Redis writes only.
+ *
+ *
Returns {@code null} when both fields are absent (ACTIVE / EXPIRED rows
+ * in the normal lifecycle). Returns the raw string form so callers can
+ * decide on parse-failure handling (the predicate swallows
+ * NumberFormatException to exclude the row from filter results; the
+ * projection lets it propagate as a data-corruption signal).
+ */
+ private static String resolveFinalizedAtStr(Map fields) {
+ String releasedAt = fields.get("released_at");
+ if (releasedAt != null) return releasedAt;
+ return fields.get("committed_at");
+ }
+
private static int findSliceStart(List sorted, String sortBy,
String sortDir, SortedListCursor cursor) {
// Walk the sorted list looking for the first row strictly greater than the cursor's
@@ -1215,6 +1310,11 @@ private ReservationSummary toSummary(ReservationDetail detail) {
.reserved(detail.getReserved())
.createdAtMs(detail.getCreatedAtMs())
.expiresAtMs(detail.getExpiresAtMs())
+ // Spec: cycles-protocol-v0.yaml revision 2026-05-22. Optional;
+ // null on ACTIVE and EXPIRED rows. NON_NULL JsonInclude on the
+ // class skips the field from the wire when absent, preserving
+ // byte-for-byte response shape for pre-revision callers.
+ .finalizedAtMs(detail.getFinalizedAtMs())
.scopePath(detail.getScopePath())
.affectedScopes(detail.getAffectedScopes())
.build();
@@ -1249,13 +1349,12 @@ private ReservationDetail buildReservationSummary(Map fields) th
if (chargedAmountStr != null) {
committed = new Amount(unit, Long.parseLong(chargedAmountStr));
}
- String committedAtStr = fields.get("committed_at");
- if (committedAtStr != null) {
- finalizedAtMs = Long.parseLong(committedAtStr);
- }
- String releasedAtStr = fields.get("released_at");
- if (releasedAtStr != null) {
- finalizedAtMs = Long.parseLong(releasedAtStr);
+ // Shared resolver with finalizedAtInWindow — the predicate and the
+ // projection MUST agree on which hash field wins when both are set.
+ // See resolveFinalizedAtStr javadoc for the released-wins rationale.
+ String finalizedAtStr = resolveFinalizedAtStr(fields);
+ if (finalizedAtStr != null) {
+ finalizedAtMs = Long.parseLong(finalizedAtStr);
}
// Parse metadata if present
diff --git a/cycles-protocol-service/cycles-protocol-service-data/src/main/java/io/runcycles/protocol/data/repository/support/FilterHasher.java b/cycles-protocol-service/cycles-protocol-service-data/src/main/java/io/runcycles/protocol/data/repository/support/FilterHasher.java
index ab690b6..db3e5ea 100644
--- a/cycles-protocol-service/cycles-protocol-service-data/src/main/java/io/runcycles/protocol/data/repository/support/FilterHasher.java
+++ b/cycles-protocol-service/cycles-protocol-service-data/src/main/java/io/runcycles/protocol/data/repository/support/FilterHasher.java
@@ -22,7 +22,9 @@ private FilterHasher() {}
public static String hash(String tenant, String idempotencyKey, String status,
String workspace, String app, String workflow,
String agent, String toolset,
- Long fromMs, Long toMs) {
+ Long fromMs, Long toMs,
+ Long expiresFromMs, Long expiresToMs,
+ Long finalizedFromMs, Long finalizedToMs) {
StringBuilder canonical = new StringBuilder(256);
canonical.append("t=").append(nullSafe(tenant)).append('|');
canonical.append("i=").append(nullSafe(idempotencyKey)).append('|');
@@ -32,18 +34,34 @@ public static String hash(String tenant, String idempotencyKey, String status,
canonical.append("wf=").append(nullSafe(workflow)).append('|');
canonical.append("ag=").append(nullSafe(agent)).append('|');
canonical.append("ts=").append(nullSafe(toolset));
- // Back-compat: only emit the from/to fields when at least one bound is set.
- // A canonical form that always carried `|fr=|to=` would change the hash for
- // every pre-window cursor (including any v0.1.25.18 sorted-path cursor
- // mid-pagination across the deployment), breaking the stated wire back-compat
- // for clients that never send the new params. Gated emission preserves the
- // v0.1.25.12 8-field hash byte-exactly for the no-window case while still
- // uniquely identifying any combination of supplied bounds.
+ // Back-compat: each window pair only emits its canonical block when at
+ // least one of its bounds is set. A canonical form that always carried
+ // every window's |...=|...= chunks would change the hash for every
+ // pre-window cursor (a v0.1.25.18 sorted-path cursor mid-pagination
+ // across the deployment, a v0.1.25.20 cursor with from/to set but
+ // no expires_*/finalized_*, etc.), breaking the wire back-compat
+ // guarantee. Independent gating preserves byte-exact hashes for
+ // every prior generation of cursor that didn't carry the supplied
+ // bounds, while still uniquely identifying any new combination.
+ //
+ // v0.1.25.20 added the `fr=`/`to=` pair (created_at_ms window).
+ // v0.1.25.21 adds `ef=`/`et=` (expires_at_ms) and `ff=`/`ft=`
+ // (finalized_at_ms) per cycles-protocol-v0.yaml revision 2026-05-22.
if (fromMs != null || toMs != null) {
canonical.append('|');
canonical.append("fr=").append(nullSafeLong(fromMs)).append('|');
canonical.append("to=").append(nullSafeLong(toMs));
}
+ if (expiresFromMs != null || expiresToMs != null) {
+ canonical.append('|');
+ canonical.append("ef=").append(nullSafeLong(expiresFromMs)).append('|');
+ canonical.append("et=").append(nullSafeLong(expiresToMs));
+ }
+ if (finalizedFromMs != null || finalizedToMs != null) {
+ canonical.append('|');
+ canonical.append("ff=").append(nullSafeLong(finalizedFromMs)).append('|');
+ canonical.append("ft=").append(nullSafeLong(finalizedToMs));
+ }
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(canonical.toString().getBytes(StandardCharsets.UTF_8));
diff --git a/cycles-protocol-service/cycles-protocol-service-data/src/test/java/io/runcycles/protocol/data/repository/RedisReservationQueryTest.java b/cycles-protocol-service/cycles-protocol-service-data/src/test/java/io/runcycles/protocol/data/repository/RedisReservationQueryTest.java
index 98f7eee..2343d26 100644
--- a/cycles-protocol-service/cycles-protocol-service-data/src/test/java/io/runcycles/protocol/data/repository/RedisReservationQueryTest.java
+++ b/cycles-protocol-service/cycles-protocol-service-data/src/test/java/io/runcycles/protocol/data/repository/RedisReservationQueryTest.java
@@ -407,7 +407,7 @@ void shouldReturnEmptyListWhenNoReservations() {
when(jedis.scan(eq("0"), any(ScanParams.class))).thenReturn(scanResult);
ReservationListResponse response = repository.listReservations(
- "acme", null, null, null, null, null, null, null, 100, null, null, null, null, null);
+ "acme", null, null, null, null, null, null, null, 100, null, null, null, null, null, null, null, null, null);
assertThat(response.getReservations()).isEmpty();
assertThat(response.getHasMore()).isFalse();
@@ -433,7 +433,7 @@ void shouldFilterByStatus() {
// Filter for ACTIVE but reservation is COMMITTED
ReservationListResponse response = repository.listReservations(
- "acme", null, "ACTIVE", null, null, null, null, null, 100, null, null, null, null, null);
+ "acme", null, "ACTIVE", null, null, null, null, null, 100, null, null, null, null, null, null, null, null, null);
assertThat(response.getReservations()).isEmpty();
}
@@ -463,7 +463,7 @@ void shouldFilterByTenantExcludingOtherTenants() {
when(pipeline.hgetAll("reservation:res_r2")).thenReturn(resp2);
ReservationListResponse response = repository.listReservations(
- "acme", null, null, null, null, null, null, null, 100, null, null, null, null, null);
+ "acme", null, null, null, null, null, null, null, 100, null, null, null, null, null, null, null, null, null);
assertThat(response.getReservations()).hasSize(1);
assertThat(response.getReservations().get(0).getReservationId()).isEqualTo("r1");
@@ -495,7 +495,7 @@ void shouldFilterByWorkspaceSubjectField() {
when(pipeline.hgetAll("reservation:res_r2")).thenReturn(resp2);
ReservationListResponse response = repository.listReservations(
- "acme", null, null, "dev", null, null, null, null, 100, null, null, null, null, null);
+ "acme", null, null, "dev", null, null, null, null, 100, null, null, null, null, null, null, null, null, null);
assertThat(response.getReservations()).hasSize(1);
assertThat(response.getReservations().get(0).getReservationId()).isEqualTo("r1");
@@ -527,7 +527,7 @@ void shouldFilterByAppSubjectField() {
when(pipeline.hgetAll("reservation:res_r2")).thenReturn(resp2);
ReservationListResponse response = repository.listReservations(
- "acme", null, null, null, "myapp", null, null, null, 100, null, null, null, null, null);
+ "acme", null, null, null, "myapp", null, null, null, 100, null, null, null, null, null, null, null, null, null);
assertThat(response.getReservations()).hasSize(1);
assertThat(response.getReservations().get(0).getReservationId()).isEqualTo("r1");
@@ -556,7 +556,7 @@ void shouldRespectLimitAndReturnHasMore() {
when(pipeline.hgetAll("reservation:res_r2")).thenReturn(resp1);
ReservationListResponse response = repository.listReservations(
- "acme", null, null, null, null, null, null, null, 1, null, null, null, null, null);
+ "acme", null, null, null, null, null, null, null, 1, null, null, null, null, null, null, null, null, null);
assertThat(response.getReservations()).hasSize(1);
assertThat(response.getHasMore()).isTrue();
@@ -584,7 +584,7 @@ void shouldReturnMatchingStatusFilter() {
// Filter for COMMITTED and reservation IS COMMITTED
ReservationListResponse response = repository.listReservations(
- "acme", null, "COMMITTED", null, null, null, null, null, 100, null, null, null, null, null);
+ "acme", null, "COMMITTED", null, null, null, null, null, 100, null, null, null, null, null, null, null, null, null);
assertThat(response.getReservations()).hasSize(1);
assertThat(response.getReservations().get(0).getReservationId()).isEqualTo("r1");
@@ -623,7 +623,7 @@ void shouldFilterByIdempotencyKey() {
when(pipeline.hgetAll("reservation:res_r2")).thenReturn(resp2);
ReservationListResponse response = repository.listReservations(
- "acme", "idem-abc", null, null, null, null, null, null, 100, null, null, null, null, null);
+ "acme", "idem-abc", null, null, null, null, null, null, 100, null, null, null, null, null, null, null, null, null);
assertThat(response.getReservations()).hasSize(1);
assertThat(response.getReservations().get(0).getReservationId()).isEqualTo("r1");
@@ -650,7 +650,7 @@ void shouldReturnEmptyWhenIdempotencyKeyDoesNotMatch() {
when(pipeline.hgetAll("reservation:res_r1")).thenReturn(resp);
ReservationListResponse response = repository.listReservations(
- "acme", "idem-nonexistent", null, null, null, null, null, null, 100, null, null, null, null, null);
+ "acme", "idem-nonexistent", null, null, null, null, null, null, 100, null, null, null, null, null, null, null, null, null);
assertThat(response.getReservations()).isEmpty();
}
@@ -689,7 +689,7 @@ void shouldSkipMalformedReservationInList() {
when(pipeline.hgetAll("reservation:res_r1")).thenReturn(resp2);
ReservationListResponse response = repository.listReservations(
- "acme", null, null, null, null, null, null, null, 100, null, null, null, null, null);
+ "acme", null, null, null, null, null, null, null, 100, null, null, null, null, null, null, null, null, null);
// Broken reservation skipped, valid one returned
assertThat(response.getReservations()).hasSize(1);
@@ -738,7 +738,7 @@ void sortsByCreatedAtAsc() {
ReservationListResponse response = repository.listReservations(
"acme", null, null, null, null, null, null, null, 100, null,
- "created_at_ms", "asc", null, null);
+ "created_at_ms", "asc", null, null, null, null, null, null);
assertThat(response.getReservations()).extracting(ReservationSummary::getReservationId)
.containsExactly("r2", "r3", "r1");
@@ -781,7 +781,7 @@ void paginatesAcrossPages() {
ReservationListResponse page1 = repository.listReservations(
"acme", null, null, null, null, null, null, null, 2, null,
- "created_at_ms", "asc", null, null);
+ "created_at_ms", "asc", null, null, null, null, null, null);
assertThat(page1.getReservations()).extracting(ReservationSummary::getReservationId)
.containsExactly("r1", "r2");
@@ -790,7 +790,7 @@ void paginatesAcrossPages() {
ReservationListResponse page2 = repository.listReservations(
"acme", null, null, null, null, null, null, null, 2, page1.getNextCursor(),
- "created_at_ms", "asc", null, null);
+ "created_at_ms", "asc", null, null, null, null, null, null);
assertThat(page2.getReservations()).extracting(ReservationSummary::getReservationId)
.containsExactly("r3", "r4");
@@ -823,7 +823,7 @@ void cursorMismatchRejected() {
ReservationListResponse page1 = repository.listReservations(
"acme", null, null, null, null, null, null, null, 1, null,
- "created_at_ms", "asc", null, null);
+ "created_at_ms", "asc", null, null, null, null, null, null);
String cursor = page1.getNextCursor();
assertThat(cursor).isNotNull();
@@ -831,7 +831,7 @@ void cursorMismatchRejected() {
// Re-use cursor under a different sort_by — MUST 400 per spec.
assertThatThrownBy(() -> repository.listReservations(
"acme", null, null, null, null, null, null, null, 1, cursor,
- "status", "asc", null, null))
+ "status", "asc", null, null, null, null, null, null))
.isInstanceOf(io.runcycles.protocol.data.exception.CyclesProtocolException.class)
.hasMessageContaining("cursor is not valid");
}
@@ -879,7 +879,7 @@ void sortedHydrationStopsAtCap() {
ReservationListResponse response = repository.listReservations(
"acme", null, null, null, null, null, null, null, 5, null,
- "created_at_ms", "asc", null, null);
+ "created_at_ms", "asc", null, null, null, null, null, null);
assertThat(response.getReservations()).hasSize(5);
assertThat(response.getReservations())
@@ -905,7 +905,7 @@ void legacyCursorPreserved() {
// "42" is a legacy SCAN cursor. With no sort params, repo must honour it and
// call jedis.scan with that exact cursor value — not route to sorted path.
ReservationListResponse response = repository.listReservations(
- "acme", null, null, null, null, null, null, null, 100, "42", null, null, null, null);
+ "acme", null, null, null, null, null, null, null, 100, "42", null, null, null, null, null, null, null, null);
assertThat(response.getReservations()).isEmpty();
verify(jedis).scan(eq("42"), any(ScanParams.class));
@@ -948,7 +948,7 @@ void legacyPathFromExcludesBelow() {
ReservationListResponse response = repository.listReservations(
"acme", null, null, null, null, null, null, null, 100, null, null, null,
- 3000L, null);
+ 3000L, null, null, null, null, null);
assertThat(response.getReservations()).extracting(ReservationSummary::getReservationId)
.containsExactly("r2");
@@ -981,7 +981,7 @@ void legacyPathToExcludesAbove() {
ReservationListResponse response = repository.listReservations(
"acme", null, null, null, null, null, null, null, 100, null, null, null,
- null, 7000L);
+ null, 7000L, null, null, null, null);
assertThat(response.getReservations()).extracting(ReservationSummary::getReservationId)
.containsExactly("r1");
@@ -1027,7 +1027,7 @@ void legacyPathInclusiveBounds() {
ReservationListResponse response = repository.listReservations(
"acme", null, null, null, null, null, null, null, 100, null, null, null,
- 3000L, 7000L);
+ 3000L, 7000L, null, null, null, null);
assertThat(response.getReservations()).extracting(ReservationSummary::getReservationId)
.containsExactlyInAnyOrder("r1", "r2", "r3");
@@ -1067,7 +1067,7 @@ void sortedPathHonoursWindow() {
ReservationListResponse response = repository.listReservations(
"acme", null, null, null, null, null, null, null, 100, null,
- "created_at_ms", "asc", 200L, 1000L);
+ "created_at_ms", "asc", 200L, 1000L, null, null, null, null);
assertThat(response.getReservations()).extracting(ReservationSummary::getReservationId)
.containsExactly("r2", "r3");
@@ -1099,7 +1099,7 @@ void cursorMismatchOnWindowChange() {
ReservationListResponse page1 = repository.listReservations(
"acme", null, null, null, null, null, null, null, 1, null,
- "created_at_ms", "asc", 200L, 1000L);
+ "created_at_ms", "asc", 200L, 1000L, null, null, null, null);
String cursor = page1.getNextCursor();
assertThat(cursor).isNotNull();
@@ -1107,7 +1107,7 @@ void cursorMismatchOnWindowChange() {
// from/to so the cursor tuple invalidates on window change).
assertThatThrownBy(() -> repository.listReservations(
"acme", null, null, null, null, null, null, null, 1, cursor,
- "created_at_ms", "asc", 999L, 1000L))
+ "created_at_ms", "asc", 999L, 1000L, null, null, null, null))
.isInstanceOf(io.runcycles.protocol.data.exception.CyclesProtocolException.class)
.hasMessageContaining("cursor is not valid");
}
@@ -1142,7 +1142,7 @@ void missingCreatedAtExcludedWithBound() {
ReservationListResponse response = repository.listReservations(
"acme", null, null, null, null, null, null, null, 100, null, null, null,
- 1000L, null);
+ 1000L, null, null, null, null, null);
assertThat(response.getReservations()).extracting(ReservationSummary::getReservationId)
.containsExactly("r1");
@@ -1175,10 +1175,284 @@ void unparseableCreatedAtExcludedWithBound() {
ReservationListResponse response = repository.listReservations(
"acme", null, null, null, null, null, null, null, 100, null, null, null,
- null, 10000L);
+ null, 10000L, null, null, null, null);
assertThat(response.getReservations()).extracting(ReservationSummary::getReservationId)
.containsExactly("r1");
}
}
+
+ // cycles-protocol revision 2026-05-22 — expires_from / expires_to filter
+ // binds to expires_at_ms (required on every row); finalized_from /
+ // finalized_to filter binds to finalized_at_ms (resolved from
+ // committed_at or released_at; ACTIVE/EXPIRED rows have neither and
+ // are normatively excluded).
+ @Nested
+ @DisplayName("listReservations — expires_* / finalized_* time windows")
+ class ExpiresAndFinalizedWindowFilter {
+
+ @SuppressWarnings("unchecked")
+ @Test
+ @DisplayName("legacy path: expires_from excludes rows expiring earlier")
+ void expiresFromExcludesEarlierExpiry() {
+ when(jedisPool.getResource()).thenReturn(jedis);
+ doNothing().when(jedis).close();
+
+ // Row r1 expires at 1000 (early), r2 expires at 5000. expires_from=3000.
+ Map early = reservationFields("r1", "ACTIVE");
+ early.put("expires_at", "1000");
+ Map late = reservationFields("r2", "ACTIVE");
+ late.put("expires_at", "5000");
+ Response