Skip to content

Latest commit

 

History

History
1304 lines (1047 loc) · 64.3 KB

File metadata and controls

1304 lines (1047 loc) · 64.3 KB

Changelog

All notable changes to cycles-server are recorded here. Format follows Keep a Changelog; versions use Semantic-ish Versioning with a fourth "patch-of-patch" segment for same-day follow-ups.

This file is for downstream consumers — people pulling the Docker image or JAR. For internal engineering history (root cause analyses, rejected alternatives, test-strategy decisions) see AUDIT.md.

Wire format is considered stable within a minor version (0.1.x). Breaking 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.58] — 2026-07-14

Changed

  • Successful direct-event replays are visible in domain metrics. Every successful keyed replay increments cycles_events_total{decision="APPLIED",reason="IDEMPOTENT_REPLAY"} rather than disappearing from the event request counter. Fresh applications remain tagged reason="OK".
  • Mutation-only observability remains exactly-once: replaying an event that originally incurred debt does not increment cycles_overdraft_incurred_total again.

Compatibility

  • Metrics-only behavior change: no protocol schema, HTTP body, Redis command, Lua script, ledger mutation, or runtime-event behavior changed. The replay path adds one in-memory Micrometer counter increment. [benchmark-skip]
  • Production and full-stack Compose defaults self-pin ghcr.io/runcycles/cycles-server:0.1.25.58.

[0.1.25.57] — 2026-07-14

Added

  • Deterministic lost-response recovery coverage. The real-Redis integration suite now discards the first successful HTTP result and repeats the exact request for reserve, commit, release, direct event, dry-run, and decide. It asserts byte-identical replay bodies, one ledger mutation, one durable reservation/event identity, and no duplicate evidence or runtime event.
  • Frozen rolling-upgrade Redis fixtures. One compatibility matrix pins pre-snapshot lifecycle fast bodies, the legacy event fast-body/backfill shape, the former dry-run namespace, pre-index reservation hashes, and the documented missing-original-body failure behavior.

Changed

  • Weaker one-off legacy replay cases were consolidated behind one fixture owner and replaced with parameterized lifecycle matrices. Existing Testcontainers Redis pause/recovery coverage remains the transport-outage check; the new lost-response cases avoid probabilistic packet timing.

Compatibility

  • Test and documentation release only: no protocol schema, HTTP behavior, production Java/Lua, Redis key layout, or performance-sensitive path changed. The compatibility suite runs in the existing PR integration job, so no old server binary or external chaos platform is required. [benchmark-skip]
  • Production and full-stack Compose defaults self-pin ghcr.io/runcycles/cycles-server:0.1.25.57.

[0.1.25.56] — 2026-07-14

Added

  • Scheduled Redis maintenance is coordinated across replicas. Reservation expiry, audit retention, event retention, created-at index repair, and created-at index sweeping now run through one shared per-job Redis lease. Owner-token compare-and-renew/delete scripts prevent a stale instance from extending or removing a successor's lease, and a bounded heartbeat keeps long reconciliations exclusive beyond the initial 30-second TTL.
  • cycles_maintenance_runs_total{job,outcome} and cycles_maintenance_duration_seconds{job,outcome} expose fixed-cardinality execution, contention, lease-loss, and failure signals.
  • Scheduled failures now use one runner-owned log message tagged by job. Operators matching the former job-specific failure strings should migrate to the documented CyclesMaintenanceFailures metric alert.

Compatibility

  • No protocol schema, HTTP request/response, ledger Lua, authoritative Redis row, or foreground request-path change. The new cycles:maintenance:lease:* keys are short-lived operational coordination state; every maintenance operation remains idempotent and retries on its next scheduled tick after Redis or process failure.
  • Production and full-stack Compose defaults self-pin ghcr.io/runcycles/cycles-server:0.1.25.56.

[0.1.25.55] — 2026-07-14

Changed

  • Reservation listing now has a typed internal query boundary. The public repository facade retains both existing method signatures, while SCAN, sorted fallback, and bounded created-at index traversal consume one immutable ReservationListQuery. This removes the 18-parameter internal call chain and keeps cursor binding, filter windows, scope matching, and projection choices together.
  • Reservation hash hydration has one owner. Summary/detail projections, optional metadata/evidence inclusion, finalized-time resolution, filtering, and model mapping now live in ReservationHashMapper, shared by listing and single-reservation reads.

Compatibility

  • No protocol schema, HTTP signature, response shape, Redis key, Lua script, filter, ordering, cursor, or feature-flag change. Existing SCAN and completeness-gated index fallback semantics are preserved and covered by the same real-Redis integration suite.
  • Production and full-stack Compose defaults self-pin ghcr.io/runcycles/cycles-server:0.1.25.55.

[0.1.25.54] — 2026-07-14

Performance

  • The default sorted reservation query is population-independent. created_at_ms sorted lists now read a completeness-gated per-tenant Redis ZSET in bounded batches instead of scanning, hydrating, and sorting every reservation globally on every page. The other six sort keys retain the authoritative full-SCAN path.
  • The tie-safe batch iterator preserves the existing total order (timestamp in the requested direction, then reservation_id ascending in both directions) inside one server-side Redis call per candidate batch. Same-host three-trial p50 medians fell from 22.5ms to 10.7ms at 1,000 rows and from 164.9ms to 11.1ms at 10,000 rows; full results are recorded in BENCHMARKS.md.

Reliability

  • Reservation creation always dual-writes the optional timestamp index. Indexed reads are enabled only after a restartable backfill publishes READY metadata and an atomic validation confirms its expected count still equals ZCARD. Missing metadata, type/count drift, malformed historical rows, or any read error falls back to the complete reservation-hash scan.
  • A nightly sweep removes stale pointers and corrects score drift; indexed reads also clean missing hashes lazily. Malformed existing rows invalidate readiness instead of deleting their pointer, and wrong-type index keys are removed before a restartable rebuild.
  • Reconciliation runs on a bounded four-thread scheduling pool so it cannot block the five-second expiry sweep. Deterministic malformed-row failures back off for one hour rather than rescanning the global keyspace every five minutes.
  • Sorted cursors are structurally and numerically validated before Redis work, producing the same 400 INVALID_REQUEST on indexed and fallback paths.
  • After one authoritative scan proves a tenant has no reservations, READY/0 metadata represents the otherwise non-persistent empty ZSET. Repeated empty reads stay cheap, and the tenant's first reserve atomically advances the expected count to one.
  • cycles.reservations.created_at_index.reads{outcome=...} exposes bounded INDEX, SCAN_DISABLED, SCAN_NOT_READY, SCAN_DRIFT, and SCAN_ERROR outcomes without a tenant tag.

Deployment

  • The read/backfill switch defaults off. Multi-pod operators must deploy this writer version everywhere with RESERVATION_CREATED_AT_INDEX_ENABLED=false, then enable it only after no older writer remains. Both production Compose files require explicit opt-in and self-pin 0.1.25.54.
  • No protocol schema, response body, ordering, filtering, or cursor-format change. Disabling the flag immediately restores the previous full-SCAN path; reservation hashes remain authoritative.

[0.1.25.53] — 2026-07-14

Performance measurement

  • Sorted reservation listing has population baselines. The benchmark suite now measures the default created_at_ms desc sorted-list query at 1,000 and 10,000 total reservation hashes, split evenly between the authenticated and an unrelated tenant. Both p50 values are tracked by nightly and release regression checks, establishing the pre-index comparison point for #240.
  • Benchmark fixture writes are pipelined and complete before timing begins. Expected large-hydration warnings are suppressed only in the benchmark profile so console I/O does not distort the measurement.

Compatibility

  • No production request-path, Redis-data, or wire-format behavior changes. Production and full-stack compose defaults self-pin ghcr.io/runcycles/cycles-server:0.1.25.53.

[0.1.25.52] — 2026-07-14

Performance

  • Keyed events retain one canonical replay body. New idempotent events keep their immutable original response only in the 30-day event hash instead of duplicating it in a separate seven-day :response string. Replays read the hash snapshot directly, preserving one Redis lookup while removing one write, one expiring key, and one full response copy per keyed event.

Changed

  • Per-scope over-limit marking has one implementation. Commit and direct event scripts now call a shared exact-int ledger helper for both ALLOW_IF_AVAILABLE and the zero-overdraft ALLOW_WITH_OVERDRAFT fallback. The protocol behavior is unchanged: only scopes whose pre-mutation balance could not cover the requested amount are marked over-limit.

Compatibility

  • Pre-0.1.25.52 event rows that only have the legacy seven-day :response key still replay byte-identically. The replay path reads that key only when the event snapshot is absent and opportunistically backfills the snapshot without recreating missing event hashes or overwriting an existing canonical body.
  • No request or response schema changes. Production and full-stack compose defaults self-pin ghcr.io/runcycles/cycles-server:0.1.25.52.

[0.1.25.51] — 2026-07-13

Fixed

  • Reserve idempotency is endpoint-scoped. Live and dry_run requests to POST /v1/reservations now share one (tenant, endpoint, idempotency_key) namespace. Reusing a key while changing dry_run returns 409 IDEMPOTENCY_MISMATCH instead of evaluating or mutating independently. Live reserve recognizes an in-flight dry-run marker, and non-persisting evaluators compare-and-set their completed body so an expired claim cannot overwrite a newer winner. An expired, uncontested claim can still publish a successful response, value-shape checks preserve 409 behavior if the companion payload hash is lost, and a legacy dry_run namespace bridge preserves pre-upgrade results for their remaining 24-hour lifetime.
  • Capped hierarchical charges mark only constrained scopes over-limit. Commit and direct-event paths no longer copy the limiting ancestor's is_over_limit=true state onto healthy descendants. This applies to ALLOW_IF_AVAILABLE and the zero-overdraft fallback of ALLOW_WITH_OVERDRAFT.
  • Event replays remain byte-identical after fast-cache loss. Idempotent events store the original Lua response in the 30-day event hash and repair the seven-day fast response key from that immutable snapshot. Legacy rows backfill the snapshot when their fast response is still available. Rows without either source return a fail-closed 500 that warns clients not to retry automatically or reuse the key, rather than synthesizing a body from current balances.
  • Corrupt expiry scope data is quarantined. Missing, malformed, empty, or non-string scope lists cannot finalize a reservation without refunding its held budget. The script removes the poison candidate from the bounded TTL sweep while preserving ACTIVE state and ledger values for reconciliation, stamps quarantined_at plus a bounded quarantine_reason, and increments cycles.reservations.quarantined with tenant/reason tags.

Compatibility

  • No request or successful-response schema changes. Production and full-stack compose defaults self-pin ghcr.io/runcycles/cycles-server:0.1.25.51.
  • During a mixed-version rolling deployment, an older pod can temporarily interpret the new reserve pending marker as an unavailable replay and return a retriable 500 for the marker's at-most-60-second lifetime. The documented production topology is single-instance; upgraded pods interoperate with pre-upgrade dry-run cache entries through the 24-hour legacy bridge.

[0.1.25.50] — 2026-07-13

Fixed

  • All budget-aware Lua paths retain int64 precision. Reserve, commit, release, expiry refunds, direct events, and extend balance snapshots now keep requested amounts, ledger arithmetic, debt, and balances as decimal strings while executing in Redis Lua. This prevents both IEEE-754 ledger drift above 2^53 and Redis cjson's 14-significant-digit response rounding. The scripts share one exact signed-decimal helper prelude instead of maintaining divergent local copies. The commit-level debt aggregate used by the int64 webhook schema saturates at Long.MAX_VALUE when multiple exact per-scope deficits exceed the aggregate domain, preventing a successful ledger mutation from becoming an unrecoverable post-mutation 500; per-scope ledger and balance values remain exact.
  • Lifecycle replay code reflects its real compatibility boundary. Dead commit/release reconstruction blocks were removed from Lua. A finalized pre-0.1.25.49 row still replays through its canonical body cache; if that cache is gone and no immutable snapshot exists, the server returns the existing retriable 500 instead of synthesizing a non-identical response.
  • Audit preparation has one owner. AuditRepository now prepares the log id, timestamp, serialized entry, score, and retention used by the atomic admin-release script and rejects missing tenant ownership before execution. Its unused fail-open write API and duplicate Java marshalling were removed.
  • Lifecycle replay metrics remain visible without double counting debt. Successful commit/release replays are recorded with reason=IDEMPOTENT_REPLAY; replaying a commit no longer increments the overdraft-incurred counter a second time.
  • Corrupt expiry rows cannot wedge the bounded sweeper. Reservations with missing, malformed, or negative estimate data are removed from the hot TTL candidate index while their state and budgets remain untouched for operator reconciliation. The expiry service logs the quarantined row at WARN instead of retrying it silently every five seconds.

Performance

  • Reservation detail, both reservation list paths, and the expiry event path now use explicit HMGET projections. Immutable response snapshots and other unrelated hash fields are no longer transferred and parsed on every read. Projection arrays are built once per operation rather than once per hydrated reservation, and all projection replies are zipped to field names by a shared HashProjections helper (name-keyed reads, no positional indexing). Lua also omits snapshot JSON when invoked without an idempotency key.

Compatibility

  • No public schema or successful-response shape changes. Large int64 values that were previously rounded are now returned and replayed exactly.
  • Production and full-stack compose defaults now self-pin the matching ghcr.io/runcycles/cycles-server:0.1.25.50 image.

[0.1.25.49] — 2026-07-12

Added

  • Event/delivery index retention sweep. A configurable daily sweep removes stale events:* and deliveries:* ZSET pointers after their backing rows expire (EVENT_RETENTION_SWEEP_CRON, default 0 30 3 * * *). Non-ZSET correlation keys are skipped without aborting either sweep pass.

Fixed

  • Expiry events are emitted exactly once. The sweeper now parses the Lua result and requires status=EXPIRED; a skipped already-expired row can no longer match merely because its state text contains EXPIRED. Event payload timestamps now read the actual Redis hash fields (created_at, expires_at).
  • Admin release audit is atomic. Admin audit JSON and both audit indexes are written by release.lua in the same Redis execution as the release, removing the successful-release/missing-audit failure window.
  • Idempotency replay remains truthful and recoverable. Reserve, commit, and release store an immutable response snapshot inside the mutation script and repair a missing fast body cache from that snapshot. A PENDINGBASE or EVIDENCE compare-and-set makes evidence enqueueing, evidence-link storage, and canonical response caching mutually exclusive with replay repair, so a concurrent miss cannot cache an evidence-less variant of a stamped response. Dry-run and decide likewise cache their response and enqueue evidence in one Redis script, and failed prepared-evidence writes now increment the evidence failure metric. Commit/release replays return a valid snapshot before consulting current budget hashes. Pre-snapshot reservations can replay while their canonical body cache survives; if it is missing, the server returns a retriable 500 rather than synthesizing a potentially different response. Reserve snapshots preserve the original decimal int64 amount instead of passing it through Redis cjson's 14-digit number formatting, and repaired reserve bodies inherit the remaining idempotency-key TTL rather than a fixed 24 hours.
  • jqwik runtime overrides work again. Configuration moved from the retired jqwik.properties format to junit-platform.properties; nightly/manual try counts use the supported jqwik.tries.default parameter.

Performance

  • API-key validation releases its Redis connection before BCrypt verification, then uses a second short checkout only for a successful key's tenant-status read. Slow password hashing no longer occupies the Redis pool.

Compatibility

  • No public request/response schema changes. Operators may override the new retention sweep cron; existing retention TTL settings remain authoritative.

[0.1.25.48] — 2026-07-11

Added

  • TENANT_CLOSED guard extended to POST /v1/events. /v1/events is a persisting budget debit, so a debit whose owning tenant is CLOSED is now rejected with HTTP 409 error=TENANT_CLOSED, mirroring the reservation guards shipped in 0.1.25.47. The check runs inside event.lua (same fail-closed whitelist as the reservation scripts: CLOSED → 409; ACTIVE/SUSPENDED → proceed; a present-but-malformed tenant:<id> record → 500 INTERNAL_ERROR, no debit; absent record → no restriction), after the idempotency-replay block and before any budget mutation — so a pre-close idempotent replay still returns its stored response, but a fresh post-flip event is rejected, with TENANT_CLOSED taking precedence over the per-scope BUDGET_* outcomes. Runtime spec revision: v0.1.25.14 (pending) adds /v1/events to the closed-tenant binding.
    • Exposure is narrow: /v1/events has no admin-key path and the tenant-key auth filter already 401s a durably-closed tenant before the controller — the guard closes the residual post-flip race (a request past auth just before the flip is durable).
    • No error-evidence change: /v1/events is outside the evidence endpoint surface (an accounting endpoint, not a decision endpoint), so none of its denials stamp cycles_evidenceTENANT_CLOSED included, consistent with the endpoint.

Compatibility

  • New 409 behavior appears only for tenants a governance plane has closed; runtime-only deployments (no tenant:<id> records) are unaffected. No wire, Redis, or event-schema change beyond the added in-script status read (one GET tenant:<id> inside event.lua, mirroring the reservation scripts). Enums.ErrorCode.TENANT_CLOSED was already added in 0.1.25.47.

[0.1.25.47] — 2026-07-10

Added

  • Governance Rule 2 terminal-owner guard on reservation mutations (TENANT_CLOSED). Once the owning tenant's status=CLOSED flip is durable in the shared Redis (written by the governance plane's tenant close), the four reservation mutations — create (POST /v1/reservations), commit, release, extend — are rejected with HTTP 409 and error=TENANT_CLOSED (standard ErrorResponse envelope). This implements the governance spec's CASCADE SEMANTICS Rule 2 / Mode B invariant (a) (cycles-governance-admin-v0.1.25.yaml): a mutation observed after the flip MUST NOT succeed, even in the race window before the close cascade drains the reservation or revokes the tenant's API keys. The check runs inside the reserve/commit/release/extend Lua scripts (like the existing BUDGET_FROZEN/BUDGET_CLOSED guards), so it is atomic with the budget mutations — a post-flip request can never partially succeed — and it is not subject to the tenant-config cache TTL. Runtime spec revision: v0.1.25.13 adds TENANT_CLOSED to the runtime ErrorCode enum (runcycles/cycles-protocol#125).
    • What changes in practice: tenant-key requests on a closed tenant were already rejected with 401 at the auth filter (which reads tenant status per request) — that behavior is unchanged. The new 409 closes the two paths auth could not: admin-key mutations (an admin-on-behalf-of release on a closed tenant previously succeeded; it now returns 409 TENANT_CLOSED) and the auth-check→script race (a request that passed auth just before the flip can no longer mutate just after it).
    • No tenant record ⇒ no restriction: runtime-only deployments without a governance plane are unaffected. A present-but-malformed tenant record (undecodable JSON, non-object, missing status, or a status outside the closed ACTIVE|SUSPENDED|CLOSED TenantStatus enum — e.g. "CLOZED" or lowercase "closed") fails closed: 500 INTERNAL_ERROR, no mutation — a corrupt governance record is never treated as an open tenant.
    • Precedence: same-key idempotent replays return their original response; any other mutation on a closed tenant is TENANT_CLOSED even when the reservation is already finalized/expired (takes precedence over RESERVATION_FINALIZED/RESERVATION_EXPIRED, per the spec revision's ERROR SEMANTICS). Open-tenant error responses are unchanged.
    • Reads unaffected: GET /v1/reservations/{id} and the list endpoint keep working on closed tenants (spec: post-mortem read access via admin keys; tenant keys remain subject to the existing auth-layer 401).
    • SUSPENDED unchanged: the mutation guard is CLOSED-only; the pre-existing auth-layer 401 for suspended tenants is untouched.
    • Idempotent replays unaffected: replaying a mutation that succeeded before the flip still returns its original response.
    • Signed denial receipts: a mutation-surface 409 TENANT_CLOSED (persisting create, commit, release) emits an error CyclesEvidence envelope and stamps cycles_evidence on the response, like the other budget/lifecycle denial codes (it is the owner-level sibling of BUDGET_CLOSED; declared in the evidence ErrorResponseMirror, cycles-evidence-v0.2.yaml 0.2.1). /v1/decide and dry_run create never 409 for a closed tenant — they return 200 decision=DENY with reason_code=TENANT_CLOSED and emit their normal decide/reserve decision evidence (see the next bullet). Extend is not an evidence endpoint and is unchanged.
    • Non-persisting evaluations answer truthfully instead of 409ing: a FRESH dry_run or POST /v1/decide evaluation on a CLOSED tenant returns 200 decision=DENY with reason_code=TENANT_CLOSED (new DecisionReasonCode value, spec revision v0.1.25.13) — previously a post-flip dry_run could produce a signed ALLOW attestation for a request whose live execution must fail. Malformed tenant records fail these evaluations closed (500, before any evidence is stamped); absent records and ACTIVE/SUSPENDED tenants evaluate exactly as before, and cached pre-close replays keep their original payload.

Fixed

  • Webhook scope_filter matching now byte-identical to the admin plane's spec-conformant matcher (cycles-server-admin PR #206). Two refinements to the runtime dispatch matcher (EventEmitterRepository.matchesScope):

    • Blank scope is unscoped: an event whose scope is blank/whitespace-only is now treated like a null-scope event — excluded from every scope-filtered subscription. Previously the bare * filter (empty-prefix startsWith) delivered blank-scope events.
    • Trailing-* filters match children only: tenant:acme-corp/* now requires a non-empty remainder after the prefix — the degenerate empty-child scope tenant:acme-corp/ no longer matches (the spec says "all scopes under acme-corp"). Unchanged: the bare base scope tenant:acme-corp never matched a …/* filter on this plane.

    Everything else is unchanged (null/blank filter matches all events including unscoped ones; no trailing * = exact, case-sensitive match). The runtime and admin matchers are now pinned to the same table of (filter, scope, expected) test cases so live dispatch and admin-plane dispatch/replay cannot drift.

Compatibility

  • Enums.ErrorCode gains TENANT_CLOSED (additive; mirrors the governance code of the same name — runtime spec revision v0.1.25.13, runcycles/cycles-protocol#125), and Enums.ReasonCode gains TENANT_CLOSED (additive; same spec revision — the 200-DENY reason for non-persisting evaluations on closed tenants). New 409 behavior appears only for tenants a governance plane has closed; deployments without tenant records see no change. The reserve/commit/ release/extend Lua scripts gain a read-only tenant-status check before their mutations (one extra Redis GET inside the script per mutation); no key layouts change. commit.lua/extend.lua read the additional tenant field from the reservation hash (written by reserve.lua since the beginning); reservations without it skip the guard.
  • Webhook delivery-selection change only for the two matcher edge cases (blank event scopes against *, empty-child scopes against …/*). Normal scopes and filters are unaffected. No other wire, Redis, event, or evidence schema change.

[0.1.25.46] — 2026-07-04

Added

  • 429 rate limiting on the public endpoints (GET /v1/evidence/* and the CyclesEvidence JWKS), implementing the spec's SHOULD-level throttling. Fixed 60s window per client IP, per instance; over-limit requests get 429 with error=LIMIT_EXCEEDED (new ErrorCode, spec v0.1.25.12), Retry-After, X-RateLimit-Reset, and X-RateLimit-Remaining: 0 — plus the standard correlation headers. Authenticated /v1 endpoints are not covered (abuse there is key-attributable). Configure with CYCLES_PUBLIC_RATE_LIMIT_ENABLED (default true) and CYCLES_PUBLIC_RATE_LIMIT_REQUESTS_PER_MINUTE (default 300).

Compatibility

  • New behavior only for anonymous callers exceeding 300 req/min/IP on the two public endpoints; normal evidence verification traffic is far below the limit. The limiter is per instance and keyed on the socket peer — deployments behind a connection-terminating ingress should rate-limit at the ingress and/or raise the limit. No wire, Redis, Lua, event, or evidence schema change. Enums.ErrorCode gains LIMIT_EXCEEDED (additive; mirrors the governance code).

[0.1.25.45] — 2026-06-27

Fixed

  • Protected operational endpoints with the configured admin key: /actuator/prometheus, /actuator/info, aggregate /actuator/health, API docs, and Swagger now require X-Admin-API-Key. Liveness/readiness probes and the protocol-public CyclesEvidence/JWKS endpoints remain unauthenticated.
  • Auth-filter error responses now set both X-Request-Id and X-Cycles-Trace-Id, and trace/request MDC filters run before security so rejected requests keep the same correlation fields as controller-handled requests.
  • Bounded the non-blocking runtime event-emission executor queue. If Redis/event persistence stalls and the queue saturates, the API still fails open for the side effect and logs a structured drop warning instead of growing heap without limit. Queue size and worker count are configurable with CYCLES_EVENTS_EMIT_QUEUE_CAPACITY and CYCLES_EVENTS_EMIT_THREADS.
  • Healthchecks now probe 127.0.0.1 readiness endpoints instead of localhost or aggregate health, avoiding IPv6 localhost ambiguity and keeping Redis-aware readiness consistent across Dockerfile and Compose files.
  • Full-stack development Compose now probes the events worker on its management port 9980 readiness endpoint, matching the production full-stack topology.
  • Removed the accidentally tracked root dump.rdb Redis snapshot and ignored future *.rdb files.
  • Production Compose examples now point at the cycles-server:0.1.25.45 image tag.

Compatibility

  • No protocol wire, Redis data-model, Lua, event schema, or evidence schema change. Operators scraping Prometheus directly must add X-Admin-API-Key to the scrape request, or inject it at trusted ingress. Under sustained event persistence outage, non-blocking webhook/event side effects may be dropped once the bounded in-process queue is full; ledger mutations remain unchanged.

[0.1.25.44] — 2026-06-26

Fixed

  • Production Compose now disables tenant labels on custom Prometheus domain metrics via CYCLES_METRICS_TENANT_TAG_ENABLED=false, avoiding tenant-id disclosure and high-cardinality series when /actuator/prometheus is exposed to a shared scrape path.
  • Production Compose now disables public SpringDoc API docs and Swagger UI by default. Local/developer defaults remain unchanged.
  • Production Compose no longer sets DASHBOARD_CORS_ORIGIN on the runtime server, because the runtime service does not consume that variable. The admin service still owns dashboard CORS in the full-stack deployment.
  • Full-stack production Compose now references current sibling service images: cycles-server-admin:0.1.25.47 and cycles-server-events:0.1.25.20.
  • Full-stack production Compose now probes admin/events readiness endpoints instead of aggregate health, matching the sibling services' Redis-aware readiness split. The events worker is published on its management port 9980, not the internal worker port 7980.
  • Full-stack production Compose now requires WEBHOOK_SECRET_ENCRYPTION_KEY for the admin/events deployment path and sets WEBHOOK_SECRET_ENCRYPTION_REQUIRED=true for admin.
  • Production Compose examples now point at the cycles-server:0.1.25.44 image tag.
  • The runtime image entrypoint now uses exec java ... so the JVM is PID 1 and receives container SIGTERM directly for Spring Boot graceful shutdown.

Compatibility

  • Deployment hardening only. No HTTP API schema, Redis data model, Lua, event, evidence, or protocol behavior change. Custom Prometheus metrics emitted from the production Compose deployment no longer include the tenant label unless explicitly re-enabled. Full-stack production deployments now fail fast if the webhook secret encryption key is missing.

[0.1.25.43] — 2026-06-25

Fixed

  • /actuator/health/readiness now includes a Redis PING health contributor. Container, Dockerfile, and release-smoke healthchecks now use readiness and go unhealthy when the ledger dependency is unreachable instead of reporting a live HTTP process as healthy while API operations return Redis-backed 5xx errors. /actuator/health/liveness remains process-only.
  • RequestIdFilter now places requestId in MDC for the lifetime of each request, matching the operations runbook and making structured logs consistently joinable by both requestId and traceId.
  • Spring Security is now explicitly stateless/API-key-only: HTTP Basic, form login, logout, and Spring Boot's generated default user autoconfiguration are disabled.
  • Redis connection failures during API-key validation now log one concise redis_unavailable warning instead of a full stack trace per affected request; unexpected validation exceptions still keep stack traces.
  • Production Compose examples now point at the 0.1.25.43 image tag.

Compatibility

  • Operational readiness change only. No HTTP API schema, Redis data model, Lua, event, evidence, or protocol behavior change. The unauthenticated readiness endpoint can now return DOWN/503 when Redis is unavailable.

[0.1.25.42] — 2026-06-25

Fixed

  • Release benchmark gating now compares the candidate median against the rolling median from benchmark-data/benchmarks/history.jsonl when history is available, while keeping the strict 25% threshold. This avoids blocking a release on one unusually fast prior release sample; baseline.json remains updated after successful releases for reference/bootstrap.
  • Guarded tenant-authorization DEBUG logging so request-time sanitizer work is skipped when DEBUG is off.

Compatibility

  • CI/workflow and benchmark script change only. No HTTP request/response, Redis, Lua, event, evidence, or spec change.

[0.1.25.41] — 2026-06-24

Fixed

  • Flattened CR/LF characters in dynamic operator-log fields added by the logging-context review so request/config/exception values cannot inject misleading log lines.
  • Removed API-key prefix/masked-token material from debug logs; auth logging now reports only key presence/length plus sanitized tenant/key/reason context on failures.
  • Sanitized JWKS retired-key parsing warnings and auth rejection logs while preserving method, path, request id, trace id, and error context.
  • Extended the same CR/LF flattening to data-plane repository/service failure logs (reservation, audit, event-emitter, evidence, expiry) via a shared LogSanitizer utility, so request-derived strings logged below the controller layer cannot inject log lines either.
  • Sanitized the remaining exception-handler path/route/reservation-id fields and tenant-authorization DEBUG values; added a regression assertion for handled protocol-exception log flattening.
  • Per-request controller request logs are emitted at DEBUG, not INFO. This removes default production success-path request log volume and keeps sanitize/attribute lookups behind isDebugEnabled; exception and side-effect-failure logs remain at INFO/WARN. Enable DEBUG for that logger to restore per-request lines. A later release-CI follow-up found the benchmark gate was also over-sensitive to a single fast baseline sample; see 0.1.25.42.

Compatibility

  • No HTTP request/response, Redis, Lua, event, evidence, or spec change. Default log volume drops (per-request request logs move from INFO to DEBUG).

[0.1.25.40] — 2026-06-24

Fixed

  • Replaced the class-only Landed in cycles exception handler log with structured protocol-exception logs carrying method, path, matched route, status, error code, request_id, trace_id, and reservation_id.
  • Added the same operational context to validation, malformed-body, and unexpected exception handler logs so 4xx/5xx responses can be joined to application logs.
  • Added request-context fields to controller request logs for reservations, balances, decisions, events, and evidence retrieval.
  • Made formerly silent non-blocking controller side-effect failures visible at WARN without changing response behavior.
  • Tightened auth and async event/evidence/audit logs to include safe identifiers such as tenant, resource, event, request, and trace context while avoiding full validation DTOs, request DTOs, API keys, and raw idempotency keys.

Validation

  • mvn -B -pl cycles-protocol-service-api -am "-Dtest=GlobalExceptionHandlerTest,ApiKeyAuthenticationFilterTest,AdminApiKeyAuthenticationFilterTest" "-Dsurefire.failIfNoSpecifiedTests=false" test
  • mvn -B -pl cycles-protocol-service-api -am "-Dtest=ReservationControllerTest,DecisionControllerTest,EventControllerTest,BalanceControllerTest,EvidenceControllerTest" "-Dsurefire.failIfNoSpecifiedTests=false" "-Dcontract.validation.enabled=false" test
  • mvn -B -pl cycles-protocol-service-data -am "-Dtest=ApiKeyRepositoryTest,ApiKeyValidationServiceTest,AuditRepositoryTest,EventEmitterRepositoryTest,EventEmitterServiceTest,ReservationExpiryServiceTest,EvidenceEmitterTest" "-Dsurefire.failIfNoSpecifiedTests=false" test

[0.1.25.39] — 2026-06-24

Fixed

  • Legacy SCAN pagination for GET /v1/reservations and GET /v1/balances now uses opaque intra-batch cursors when a page limit is reached mid-batch, preventing deterministic skipped rows on follow-up pages for an unchanged SCAN batch. As with Redis SCAN generally, pagination remains best-effort under concurrent writes or Redis rehashing.
  • Sorted GET /v1/reservations no longer truncates matches at 2000 hydrated rows. Large result sets still warn so operators can narrow filters or add indexed listing support later.
  • Auth-filter error bodies now include trace_id and set X-Cycles-Trace-Id when the trace filter has not already done so.
  • POST /v1/events idempotent replay now returns the stored original response payload and skips duplicate event metrics/balance event emission.
  • Invalid tenant default_commit_overage_policy values now fail closed with INVALID_REQUEST; Lua commit/event paths also reject unknown overage policies defensively.
  • API key validation no longer caches full allow/deny decisions. It still caches BCrypt verification results briefly, including repeated wrong-secret checks keyed by the current stored hash, while status, expiry, tenant, and tenant-status checks are read on every request.
  • Production Compose files require REDIS_PASSWORD, authenticate Redis health checks, stop publishing Redis on the host port, and pin cycles-server to 0.1.25.39.

Documentation

  • Clarified idempotency status codes: header/body key mismatch is 400 INVALID_REQUEST; same-key/different-payload replay is 409 IDEMPOTENCY_MISMATCH.

[0.1.25.38] — 2026-06-23

Fixed

  • Unconfigured CyclesEvidence no longer queues source records. When either EVIDENCE_SERVER_ID or EVIDENCE_SIGNING_SIGNER_DID is blank, EvidenceEmitter.emit(...) now returns null before building a source record or pushing to evidence:pending.
  • Configured deployments are unchanged: the emitter still null-strips the evidence payload, computes evidence_id synchronously, stamps it onto the queued record, and returns cycles_evidence for the response.

Documentation

  • Updated the evidence configuration comments to state that missing public identity disables evidence emission completely.

Validation

  • mvn -B -pl cycles-protocol-service-data -am -Dtest=EvidenceEmitterTest -Dsurefire.failIfNoSpecifiedTests=false test passes.

[0.1.25.21] — 2026-05-22

expires_from/expires_to and finalized_from/finalized_to ISO-8601 time-window filters on GET /v1/reservations, implementing cycles-protocol-v0.yaml revision 2026-05-22 (runcycles/cycles-protocol#98). Closes #162.

Added

  • Four new query parameters on listReservations mirroring the v0.1.25.20 from/to shape. All ISO 8601 format: date-time, all optional, all inclusive bounds:
    • expires_from / expires_to — bound on expires_at_ms (required field; applies to every row regardless of status).
    • finalized_from / finalized_to — bound on finalized_at_ms (populated only on COMMITTED/RELEASED; ACTIVE and EXPIRED rows are normatively excluded since the field is absent).
  • The three window filters (from/to + expires_* + finalized_*) compose with AND semantics — a row must satisfy every supplied predicate to be returned.
  • finalized_at_ms on ReservationSummary. Pre-revision the field was only on ReservationDetail, which meant clients filtering with finalized_* couldn't see the timestamp they were filtering on without a per-row getReservation call. The summary now carries the field with the same population semantics. Strict-schema clients remain compatible because the field is optional (@JsonInclude(NON_NULL)).

Validation

  • Each pair rejects expires_from > expires_to and finalized_from > finalized_to with HTTP 400 before any repository call.
  • Malformed ISO-8601 → 400 with distinct Invalid {param_name} message identifying which parameter failed.
  • Blank-string values for any of the six bounds treated as unset per the normative carve-out in the 2026-05-22 spec revision.

Internal

  • RedisReservationRepository.listReservations(...) signature gains trailing Long expiresFromMs, Long expiresToMs, Long finalizedFromMs, Long finalizedToMs (14 → 18 args). Private listReservationsSorted(...) mirrors. Two new predicate helpers: expiresAtInWindow(fields, fromMs, toMs) and finalizedAtInWindow(fields, fromMs, toMs), applied in both legacy SCAN-cursor and sorted paths after the existing scope/status/tenant predicates.
  • finalizedAtInWindow resolves the timestamp from committed_at OR released_at (whichever is set), matching buildReservationSummary's projection logic. Both fields absent → row excluded per the normative ACTIVE/EXPIRED exclusion.
  • FilterHasher.hash(...) gains four trailing Long arguments (10 → 14 args) with independent gated emission. Each window pair emits its |ef=|et= / |ff=|ft= block only when at least one of its bounds is non-null — preserves byte-exact back-compat for both v0.1.25.18 cursors (no window canonical) and v0.1.25.20 cursors (|fr=|to= canonical, no expires/finalized). Locked down by FilterHasherTest.preservesV01_25_20HashWhenOnlyFromTo (golden ad7204d521cfd133).
  • ReservationSummary.finalizedAtMs projection added to toSummary(...) builder.

Coverage

  • 557 protocol-service tests pass (384 data + 173 api), up from 538 in v0.1.25.20 (+19 new):
    • FilterHasherTest: +3 new (expires/finalized distinctness, finalized vs from/to distinctness, v0.1.25.20 8-byte golden lockdown).
    • RedisReservationQueryTest: +6 new under ExpiresAndFinalizedWindowFilter nested class.
    • ReservationControllerTest: +10 new under ListReservations nested class (4 malformed-*, 2 reversed-window, expires propagation, finalized propagation, all-three combined, blank-as-unset for new windows).
  • JaCoCo 95% bundle gate met.

Behavior change

None for existing callers. All four new params are optional; the gated FilterHasher emission preserves byte-exact cursor back-compat for both v0.1.25.18 and v0.1.25.20 sorted-path cursors. The single new response-body field on ReservationSummary is optional with @JsonInclude(NON_NULL), so v0.1.25.20-shape responses go out byte-for-byte when no terminal-state rows are returned.

[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.

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.

Fixed (security)

  • Re-pin tomcat.version=10.1.55 in cycles-protocol-service/pom.xml to close seven CVEs flagged by Trivy against org.apache.tomcat.embed:tomcat-embed-core 10.1.54 (the version Spring Boot 3.5.14's BOM manages today):
    • CVE-2026-43512 (CRITICAL) — fixed in 10.1.55 / 11.0.22.
    • CVE-2026-43515 (CRITICAL) — fixed in 10.1.55 / 11.0.22.
    • CVE-2026-41293 (CRITICAL) — fixed in 10.1.55 / 11.0.22.
    • CVE-2026-43513 (HIGH) — fixed in 10.1.55 / 11.0.22.
    • CVE-2026-42498 (HIGH) — fixed in 10.1.55 / 11.0.22.
    • CVE-2026-41284 (HIGH) — fixed in 10.1.55 / 11.0.22.
    • CVE-2026-43514 (LOW) — fixed in 10.1.55 / 11.0.22.
  • The v0.1.25.16 override (tomcat.version=10.1.54) was removed in v0.1.25.18 once Spring Boot 3.5.14's BOM caught up. This re-adds the same pattern one patch higher. Removable again once Spring Boot ships with 10.1.55+ as its managed version.

Retained

  • commons-lang3.version=3.18.0 override stays (CVE-2025-48924 still unfixed in Spring Boot 3.5.14's BOM-managed 3.17.0).

Notes

  • No production-code or test changes. All 537 protocol-service tests pass (374 data + 163 api). Wire format unchanged from v0.1.25.18.

[0.1.25.18] — 2026-04-26

Dependency hygiene aligning all three Cycles services (events / server / admin) on the same Spring Boot patch and Redis client major. No code or wire-format changes — pom-only patch.

Changed

  • Spring Boot 3.5.13 → 3.5.14. Patch upgrade picking up upstream security hardening (constant-time comparison for remote DevTools secret, RandomValuePropertySource switched to SecureRandom, hostname verification applied consistently for Cassandra/RabbitMQ SSL) plus symlink-handling fixes in ApplicationPidFileWriter / ApplicationTemp. Mirrors the events-server bump shipped in cycles-server-events v0.1.25.12.
  • Drop <tomcat.version>10.1.54</tomcat.version> override. Spring Boot 3.5.14's BOM now manages Tomcat 10.1.54 directly (verified against spring-boot-dependencies-3.5.14.pom), so the explicit pin added in v0.1.25.16 to close CVE-2026-34483 / CVE-2026-34487 is redundant. Same effective Tomcat version, smaller pom diff for future Spring Boot bumps.
  • Jedis 7.4.1 → 6.2.0. Aligns with cycles-server-events and cycles-server-admin on a single Redis-client major across the fleet, simplifying coordinated dependency upgrades. All call sites use stable APIs (Jedis, JedisPool, Pipeline, Response, ScanParams, ScanResult, JedisNoScriptException) — no 7.x-only API in use; all 152 tests pass on 6.2.0.

Retained

  • <commons-lang3.version>3.18.0</commons-lang3.version> override stays — Spring Boot 3.5.14's BOM still manages commons-lang3 at 3.17.0 (CVE-2025-48924 unfixed there), so the explicit 3.18.0 pin added in v0.1.25.17 is still required. Override comment updated to reference SB 3.5.14.

[0.1.25.17] — 2026-04-22

Fixed (security)

  • Pin commons-lang3.version=3.18.0 in cycles-protocol-service/pom.xml to close CVE-2025-48924 (Trivy HIGH) on commons-lang3-3.17.0, which ships transitively 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; the override is removable once Spring Boot ships a managed version of 3.18.0+.

Notes

  • No code, API, or Lua-script changes. All 152 tests pass. Wire format unchanged from v0.1.25.15.

[0.1.25.16] — 2026-04-19

Pom-version-only bump — no standalone GitHub release was cut for this version. Its changes ship cumulatively in v0.1.25.17.

Fixed (security)

  • Bump spring-boot-starter-parent 3.5.11 → 3.5.13 and pin tomcat.version=10.1.54 to close five HIGH/CRITICAL CVEs surfaced by the new PR-time Trivy container scan:
    • CVE-2026-22732 (CRITICAL)spring-security-web; fixed in 6.5.9, pulled in transitively by Spring Boot 3.5.13.
    • CVE-2026-29129 (HIGH) and CVE-2026-29145 (CRITICAL)tomcat-embed-core; fixed in 10.1.53, transitive via Spring Boot 3.5.13.
    • CVE-2026-34483 (HIGH) and CVE-2026-34487 (HIGH)tomcat-embed-core; fixed in 10.1.54. The explicit tomcat.version property override is needed until Spring Boot 3.5.14 (with 10.1.54+ as its managed version) ships.

Notes

  • No code or API changes. All 152 tests pass. Wire format unchanged from v0.1.25.15.

[0.1.25.15] — 2026-04-18

Fixed

  • Runtime-written audit-log entries now respect a configurable retention TTL (default 400 days). Previously, AuditRepository.log() wrote audit:log:{id} keys with no EXPIRE, so runtime-written rows persisted indefinitely until Redis eviction — silently failing to participate in the 400-day retention tier the admin plane applies to authenticated audit rows. Matches the authenticated-tier default on cycles-server-admin's AuditRepository (admin's audit.retention.authenticated.days=400). Runtime never writes the admin-plane __admin__ / __unauth__ sentinels, so a single tier is sufficient.

Added

  • audit.retention.days config (default 400, env AUDIT_RETENTION_DAYS). Set to 0 for indefinite retention (legal hold, HIPAA-adjacent deployments, or environments that offload audit to an archive store).
  • audit.sweep.cron config (default 0 0 3 * * *, env AUDIT_SWEEP_CRON). Daily @Scheduled sweep prunes stale audit:logs:{tenantId} and audit:logs:_all ZSET pointers whose target audit:log:{id} key has TTL-expired. Self-contained — does not depend on admin's sweep running against the same Redis. Safe to run in parallel with admin's sweep (idempotent ZREMRANGEBYSCORE).

Internal

  • AuditRepository.LOG_AUDIT_LUA now reads ARGV[4] as an optional TTL in seconds (0 or negative = no EX). Same shape as admin's script, minus the sentinel branching.

[0.1.25.14] — 2026-04-18

Added

  • W3C Trace Context correlation per cycles-protocol-v0.yaml revision 2026-04-18. Every response now carries an X-Cycles-Trace-Id header. The server accepts a traceparent (W3C version 00) or X-Cycles-Trace-Id header on inbound requests and echoes back the same trace_id; when neither is present it generates a fresh 128-bit id (32 lowercase hex). Malformed headers are silently ignored; the server never rejects a request for a bad correlation header.
  • trace_id field on ErrorResponse, Event, WebhookDelivery, and AuditLogEntry bodies. Optional for wire back-compat; conformant servers populate it on every payload causally downstream of the request.
  • trace_flags (^[0-9a-f]{2}$) and traceparent_inbound_valid (boolean) on WebhookDelivery per governance-admin spec v0.1.25.28. These preserve the upstream W3C sampling decision so the events sidecar can reconstruct an outbound traceparent with the correct trace-flags byte instead of defaulting to 01.
  • SLF4J MDC now carries traceId alongside requestId for every request — log aggregators can group by trace_id to see all lines produced during a single logical operation.
  • ReservationExpiryService mints a fresh trace_id per sweep batch so reservation.expired events emitted in the same sweep correlate to each other.

Internal

  • New TraceContextFilter (@Order(0)) runs before RequestIdFilter and sets the cyclesTraceId request attribute for downstream code.
  • EventEmitterService.emit(...) gains a final String traceId parameter. The full-arity emitBalanceEvents(...) signature likewise. Three prior overloads kept as delegating wrappers (traceId = null) for source compatibility with existing tests.
  • BaseController exposes protected resolveRequestId and resolveTraceId helpers that controllers use to thread the ids into event-emission and audit-log calls.

[0.1.25.13] — 2026-04-16

Fixed

  • GET /v1/reservations sorted path no longer hydrates an unbounded reservation population before the in-memory sort. A SORTED_HYDRATE_CAP = 2000 guard mirrors the admin-plane pattern shipped in cycles-server-admin v0.1.25.24: once the cap is hit the SCAN loop breaks, a WARN is logged so operators can see the window was truncated, and the cursor page still fills from the capped slice. Callers that need to see past the cap should narrow filters (status, idempotency_key, workspace/app/ workflow/agent/toolset) — same workaround doc pattern as admin.

Internal

  • Enums.ReservationSortBy and Enums.SortDirection now carry @JsonValue getWire() + @JsonCreator fromWire(String) Jackson annotations matching the admin plane's SortSpec / SortDirection pattern. Wire form stays lowercase, parsing stays case-insensitive with null → null. Controller-level validation is unchanged: unknown tokens still surface as HTTP 400 INVALID_REQUEST with the documented allow-list payload.
  • RedisReservationRepository.SORTED_HYDRATE_CAP is package-private (test-visible) to allow cap-hit tests to assert the bound deterministically.

Notes for upgraders

Behavior-visible for callers that previously relied on the sorted path silently returning all rows even for a single tenant with thousands of reservations: the page shape is identical, but rows beyond row 2000 in the capped slice are now unreachable without narrowing filters. Same trade-off doc as the admin plane cap.

[0.1.25.12] — 2026-04-16

Added

  • GET /v1/reservations now accepts sort_by and sort_dir query parameters (cycles-protocol spec revision 2026-04-16). Valid sort_by values: reservation_id, tenant, scope_path, status, reserved, created_at_ms, expires_at_ms. Valid sort_dir values: asc, desc (default desc when sort_by is provided). Invalid enum values return HTTP 400 INVALID_REQUEST.
  • Server-side ordering with deterministic reservation_id ASC tiebreaker so pagination is unambiguous under ties.
  • Opaque sorted cursor (base64url-no-pad JSON, {v,sb,sd,fh,lsv,lrid}) that binds to the (sort_by, sort_dir, filters) tuple via an 8-byte SHA-256 filter hash. Reusing a cursor under a different tuple returns HTTP 400.

Wire format

Backward compatible. Omitting sort_by/sort_dir preserves the existing Redis-SCAN cursor semantics; legacy all-digit cursors continue to work unchanged.

Internal

  • New support.SortedListCursor, support.FilterHasher, support.ReservationComparators utilities.
  • RedisReservationRepository.listReservations signature extended with trailing sortBy, sortDir parameters (10 → 12 args). Direct callers inside the service have been updated; external callers (if any) must add two trailing nulls.

Notes for upgraders

No action required for clients that don't use the new parameters. Ops teams monitoring sorted-list query latency should watch for the documented O(N) full-SCAN behaviour — see OPERATIONS.md.

0.1.25.11 — 2026-04-14

Added

  • Thundering-herd test for idempotency cache expiry. Asserts that N concurrent retries with the same idempotency key (arriving after the cache has expired) produce exactly one reservation, not N. Also verifies metric tags split correctly: 1 × reason=OK (the winner)
    • (N-1) × reason=IDEMPOTENT_REPLAY (the replays).
  • Concurrent-accuracy test for the custom cycles.reservations.reserve counter under 8-thread × 10-request load. Counter count must match client-observed successes with zero lost increments.

Wire format

Unchanged. Test-only release. No production-code changes.

Notes for upgraders

No action required. These tests are regression gates — if you're not refactoring the reservation path or the metrics component, nothing changes for you.

0.1.25.10 — 2026-04-14

Added

  • Seven domain-level Prometheus counters under the cycles_* namespace. See OPERATIONS.md for the full list, tag semantics, and alerting recipes. Short summary:
    • cycles_reservations_reserve_total
    • cycles_reservations_commit_total
    • cycles_reservations_release_total
    • cycles_reservations_extend_total
    • cycles_reservations_expired_total
    • cycles_events_total
    • cycles_overdraft_incurred_total
  • Configuration flag cycles.metrics.tenant-tag.enabled (default true) — set to false in deployments with many thousands of tenants to keep Prometheus cardinality bounded.
  • RedisDisconnectResilienceIntegrationTest — exercises the paused-Redis failure mode end-to-end and guards against silent-failure regressions.

Fixed

  • ReservationExpiryService was silently no-op'ing event emission on every expiry since v0.1.25.3 because it looked up the wrong Redis key prefix (reservation:<id> instead of reservation:res_<id>). The reservation.expired webhook now actually fires. If your downstream webhook consumer assumed expiries would never emit an event, update it before upgrading.

Wire format

Unchanged. Upgrading from v0.1.25.9 requires no client changes.

Notes for upgraders

  • New counters appear on your next Prometheus scrape. No config change needed to emit them; they are on by default.
  • The reservation.expired webhook fix will start delivering events you weren't receiving before. Confirm your webhook endpoint handles them.

0.1.25.9 — 2026-04-14

Added

  • Seven new test classes landing the second-wave coverage plan (overdraft property tests, expire.lua direct conformance, admin-release vs agent-commit race, multi-scope attribution under contention, idempotency-cache expiry, clock-skew resilience, audit-log completeness). See AUDIT.md for the full strategy.

Wire format

Unchanged. Test-only release.

0.1.25.8 — 2026-04-13

Added

  • Admin-on-behalf-of release (spec revision 2026-04-13). Dual-auth endpoint: POST /v1/reservations/{id}/release now accepts either a tenant X-Cycles-API-Key or a server-configured X-Admin-API-Key via AdminApiKeyAuthenticationFilter. Admin-driven releases write an audit entry with metadata.actor_type=admin_on_behalf_of to the shared audit:log:* Redis store, surfacing in the governance dashboard.
  • admin.api-key application property for configuring the admin key.

Security

  • Admin key comparison uses MessageDigest.isEqual (constant-time on equal-length inputs). Deployments should rotate to fixed-length keys.
  • CR/LF injection guarded in audit-log reason field.

0.1.25.7 — 2026-04-11

Added

  • Enums.ReasonCode as a typed enum (previously stringly-typed on the wire). Drop-in Jackson round-trip-compatible with existing clients.

Fixed

  • Flaky EventEmitterServiceTest on CI. Replaced Thread.sleep(200) + verify() racing with Mockito.timeout() / after().

0.1.25.6 — 2026-04-10

Changed

  • Reserve/event/decide now distinguish UNIT_MISMATCH from BUDGET_NOT_FOUND. Previously a wrong-unit request surfaced as "budget not found" which was misleading when the scope had a budget under a different unit. The scripts now probe alternate units and emit UNIT_MISMATCH with the set of configured units.

0.1.25.5 — 2026-04-08

Fixed

  • Duplicate emission of budget-state transition events (budget.approaching_limit, budget.at_limit, budget.over_limit, debt.incurred) on multi-scope operations. Closes cycles-server-events#15.

0.1.25.4 — 2026-04-07

Changed

  • Event-data payloads now include all fields the webhook consumers require for correct dedup/ordering (reservation_id, scope, unit, actor, timestamps). Previously some events arrived with missing fields that forced webhook consumers to re-query the server.

0.1.25.3 — 2026-04-03

Added

  • Runtime event emission: reservation.reserved, reservation.committed, reservation.released, reservation.expired, reservation.extended, event.applied, and the budget-state transitions. Events land on a Redis stream consumed by cycles-server-events for webhook fan-out.
  • PROTOCOL_VERSION constant correctly set to v0.1.25.

0.1.25.2 — 2026-04-02

Fixed

  • getBalances and listReservations now lowercase the stored scope before segment matching, so operator-curated budgets with mixed-case scope paths are findable. Writes were already lowercase; this was a read-side defensive fix. Closes cycles-openclaw-budget-guard#70, cycles-server-admin#54.

0.1.25.1 — 2026-04-01

Added

  • Webhook event emission from the runtime server. Reserve/commit/release/ extend and decide now emit events to the shared Redis dispatch queue for cycles-server-events to fan out to configured subscribers. Events include reservation.denied (on DENY decisions) and reservation.commit_overage (when committed actual > estimate).
  • EventEmitterService with async, non-blocking emission on a dedicated daemon thread pool (CompletableFuture.runAsync). The request thread never waits on event writes.
  • TTL retention: event keys expire after 90 days, delivery keys after 14 days. Configurable via EVENT_TTL_DAYS / DELIVERY_TTL_DAYS.

Performance

  • Event save (SET + EXPIRE + 2× ZADD) and subscription lookup (2× SMEMBERS) batched into one Redis pipeline round-trip (was 6 sequential). Near-zero overhead on non-event paths; commit p50 recovered from 13.4ms to 5.6ms after the fix below.

Fixed

  • Commit overage event was firing on every commit, not just true overages. Now emits only when actual > estimateAmount.

Archive

v0.1.x and earlier versions predating this changelog: see AUDIT.md.