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
40 changes: 39 additions & 1 deletion AUDIT.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Cycles Protocol v0.1.25 — Server Implementation Audit

**Date:** 2026-05-21 (v0.1.25.19 — supply-chain CVE patch; re-pin `tomcat.version=10.1.55` in `cycles-protocol-service/pom.xml` to close 7 new CVEs flagged by Trivy against `tomcat-embed-core 10.1.54` (CRITICAL: CVE-2026-43512, CVE-2026-43515, CVE-2026-41293; HIGH: CVE-2026-43513, CVE-2026-42498, CVE-2026-41284; LOW: CVE-2026-43514 — all fixed in 10.1.55 / 11.0.22). Mirrors the v0.1.25.16 pattern; the override was dropped in v0.1.25.18 when SB 3.5.14's BOM caught up to 10.1.54, now re-added one patch higher because Trivy DB updates between 2026-05-11 (last green main run) and 2026-05-21 surfaced a new wave on the same artifact. Removable once Spring Boot ships with 10.1.55+ as its managed version. `commons-lang3.version=3.18.0` retained (CVE-2025-48924 still unfixed in SB 3.5.14's managed 3.17.0). No production code or test changes; all 537 protocol-service tests pass.),
**Date:** 2026-05-21 (v0.1.25.20 — `from` / `to` ISO-8601 time-window filter on `GET /v1/reservations` per cycles-protocol revision 2026-05-21; closes runcycles/cycles-server#159. Two new query params on `listReservations`, both `string`/`format: date-time`, both inclusive bounds on `created_at_ms`, both bind to `created_at_ms` regardless of `sort_by`. Implemented in both the legacy SCAN-cursor and sorted paths. `FilterHasher.hash(...)` now folds `fromMs`/`toMs` into the canonical hash so sorted-path cursors invalidate on window change (the legacy Redis-SCAN cursor is not window-validated, matching how it already treats every other filter). Validation: malformed values → 400, `from > to` → 400 before any repository call, blank strings treated as unset, missing/unparseable `created_at` rows defensively excluded when either bound supplied. Pure additive wire change — all v0.1.25.x clients that don't send the params continue to work byte-for-byte. 538 tests pass (375 data + 163 api).),
2026-05-21 (v0.1.25.19 — supply-chain CVE patch; re-pin `tomcat.version=10.1.55` in `cycles-protocol-service/pom.xml` to close 7 new CVEs flagged by Trivy against `tomcat-embed-core 10.1.54` (CRITICAL: CVE-2026-43512, CVE-2026-43515, CVE-2026-41293; HIGH: CVE-2026-43513, CVE-2026-42498, CVE-2026-41284; LOW: CVE-2026-43514 — all fixed in 10.1.55 / 11.0.22). Mirrors the v0.1.25.16 pattern; the override was dropped in v0.1.25.18 when SB 3.5.14's BOM caught up to 10.1.54, now re-added one patch higher because Trivy DB updates between 2026-05-11 (last green main run) and 2026-05-21 surfaced a new wave on the same artifact. Removable once Spring Boot ships with 10.1.55+ as its managed version. `commons-lang3.version=3.18.0` retained (CVE-2025-48924 still unfixed in SB 3.5.14's managed 3.17.0). No production code or test changes; all 537 protocol-service tests pass.),
2026-04-26 (v0.1.25.18 — dependency hygiene matching `cycles-server-events` v0.1.25.12: bump `spring-boot-starter-parent` 3.5.13 → 3.5.14 (patch with upstream security hardening — constant-time comparison for remote DevTools secret, `RandomValuePropertySource` SecureRandom, hostname verification applied consistently for Cassandra/RabbitMQ SSL, plus symlink-handling fixes); **drop `<tomcat.version>10.1.54</tomcat.version>` override** since Spring Boot 3.5.14's BOM now manages 10.1.54 directly (verified against `spring-boot-dependencies-3.5.14.pom`); commons-lang3 3.18.0 override retained — Spring Boot 3.5.14's BOM still manages 3.17.0. **Jedis 7.4.1 → 6.2.0** to align all three services on the same Redis client major (events at 6.2.0 since v0.1.25.12, admin at 6.2.0 in v0.1.25.41); all call sites use stable APIs (`Jedis`, `JedisPool`, `Pipeline`, `Response`, `ScanParams`, `ScanResult`, `JedisNoScriptException`) — no 7.x-only API usage. No code changes; all 152 tests pass.),
2026-04-19 (v0.1.25.17 — supply-chain CVE fix follow-up; pin `commons-lang3.version=3.18.0` to close CVE-2025-48924 (Trivy HIGH) on the `commons-lang3-3.17.0` jar that ships in the fat-jar image via `swagger-core-jakarta` (OpenAPI UI). Spring Boot 3.5.13's BOM manages commons-lang3 at 3.17.0 — override is removable once Spring Boot ships a managed version of 3.18.0+. All 152 tests pass),
2026-04-19 (v0.1.25.16 — supply-chain CVE fix; bump `spring-boot-starter-parent` 3.5.11 → 3.5.13 and pin `tomcat.version=10.1.54` to close 5 HIGH/CRITICAL CVEs flagged by the new PR-time Trivy scan — CVE-2026-22732 CRITICAL on `spring-security-web` (fixed 6.5.9, pulled in transitively by 3.5.13), CVE-2026-29129 HIGH + CVE-2026-29145 CRITICAL on `tomcat-embed-core` (fixed 10.1.53, transitive), CVE-2026-34483 HIGH + CVE-2026-34487 HIGH on `tomcat-embed-core` (fixed 10.1.54, explicit property override since Spring Boot 3.5.14 with 10.1.54+ as managed version hasn't shipped yet); no code changes, all 152 tests pass),
Expand All @@ -25,6 +26,43 @@

---

### 2026-05-21 — v0.1.25.20: `from` / `to` time-window filter on listReservations

Closes [#159](https://github.com/runcycles/cycles-server/issues/159). Spec landed in `cycles-protocol-v0.yaml` revision 2026-05-21 (PR runcycles/cycles-protocol#97); this is the matching runtime impl.

**Why the spec change.** The original "fetch last 24h of reservations" workflow required clients to sort by `created_at_ms` and walk pages until the trailing row fell out of the window, doubling page size on each retry. For high-volume agent clusters this scans far more rows than the caller needs. With server-side `from` / `to`, the scan is boundaried before hydration and cursor pagination over that window stays cursor-stable.

**Naming and wire-type.** `from` / `to` with `format: date-time` matches the family-wide convention already in use on `listAuditLogs`, `listEvents`, `listWebhookDeliveries`, `listTenantEvents`, `listTenantWebhookDeliveries` in the governance-admin spec. Bespoke `created_after`/`created_before` names or Unix-epoch wire types would have split the convention for clients and codegen.

**Sort-binding semantics.** The filter always binds to `created_at_ms`, never to the column referenced by `sort_by`. A client doing `sort_by=expires_at_ms&from=…&to=…` gets reservations *created* in the window, ordered by *expiry*. This keeps the contract predictable across sort keys — no per-key filter semantics to memorize.

**Two execution paths.** `RedisReservationRepository.listReservations` retains its v0.1.25.12 dual-path shape (legacy SCAN-cursor when no sort params are present, full sorted-path otherwise). Both paths apply the window filter as a per-row predicate immediately after the existing scope/status/tenant predicates and before hydration. Predicate body is shared via a `createdAtInWindow(fields, fromMs, toMs)` static helper.

**Sorted-path cursor invalidation.** `FilterHasher.hash(...)` now takes two additional `Long` arguments (`fromMs`, `toMs`). When at least one is non-null, the canonical string appends `|fr=<ms>|to=<ms>` after the existing eight string fields; when both are null, the appendix is **omitted entirely** so the canonical form reverts byte-exactly to the v0.1.25.12 8-field shape. This means a **sorted-path cursor** (the opaque cursor stored in `SortedListCursor.filterHash`, returned when `sort_by` / `sort_dir` is supplied) issued under one window returns HTTP 400 `INVALID_REQUEST` if re-used under a different one — same contract as the v0.1.25.12 `sort_by`/`sort_dir`/filter-mismatch path — while a sorted-path cursor issued by a v0.1.25.18 server (pre-window era) still resolves on v0.1.25.20 when the client never sends `from`/`to`, because the gated-emission canonical form matches byte-exactly. The legacy Redis-SCAN cursor (returned when no sort params are supplied) does not carry filter state and is **not** window-validated; this matches how the legacy path already treats `status` / `workspace` / `app` / other filters. Locked down by `FilterHasherTest.preservesPreWindowHashWhenBothBoundsNull` (golden `2f397ea0e8fb53b7`) and `RedisReservationQueryTest.cursorMismatchOnWindowChange`.

**Validation choices.**

- ISO-8601 parsed via `Instant.parse(...)`. Malformed values surface `DateTimeParseException` → 400 `INVALID_REQUEST` with `Invalid from: <raw>` / `Invalid to: <raw>` message — same shape as the existing `sort_by` / `sort_dir` rejections.
- `from > to` → 400 *before* the repository call. Detecting reversed windows after the scan would waste server work for an obviously-broken client; the controller-level guard is the right boundary.
- Blank-string values (`?from=&to=`) are treated as unset. This matches the additive-parameter intent — an omitted bound and an empty bound both mean "no bound on that side." Avoids the cryptic-400 hazard from a client sending an unconditional `?from=${maybeUnset}`.
- Equal bounds (`from == to`) are accepted as a degenerate closed point-window. Mathematically consistent with the inclusive-both-ends contract; clients chasing a single millisecond can do so without a special-case 400.

**Defensive read-side.** The window predicate also drops rows whose `created_at` hash field is missing or unparseable, but only when at least one bound is supplied. Without this, a stale/malformed Redis row would leak past a time filter that's supposed to exclude it. With both bounds null (filter inactive), missing/unparseable rows still surface through the rest of the pipeline as they always did — the unfiltered path is unchanged.

**Internal Java signature change, no wire impact.** `listReservations(...)` gains trailing `Long fromMs, Long toMs` (12 → 14 args). Private `listReservationsSorted(...)` mirrors. `FilterHasher.hash(...)` gains the same two trailing args (8 → 10). All Java callers updated. No wire format change — clients that omit `from`/`to` get exactly the v0.1.25.18 response byte-for-byte.

**Coverage.**

- `FilterHasherTest` — 3 new cases: distinct hash on from/to differences, positional distinctness (`from=100, to=200` ≠ `from=200, to=100`), and the pre-window 8-field hash back-compat golden case.
- `RedisReservationQueryTest` — 7 new cases under a `TimeWindowFilter` nested class: legacy-path `from` excludes below, legacy-path `to` excludes above, inclusive bounds (rows at `created_at = from` and `created_at = to` both kept, row at `to+1` dropped), sorted-path window with sort-by-`created_at_ms` ordering preserved, cursor mismatch on window change rejected with 400, missing `created_at` field excluded under window, unparseable `created_at` excluded under window.
- `ReservationControllerTest` — 7 new cases under the `ListReservations` nested class: malformed `from` → 400, malformed `to` → 400, `from > to` → 400 with `verify(repository, never())` to lock the pre-repository check, `from` only propagates, `to` only propagates, equal bounds accepted, combination with `sort_by=expires_at_ms` propagates correctly, blank strings treated as unset.

All 538 protocol-service tests pass (375 data + 163 api). The 95% coverage gate per CLAUDE.md is satisfied — the only new untested branch is the equality fallthrough on `null` for both bounds, which is covered by every pre-existing `listReservations` test that doesn't pass `from`/`to`.

**Out of scope.** The issue's rationale mentions cleanup of expired/abandoned reservations as a use case — that actually wants `expires_at` or `finalized_at` window filters, not `created_at`. Flagged on the issue thread and intentionally left as a follow-up to keep this change small and reviewable.

---

### 2026-04-18 — v0.1.25.15: audit-log retention TTL (runtime-side fix)

Closes a gap surfaced by the post-v0.1.25.14 alignment audit: runtime's `AuditRepository.log()` was writing `audit:log:{id}` string keys with no `EX`, so runtime-written audit rows persisted indefinitely until Redis memory-eviction kicked in. This broke the 400-day retention story the admin plane tells operators — admin's `AuditRepository` already applies tiered TTL (400d authenticated / 30d unauthenticated) via a conditional Lua `SET … EX ttl`, but runtime-written rows were silently non-compliant with that contract. The audit dashboard reads from the shared index, so stale admin ZSETs would also accumulate pointers to long-expired runtime rows without a compensating sweep.
Expand Down
65 changes: 65 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,71 @@ changes to request/response bodies or Lua-script semantics would require a
minor bump. "Internal signature changes" (e.g. Java method parameters) are
called out but are not breaking to API clients.

## [0.1.25.20] — 2026-05-21

`from` / `to` ISO-8601 time-window filter on `GET /v1/reservations`,
implementing `cycles-protocol-v0.yaml` revision 2026-05-21 and closing
[runcycles/cycles-server#159](https://github.com/runcycles/cycles-server/issues/159).

### Added

- **`from` and `to` query parameters on `listReservations`** — both
`string` `format: date-time` (ISO 8601), both optional, both
inclusive bounds on the reservation's `created_at_ms`. The filter
is fixed to `created_at_ms` regardless of `sort_by`, so
`sort_by=expires_at_ms&from=…&to=…` returns reservations *created*
in the window, ordered by *expiry*. Implemented in both the legacy
SCAN-cursor path and the sorted path.
- **Sorted-path cursor invalidation on window change.**
`FilterHasher.hash(...)` now folds `fromMs` and `toMs` into the
canonical hash that's embedded in the **sorted-path cursor**
(the opaque cursor returned when `sort_by` or `sort_dir` is
supplied, or when resuming a sorted cursor from a prior page),
so reusing such a cursor under a different `(from, to)` returns
HTTP 400 INVALID_REQUEST — same contract as the v0.1.25.12
`sort_by` / `sort_dir` / subject-filter mismatch path. The
legacy Redis-SCAN cursor (returned when no sort params are
supplied) is unchanged and does not carry filter state; clients
paginating with `from` / `to` but no `sort_by` must keep their
window stable across pages, matching how the legacy path
already treats every other filter.

### Validation

- Malformed `from` or `to` (anything that `Instant.parse` rejects) →
HTTP 400 INVALID_REQUEST with message `Invalid from: …` or
`Invalid to: …`.
- `from > to` → HTTP 400 INVALID_REQUEST before any repository call,
with message `from must be less than or equal to to`. Equal bounds
(closed point window) are valid.
- Blank-string values for either parameter are treated as unset (no
400). Matches the additive-parameter intent: an omitted bound and
an empty-string bound both mean "no bound on that side."
- Defensive: rows whose `created_at` hash field is missing or
unparseable are excluded when either bound is supplied. Malformed
storage rows cannot leak past a time filter.

### Internal

- `RedisReservationRepository.listReservations(...)` signature gains
trailing `Long fromMs, Long toMs` (14 args total). Same shape on the
private `listReservationsSorted(...)` helper. Internal Java signature
change only — wire format is purely additive, all v0.1.25.x clients
that don't send `from`/`to` continue to work byte-for-byte.

### Coverage

- 538 tests across the protocol-service modules pass (375 data + 163
api). New tests:
- `FilterHasherTest`: from/to inclusion, positional distinctness.
- `RedisReservationQueryTest`: 7 new cases covering legacy-path
from/to, sorted-path from/to, inclusive-bound semantics,
cursor-mismatch-on-window-change, and missing/unparseable
`created_at` defensive exclusion.
- `ReservationControllerTest`: 7 new cases covering malformed-from,
malformed-to, reversed-window, from-only, to-only, equal-bounds,
combination with `sort_by=expires_at_ms`, blank-string handling.

## [0.1.25.19] — 2026-05-21

Supply-chain CVE patch. No code, API, or Lua-script changes — pom-only.
Expand Down
14 changes: 10 additions & 4 deletions cycles-protocol-service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,12 +459,18 @@ List reservations for the effective tenant. Optional recovery/debug endpoint. Re
| `cursor` | Opaque pagination cursor from previous response |
| `sort_by` | One of `reservation_id`, `tenant`, `scope_path`, `status`, `reserved`, `created_at_ms`, `expires_at_ms` (v0.1.25.12+). Omit for legacy unordered behaviour. |
| `sort_dir` | `asc` or `desc`. Defaults to `desc` when `sort_by` is provided. Ignored unless `sort_by` is set. |
| `from` | ISO 8601 date-time (v0.1.25.20+). Inclusive lower bound on `created_at_ms`. Always binds to `created_at_ms` regardless of `sort_by`. May be supplied alone (no upper bound) or with `to`. Blank string treated as unset. |
| `to` | ISO 8601 date-time (v0.1.25.20+). Inclusive upper bound on `created_at_ms`. Same binding and blank-as-unset rules as `from`. `from > to` returns `400 INVALID_REQUEST`. |

When `sort_by` or `sort_dir` is provided, the cursor encodes the
`(sort_by, sort_dir, filters)` tuple — reusing a cursor after
changing the sort key, direction, or any filter returns `400
INVALID_REQUEST`. When both are omitted, the legacy Redis-SCAN
cursor path is used and existing clients are unaffected.
`(sort_by, sort_dir, filters)` tuple — reusing a sorted cursor after
changing the sort key, direction, or any filter (including `from` /
`to`) returns `400 INVALID_REQUEST`. When both are omitted, the
legacy Redis-SCAN cursor path is used and existing clients are
unaffected; the legacy cursor does **not** carry filter state, so
callers paginating with `from` / `to` but no `sort_by` must keep
their window stable across pages (same convention as `status` /
`workspace` / other filters on the legacy path).

**Response** `200 OK`
```json
Expand Down
Loading