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.
- 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 taggedreason="OK". - Mutation-only observability remains exactly-once: replaying an event that
originally incurred debt does not increment
cycles_overdraft_incurred_totalagain.
- 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.
- 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.
- 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.
- 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.
- 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}andcycles_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
CyclesMaintenanceFailuresmetric alert.
- 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.
- 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.
- 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.
- The default sorted reservation query is population-independent.
created_at_mssorted 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_idascending 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 inBENCHMARKS.md.
- 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_REQUESTon 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 boundedINDEX,SCAN_DISABLED,SCAN_NOT_READY,SCAN_DRIFT, andSCAN_ERRORoutcomes without a tenant tag.
- 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-pin0.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.
- Sorted reservation listing has population baselines. The benchmark suite
now measures the default
created_at_ms descsorted-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.
- 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.
- 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
:responsestring. 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.
- 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_AVAILABLEand the zero-overdraftALLOW_WITH_OVERDRAFTfallback. The protocol behavior is unchanged: only scopes whose pre-mutation balance could not cover the requested amount are marked over-limit.
- Pre-0.1.25.52 event rows that only have the legacy seven-day
:responsekey 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.
- Reserve idempotency is endpoint-scoped. Live and
dry_runrequests toPOST /v1/reservationsnow share one(tenant, endpoint, idempotency_key)namespace. Reusing a key while changingdry_runreturns409 IDEMPOTENCY_MISMATCHinstead 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 legacydry_runnamespace 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=truestate onto healthy descendants. This applies toALLOW_IF_AVAILABLEand the zero-overdraft fallback ofALLOW_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_atplus a boundedquarantine_reason, and incrementscycles.reservations.quarantinedwith tenant/reason tags.
- 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.
- 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^53and 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 atLong.MAX_VALUEwhen 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.
AuditRepositorynow 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.
- Reservation detail, both reservation list paths, and the expiry event path
now use explicit
HMGETprojections. 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 sharedHashProjectionshelper (name-keyed reads, no positional indexing). Lua also omits snapshot JSON when invoked without an idempotency key.
- 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.50image.
- Event/delivery index retention sweep. A configurable daily sweep removes
stale
events:*anddeliveries:*ZSET pointers after their backing rows expire (EVENT_RETENTION_SWEEP_CRON, default0 30 3 * * *). Non-ZSET correlation keys are skipped without aborting either sweep pass.
- 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 containsEXPIRED. 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.luain 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
PENDING→BASEorEVIDENCEcompare-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.propertiesformat tojunit-platform.properties; nightly/manual try counts use the supportedjqwik.tries.defaultparameter.
- 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.
- No public request/response schema changes. Operators may override the new retention sweep cron; existing retention TTL settings remain authoritative.
TENANT_CLOSEDguard extended toPOST /v1/events./v1/eventsis a persisting budget debit, so a debit whose owning tenant isCLOSEDis now rejected with HTTP 409error=TENANT_CLOSED, mirroring the reservation guards shipped in 0.1.25.47. The check runs insideevent.lua(same fail-closed whitelist as the reservation scripts:CLOSED→ 409;ACTIVE/SUSPENDED→ proceed; a present-but-malformedtenant:<id>record → 500INTERNAL_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, withTENANT_CLOSEDtaking precedence over the per-scopeBUDGET_*outcomes. Runtime spec revision: v0.1.25.14 (pending) adds/v1/eventsto the closed-tenant binding.- Exposure is narrow:
/v1/eventshas 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/eventsis outside the evidence endpoint surface (an accounting endpoint, not a decision endpoint), so none of its denials stampcycles_evidence—TENANT_CLOSEDincluded, consistent with the endpoint.
- Exposure is narrow:
- 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 (oneGET tenant:<id>insideevent.lua, mirroring the reservation scripts).Enums.ErrorCode.TENANT_CLOSEDwas already added in 0.1.25.47.
- Governance Rule 2 terminal-owner guard on reservation mutations
(
TENANT_CLOSED). Once the owning tenant'sstatus=CLOSEDflip 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 anderror=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 existingBUDGET_FROZEN/BUDGET_CLOSEDguards), 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 addsTENANT_CLOSEDto 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
releaseon a closed tenant previously succeeded; it now returns 409TENANT_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 closedACTIVE|SUSPENDED|CLOSEDTenantStatus enum — e.g."CLOZED"or lowercase"closed") fails closed: 500INTERNAL_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_CLOSEDeven when the reservation is already finalized/expired (takes precedence overRESERVATION_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 anerrorCyclesEvidence envelope and stampscycles_evidenceon the response, like the other budget/lifecycle denial codes (it is the owner-level sibling ofBUDGET_CLOSED; declared in the evidence ErrorResponseMirror, cycles-evidence-v0.2.yaml 0.2.1)./v1/decideanddry_runcreate never 409 for a closed tenant — they return 200decision=DENYwithreason_code=TENANT_CLOSEDand emit their normaldecide/reservedecision 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_runorPOST /v1/decideevaluation on a CLOSED tenant returns 200decision=DENYwithreason_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.
- 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
-
Webhook
scope_filtermatching 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-prefixstartsWith) 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 scopetenant:acme-corp/no longer matches (the spec says "all scopes under acme-corp"). Unchanged: the bare base scopetenant:acme-corpnever 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. - 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
Enums.ErrorCodegainsTENANT_CLOSED(additive; mirrors the governance code of the same name — runtime spec revision v0.1.25.13, runcycles/cycles-protocol#125), andEnums.ReasonCodegainsTENANT_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 RedisGETinside the script per mutation); no key layouts change.commit.lua/extend.luaread the additionaltenantfield from the reservation hash (written byreserve.luasince 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.
- 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 get429witherror=LIMIT_EXCEEDED(new ErrorCode, spec v0.1.25.12),Retry-After,X-RateLimit-Reset, andX-RateLimit-Remaining: 0— plus the standard correlation headers. Authenticated/v1endpoints are not covered (abuse there is key-attributable). Configure withCYCLES_PUBLIC_RATE_LIMIT_ENABLED(defaulttrue) andCYCLES_PUBLIC_RATE_LIMIT_REQUESTS_PER_MINUTE(default300).
- 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.ErrorCodegainsLIMIT_EXCEEDED(additive; mirrors the governance code).
- Protected operational endpoints with the configured admin key:
/actuator/prometheus,/actuator/info, aggregate/actuator/health, API docs, and Swagger now requireX-Admin-API-Key. Liveness/readiness probes and the protocol-public CyclesEvidence/JWKS endpoints remain unauthenticated. - Auth-filter error responses now set both
X-Request-IdandX-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_CAPACITYandCYCLES_EVENTS_EMIT_THREADS. - Healthchecks now probe
127.0.0.1readiness endpoints instead oflocalhostor 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
9980readiness endpoint, matching the production full-stack topology. - Removed the accidentally tracked root
dump.rdbRedis snapshot and ignored future*.rdbfiles. - Production Compose examples now point at the
cycles-server:0.1.25.45image tag.
- No protocol wire, Redis data-model, Lua, event schema, or evidence schema
change. Operators scraping Prometheus directly must add
X-Admin-API-Keyto 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.
- 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/prometheusis 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_ORIGINon 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.47andcycles-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 port7980. - Full-stack production Compose now requires
WEBHOOK_SECRET_ENCRYPTION_KEYfor the admin/events deployment path and setsWEBHOOK_SECRET_ENCRYPTION_REQUIRED=truefor admin. - Production Compose examples now point at the
cycles-server:0.1.25.44image 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.
- 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
tenantlabel unless explicitly re-enabled. Full-stack production deployments now fail fast if the webhook secret encryption key is missing.
/actuator/health/readinessnow includes a RedisPINGhealth 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/livenessremains process-only.RequestIdFilternow placesrequestIdin MDC for the lifetime of each request, matching the operations runbook and making structured logs consistently joinable by bothrequestIdandtraceId.- 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_unavailablewarning 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.43image tag.
- 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.
- Release benchmark gating now compares the candidate median against the
rolling median from
benchmark-data/benchmarks/history.jsonlwhen history is available, while keeping the strict 25% threshold. This avoids blocking a release on one unusually fast prior release sample;baseline.jsonremains updated after successful releases for reference/bootstrap. - Guarded tenant-authorization DEBUG logging so request-time sanitizer work is skipped when DEBUG is off.
- CI/workflow and benchmark script change only. No HTTP request/response, Redis, Lua, event, evidence, or spec change.
- 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
LogSanitizerutility, 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, notINFO. This removes default production success-path request log volume and keeps sanitize/attribute lookups behindisDebugEnabled; exception and side-effect-failure logs remain atINFO/WARN. EnableDEBUGfor 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; see0.1.25.42.
- No HTTP request/response, Redis, Lua, event, evidence, or spec change. Default log volume drops (per-request request logs move from INFO to DEBUG).
- Replaced the class-only
Landed in cycles exception handlerlog with structured protocol-exception logs carrying method, path, matched route, status, error code,request_id,trace_id, andreservation_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
WARNwithout 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.
mvn -B -pl cycles-protocol-service-api -am "-Dtest=GlobalExceptionHandlerTest,ApiKeyAuthenticationFilterTest,AdminApiKeyAuthenticationFilterTest" "-Dsurefire.failIfNoSpecifiedTests=false" testmvn -B -pl cycles-protocol-service-api -am "-Dtest=ReservationControllerTest,DecisionControllerTest,EventControllerTest,BalanceControllerTest,EvidenceControllerTest" "-Dsurefire.failIfNoSpecifiedTests=false" "-Dcontract.validation.enabled=false" testmvn -B -pl cycles-protocol-service-data -am "-Dtest=ApiKeyRepositoryTest,ApiKeyValidationServiceTest,AuditRepositoryTest,EventEmitterRepositoryTest,EventEmitterServiceTest,ReservationExpiryServiceTest,EvidenceEmitterTest" "-Dsurefire.failIfNoSpecifiedTests=false" test
- Legacy SCAN pagination for
GET /v1/reservationsandGET /v1/balancesnow 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/reservationsno 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_idand setX-Cycles-Trace-Idwhen the trace filter has not already done so. POST /v1/eventsidempotent replay now returns the stored original response payload and skips duplicate event metrics/balance event emission.- Invalid tenant
default_commit_overage_policyvalues now fail closed withINVALID_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 pincycles-serverto0.1.25.39.
- Clarified idempotency status codes: header/body key mismatch is
400 INVALID_REQUEST; same-key/different-payload replay is409 IDEMPOTENCY_MISMATCH.
- Unconfigured CyclesEvidence no longer queues source records. When either
EVIDENCE_SERVER_IDorEVIDENCE_SIGNING_SIGNER_DIDis blank,EvidenceEmitter.emit(...)now returnsnullbefore building a source record or pushing toevidence:pending. - Configured deployments are unchanged: the emitter still null-strips the
evidence payload, computes
evidence_idsynchronously, stamps it onto the queued record, and returnscycles_evidencefor the response.
- Updated the evidence configuration comments to state that missing public identity disables evidence emission completely.
mvn -B -pl cycles-protocol-service-data -am -Dtest=EvidenceEmitterTest -Dsurefire.failIfNoSpecifiedTests=false testpasses.
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.
- Four new query parameters on
listReservationsmirroring the v0.1.25.20from/toshape. All ISO 8601format: date-time, all optional, all inclusive bounds:expires_from/expires_to— bound onexpires_at_ms(required field; applies to every row regardless of status).finalized_from/finalized_to— bound onfinalized_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_msonReservationSummary. Pre-revision the field was only onReservationDetail, which meant clients filtering withfinalized_*couldn't see the timestamp they were filtering on without a per-rowgetReservationcall. The summary now carries the field with the same population semantics. Strict-schema clients remain compatible because the field is optional (@JsonInclude(NON_NULL)).
- Each pair rejects
expires_from > expires_toandfinalized_from > finalized_towith 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.
RedisReservationRepository.listReservations(...)signature gains trailingLong expiresFromMs, Long expiresToMs, Long finalizedFromMs, Long finalizedToMs(14 → 18 args). PrivatelistReservationsSorted(...)mirrors. Two new predicate helpers:expiresAtInWindow(fields, fromMs, toMs)andfinalizedAtInWindow(fields, fromMs, toMs), applied in both legacy SCAN-cursor and sorted paths after the existing scope/status/tenant predicates.finalizedAtInWindowresolves the timestamp fromcommitted_atORreleased_at(whichever is set), matchingbuildReservationSummary's projection logic. Both fields absent → row excluded per the normative ACTIVE/EXPIRED exclusion.FilterHasher.hash(...)gains four trailingLongarguments (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 byFilterHasherTest.preservesV01_25_20HashWhenOnlyFromTo(goldenad7204d521cfd133).ReservationSummary.finalizedAtMsprojection added totoSummary(...)builder.
- 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 underExpiresAndFinalizedWindowFilternested class.ReservationControllerTest: +10 new underListReservationsnested class (4 malformed-*, 2 reversed-window, expires propagation, finalized propagation, all-three combined, blank-as-unset for new windows).
- JaCoCo 95% bundle gate met.
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.
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.
fromandtoquery parameters onlistReservations— bothstringformat: date-time(ISO 8601), both optional, both inclusive bounds on the reservation'screated_at_ms. The filter is fixed tocreated_at_msregardless ofsort_by, sosort_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 foldsfromMsandtoMsinto the canonical hash that's embedded in the sorted-path cursor (the opaque cursor returned whensort_byorsort_diris 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.12sort_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 withfrom/tobut nosort_bymust keep their window stable across pages, matching how the legacy path already treats every other filter.
- Malformed
fromorto(anything thatInstant.parserejects) → HTTP 400 INVALID_REQUEST with messageInvalid from: …orInvalid to: …. from > to→ HTTP 400 INVALID_REQUEST before any repository call, with messagefrom 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_athash field is missing or unparseable are excluded when either bound is supplied. Malformed storage rows cannot leak past a time filter.
RedisReservationRepository.listReservations(...)signature gains trailingLong fromMs, Long toMs(14 args total). Same shape on the privatelistReservationsSorted(...)helper. Internal Java signature change only — wire format is purely additive, all v0.1.25.x clients that don't sendfrom/tocontinue to work byte-for-byte.
- 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/unparseablecreated_atdefensive exclusion.ReservationControllerTest: 7 new cases covering malformed-from, malformed-to, reversed-window, from-only, to-only, equal-bounds, combination withsort_by=expires_at_ms, blank-string handling.
Supply-chain CVE patch. No code, API, or Lua-script changes — pom-only.
- Re-pin
tomcat.version=10.1.55incycles-protocol-service/pom.xmlto close seven CVEs flagged by Trivy againstorg.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.
commons-lang3.version=3.18.0override stays (CVE-2025-48924 still unfixed in Spring Boot 3.5.14's BOM-managed 3.17.0).
- No production-code or test changes. All 537 protocol-service tests pass (374 data + 163 api). Wire format unchanged from v0.1.25.18.
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.
- Spring Boot 3.5.13 → 3.5.14. Patch upgrade picking up upstream
security hardening (constant-time comparison for remote DevTools
secret,
RandomValuePropertySourceswitched toSecureRandom, hostname verification applied consistently for Cassandra/RabbitMQ SSL) plus symlink-handling fixes inApplicationPidFileWriter/ApplicationTemp. Mirrors the events-server bump shipped incycles-server-eventsv0.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 againstspring-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-eventsandcycles-server-adminon 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.
<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.
- Pin
commons-lang3.version=3.18.0incycles-protocol-service/pom.xmlto close CVE-2025-48924 (Trivy HIGH) oncommons-lang3-3.17.0, which ships transitively in the fat-jar image viaswagger-core-jakarta(OpenAPI UI). Spring Boot 3.5.13's BOM managescommons-lang3at3.17.0; the override is removable once Spring Boot ships a managed version of3.18.0+.
- No code, API, or Lua-script changes. All 152 tests pass. Wire format
unchanged from
v0.1.25.15.
Pom-version-only bump — no standalone GitHub release was cut for this
version. Its changes ship cumulatively in v0.1.25.17.
- Bump
spring-boot-starter-parent3.5.11 → 3.5.13and pintomcat.version=10.1.54to close five HIGH/CRITICAL CVEs surfaced by the new PR-time Trivy container scan:- CVE-2026-22732 (CRITICAL) —
spring-security-web; fixed in6.5.9, pulled in transitively by Spring Boot 3.5.13. - CVE-2026-29129 (HIGH) and CVE-2026-29145 (CRITICAL) —
tomcat-embed-core; fixed in10.1.53, transitive via Spring Boot 3.5.13. - CVE-2026-34483 (HIGH) and CVE-2026-34487 (HIGH) —
tomcat-embed-core; fixed in10.1.54. The explicittomcat.versionproperty override is needed until Spring Boot 3.5.14 (with 10.1.54+ as its managed version) ships.
- CVE-2026-22732 (CRITICAL) —
- No code or API changes. All 152 tests pass. Wire format unchanged
from
v0.1.25.15.
- Runtime-written audit-log entries now respect a configurable retention
TTL (default 400 days). Previously,
AuditRepository.log()wroteaudit:log:{id}keys with noEXPIRE, 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 oncycles-server-admin'sAuditRepository(admin'saudit.retention.authenticated.days=400). Runtime never writes the admin-plane__admin__/__unauth__sentinels, so a single tier is sufficient.
audit.retention.daysconfig (default400, envAUDIT_RETENTION_DAYS). Set to0for indefinite retention (legal hold, HIPAA-adjacent deployments, or environments that offload audit to an archive store).audit.sweep.cronconfig (default0 0 3 * * *, envAUDIT_SWEEP_CRON). Daily@Scheduledsweep prunes staleaudit:logs:{tenantId}andaudit:logs:_allZSET pointers whose targetaudit: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 (idempotentZREMRANGEBYSCORE).
AuditRepository.LOG_AUDIT_LUAnow reads ARGV[4] as an optional TTL in seconds (0or negative = noEX). Same shape as admin's script, minus the sentinel branching.
- W3C Trace Context correlation per
cycles-protocol-v0.yamlrevision 2026-04-18. Every response now carries anX-Cycles-Trace-Idheader. The server accepts atraceparent(W3C version 00) orX-Cycles-Trace-Idheader 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_idfield onErrorResponse,Event,WebhookDelivery, andAuditLogEntrybodies. Optional for wire back-compat; conformant servers populate it on every payload causally downstream of the request.trace_flags(^[0-9a-f]{2}$) andtraceparent_inbound_valid(boolean) onWebhookDeliveryper governance-admin spec v0.1.25.28. These preserve the upstream W3C sampling decision so the events sidecar can reconstruct an outboundtraceparentwith the correct trace-flags byte instead of defaulting to01.- SLF4J MDC now carries
traceIdalongsiderequestIdfor every request — log aggregators can group by trace_id to see all lines produced during a single logical operation. ReservationExpiryServicemints a fresh trace_id per sweep batch soreservation.expiredevents emitted in the same sweep correlate to each other.
- New
TraceContextFilter(@Order(0)) runs beforeRequestIdFilterand sets thecyclesTraceIdrequest attribute for downstream code. EventEmitterService.emit(...)gains a finalString traceIdparameter. The full-arityemitBalanceEvents(...)signature likewise. Three prior overloads kept as delegating wrappers (traceId = null) for source compatibility with existing tests.BaseControllerexposes protectedresolveRequestIdandresolveTraceIdhelpers that controllers use to thread the ids into event-emission and audit-log calls.
GET /v1/reservationssorted path no longer hydrates an unbounded reservation population before the in-memory sort. ASORTED_HYDRATE_CAP = 2000guard mirrors the admin-plane pattern shipped incycles-server-adminv0.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.
Enums.ReservationSortByandEnums.SortDirectionnow carry@JsonValue getWire()+@JsonCreator fromWire(String)Jackson annotations matching the admin plane'sSortSpec/SortDirectionpattern. Wire form stays lowercase, parsing stays case-insensitive withnull → null. Controller-level validation is unchanged: unknown tokens still surface as HTTP 400INVALID_REQUESTwith the documented allow-list payload.RedisReservationRepository.SORTED_HYDRATE_CAPis package-private (test-visible) to allow cap-hit tests to assert the bound deterministically.
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.
GET /v1/reservationsnow acceptssort_byandsort_dirquery parameters (cycles-protocol spec revision 2026-04-16). Validsort_byvalues:reservation_id,tenant,scope_path,status,reserved,created_at_ms,expires_at_ms. Validsort_dirvalues:asc,desc(defaultdescwhensort_byis provided). Invalid enum values return HTTP 400INVALID_REQUEST.- Server-side ordering with deterministic
reservation_id ASCtiebreaker 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.
Backward compatible. Omitting sort_by/sort_dir preserves the
existing Redis-SCAN cursor semantics; legacy all-digit cursors
continue to work unchanged.
- New
support.SortedListCursor,support.FilterHasher,support.ReservationComparatorsutilities. RedisReservationRepository.listReservationssignature extended with trailingsortBy,sortDirparameters (10 → 12 args). Direct callers inside the service have been updated; external callers (if any) must add two trailingnulls.
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
- 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).
- (N-1) ×
- Concurrent-accuracy test for the custom
cycles.reservations.reservecounter under 8-thread × 10-request load. Counter count must match client-observed successes with zero lost increments.
Unchanged. Test-only release. No production-code changes.
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
- Seven domain-level Prometheus counters under the
cycles_*namespace. SeeOPERATIONS.mdfor the full list, tag semantics, and alerting recipes. Short summary:cycles_reservations_reserve_totalcycles_reservations_commit_totalcycles_reservations_release_totalcycles_reservations_extend_totalcycles_reservations_expired_totalcycles_events_totalcycles_overdraft_incurred_total
- Configuration flag
cycles.metrics.tenant-tag.enabled(defaulttrue) — set tofalsein 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.
ReservationExpiryServicewas 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 ofreservation:res_<id>). Thereservation.expiredwebhook now actually fires. If your downstream webhook consumer assumed expiries would never emit an event, update it before upgrading.
Unchanged. Upgrading from v0.1.25.9 requires no client changes.
- New counters appear on your next Prometheus scrape. No config change needed to emit them; they are on by default.
- The
reservation.expiredwebhook fix will start delivering events you weren't receiving before. Confirm your webhook endpoint handles them.
0.1.25.9 — 2026-04-14
- Seven new test classes landing the second-wave coverage plan
(overdraft property tests,
expire.luadirect conformance, admin-release vs agent-commit race, multi-scope attribution under contention, idempotency-cache expiry, clock-skew resilience, audit-log completeness). SeeAUDIT.mdfor the full strategy.
Unchanged. Test-only release.
0.1.25.8 — 2026-04-13
- Admin-on-behalf-of release (spec revision 2026-04-13). Dual-auth
endpoint:
POST /v1/reservations/{id}/releasenow accepts either a tenantX-Cycles-API-Keyor a server-configuredX-Admin-API-KeyviaAdminApiKeyAuthenticationFilter. Admin-driven releases write an audit entry withmetadata.actor_type=admin_on_behalf_ofto the sharedaudit:log:*Redis store, surfacing in the governance dashboard. admin.api-keyapplication property for configuring the admin key.
- 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
reasonfield.
0.1.25.7 — 2026-04-11
Enums.ReasonCodeas a typed enum (previously stringly-typed on the wire). Drop-in Jackson round-trip-compatible with existing clients.
- Flaky
EventEmitterServiceTeston CI. ReplacedThread.sleep(200)+verify()racing withMockito.timeout()/after().
0.1.25.6 — 2026-04-10
- Reserve/event/decide now distinguish
UNIT_MISMATCHfromBUDGET_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 emitUNIT_MISMATCHwith the set of configured units.
0.1.25.5 — 2026-04-08
- Duplicate emission of budget-state transition events
(
budget.approaching_limit,budget.at_limit,budget.over_limit,debt.incurred) on multi-scope operations. Closescycles-server-events#15.
0.1.25.4 — 2026-04-07
- 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
- 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 bycycles-server-eventsfor webhook fan-out. PROTOCOL_VERSIONconstant correctly set tov0.1.25.
0.1.25.2 — 2026-04-02
getBalancesandlistReservationsnow 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. Closescycles-openclaw-budget-guard#70,cycles-server-admin#54.
0.1.25.1 — 2026-04-01
- 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-eventsto fan out to configured subscribers. Events includereservation.denied(on DENY decisions) andreservation.commit_overage(when committedactual > estimate). EventEmitterServicewith 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.
- 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.
- Commit overage event was firing on every commit, not just true
overages. Now emits only when
actual > estimateAmount.
v0.1.x and earlier versions predating this changelog: see AUDIT.md.