parsedCursor = SortedListCursor.decode(startCursor);
@@ -562,7 +563,8 @@ public ReservationListResponse listReservations(String tenant, String idempotenc
// expecting the cursor to carry the sort state — we honour that).
if (sortRequested || parsedCursor.isPresent()) {
return listReservationsSorted(tenant, idempotencyKey, status, workspace, app,
- workflow, agent, toolset, limit, parsedCursor.orElse(null), sortBy, sortDir);
+ workflow, agent, toolset, limit, parsedCursor.orElse(null), sortBy, sortDir,
+ fromMs, toMs);
}
try (Jedis jedis = jedisPool.getResource()) {
@@ -602,6 +604,7 @@ public ReservationListResponse listReservations(String tenant, String idempotenc
if (workflowSegment != null && !scopeHasSegment(scopePath, workflowSegment)) continue;
if (agentSegment != null && !scopeHasSegment(scopePath, agentSegment)) continue;
if (toolsetSegment != null && !scopeHasSegment(scopePath, toolsetSegment)) continue;
+ if (!createdAtInWindow(fields, fromMs, toMs)) continue;
result.add(toSummary(buildReservationSummary(fields)));
@@ -640,7 +643,8 @@ public ReservationListResponse listReservations(String tenant, String idempotenc
private ReservationListResponse listReservationsSorted(
String tenant, String idempotencyKey, String status,
String workspace, String app, String workflow, String agent, String toolset,
- int limit, SortedListCursor resumeCursor, String sortBy, String sortDir) {
+ int limit, SortedListCursor resumeCursor, String sortBy, String sortDir,
+ Long fromMs, Long toMs) {
// Normalize for cursor storage + comparator use. Null sort_dir with a non-null
// sort_by defaults to DESC per spec; null sort_by with a non-null sort_dir defaults
@@ -651,7 +655,7 @@ private ReservationListResponse listReservationsSorted(
: (resumeCursor != null ? resumeCursor.getSortDir() : "desc");
String filterHash = FilterHasher.hash(tenant, idempotencyKey, status,
- workspace, app, workflow, agent, toolset);
+ workspace, app, workflow, agent, toolset, fromMs, toMs);
// Spec: cursor is valid only for the same (sort_by, sort_dir, filters) tuple.
if (resumeCursor != null) {
@@ -702,6 +706,7 @@ private ReservationListResponse listReservationsSorted(
if (workflowSegment != null && !scopeHasSegment(scopePath, workflowSegment)) continue;
if (agentSegment != null && !scopeHasSegment(scopePath, agentSegment)) continue;
if (toolsetSegment != null && !scopeHasSegment(scopePath, toolsetSegment)) continue;
+ if (!createdAtInWindow(fields, fromMs, toMs)) continue;
matching.add(toSummary(buildReservationSummary(fields)));
} catch (Exception e) {
@@ -754,6 +759,32 @@ private ReservationListResponse listReservationsSorted(
}
}
+ /**
+ * Inclusive time-window predicate for listReservations from/to filters
+ * (cycles-protocol-v0.yaml revision 2026-05-21). Reads the per-reservation
+ * {@code created_at} hash field (stored as epoch-ms decimal string) and
+ * returns true iff the row is inside the requested window. A row with
+ * missing or unparseable {@code created_at} is treated as out-of-window
+ * when EITHER bound is supplied — leaking malformed-write rows past a
+ * time filter would silently break the contract.
+ *
+ * Returns true when both bounds are null (filter inactive).
+ */
+ private static boolean createdAtInWindow(Map fields, Long fromMs, Long toMs) {
+ if (fromMs == null && toMs == null) return true;
+ String createdAtStr = fields.get("created_at");
+ if (createdAtStr == null) return false;
+ long createdAt;
+ try {
+ createdAt = Long.parseLong(createdAtStr);
+ } catch (NumberFormatException e) {
+ return false;
+ }
+ if (fromMs != null && createdAt < fromMs) return false;
+ if (toMs != null && createdAt > toMs) return false;
+ return true;
+ }
+
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
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 5aa242d..ab690b6 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
@@ -21,7 +21,8 @@ private FilterHasher() {}
public static String hash(String tenant, String idempotencyKey, String status,
String workspace, String app, String workflow,
- String agent, String toolset) {
+ String agent, String toolset,
+ Long fromMs, Long toMs) {
StringBuilder canonical = new StringBuilder(256);
canonical.append("t=").append(nullSafe(tenant)).append('|');
canonical.append("i=").append(nullSafe(idempotencyKey)).append('|');
@@ -31,6 +32,18 @@ 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.
+ if (fromMs != null || toMs != null) {
+ canonical.append('|');
+ canonical.append("fr=").append(nullSafeLong(fromMs)).append('|');
+ canonical.append("to=").append(nullSafeLong(toMs));
+ }
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(canonical.toString().getBytes(StandardCharsets.UTF_8));
@@ -47,4 +60,8 @@ public static String hash(String tenant, String idempotencyKey, String status,
private static String nullSafe(String s) {
return s == null ? "" : s;
}
+
+ private static String nullSafeLong(Long v) {
+ return v == null ? "" : Long.toString(v);
+ }
}
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 4e4592b..98f7eee 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);
+ "acme", null, null, null, null, null, null, null, 100, 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);
+ "acme", null, "ACTIVE", null, null, null, null, null, 100, 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);
+ "acme", null, null, null, null, null, null, null, 100, 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);
+ "acme", null, null, "dev", null, null, null, null, 100, 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);
+ "acme", null, null, null, "myapp", null, null, null, 100, 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);
+ "acme", null, null, null, null, null, null, null, 1, 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);
+ "acme", null, "COMMITTED", null, null, null, null, null, 100, 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);
+ "acme", "idem-abc", null, null, null, null, null, null, 100, 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);
+ "acme", "idem-nonexistent", null, null, null, null, null, null, 100, 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);
+ "acme", null, null, null, null, null, null, null, 100, 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");
+ "created_at_ms", "asc", 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");
+ "created_at_ms", "asc", 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");
+ "created_at_ms", "asc", 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");
+ "created_at_ms", "asc", 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"))
+ "status", "asc", 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");
+ "created_at_ms", "asc", null, null);
assertThat(response.getReservations()).hasSize(5);
assertThat(response.getReservations())
@@ -905,10 +905,280 @@ 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);
+ "acme", null, null, null, null, null, null, null, 100, "42", null, null, null, null);
assertThat(response.getReservations()).isEmpty();
verify(jedis).scan(eq("42"), any(ScanParams.class));
}
}
+
+ // cycles-protocol revision 2026-05-21 — from/to inclusive window filter on
+ // listReservations. Verifies the predicate is applied in both the legacy
+ // SCAN-cursor path and the sorted path. The filter is fixed to created_at_ms
+ // regardless of sort_by, and missing/unparseable created_at rows are
+ // excluded when EITHER bound is supplied (defensive against malformed writes).
+ @Nested
+ @DisplayName("listReservations — from/to time window")
+ class TimeWindowFilter {
+
+ @SuppressWarnings("unchecked")
+ @Test
+ @DisplayName("legacy path: from drops rows with created_at < from")
+ void legacyPathFromExcludesBelow() {
+ when(jedisPool.getResource()).thenReturn(jedis);
+ doNothing().when(jedis).close();
+
+ Map before = reservationFields("r1", "ACTIVE");
+ before.put("created_at", "1000");
+ Map inside = reservationFields("r2", "ACTIVE");
+ inside.put("created_at", "5000");
+
+ Response