From c6a7d9a61e563b1be78f1911a9d310b6f7493497 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Thu, 18 Jun 2026 07:48:11 -0400 Subject: [PATCH 1/6] feat(evidence): publish retired-key rotation history in the JWK Set (v0.1.25.33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend getEvidenceJwks (GET /v1/.well-known/cycles-jwks.json) to publish operator-configured RETIRED signing keys alongside the active key, so CyclesEvidence signed before a key rotation still verifies against the key whose [cycles_nbf_ms, cycles_exp_ms) window covers its issued_at_ms. - JwksController: new @Value cycles.evidence.signing.retired-keys (env EVIDENCE_SIGNING_RETIRED_KEYS), a JSON array parsed via a local ObjectMapper (no injected bean, preserving @WebMvcTest loadability); fail-safe to empty on malformed/non-array config — the active key always still publishes. - JwksDocuments.jwkSet: 4-arg overload (3-arg delegates with List.of()) that appends each retired key as a bounded-window JWK (status:retired, cycles_exp_ms EXCLUSIVE/required). - Defensive skips (codex review, 3 Medium): malformed hex; null exp_ms; empty/inverted window (exp <= nbf); missing/non-integral nbf_ms (not coerced to epoch 0); same key material as the active key (ambiguous window); duplicate kid (set-wide uniqueness). No spec change (CyclesEvidenceJwks already supports multi-key + windows + status:retired) and no wire change to existing endpoints. Scope: publication of configured rotation history only — Redis-backed auto-rotation and did:cycles-form producer stamping remain follow-ups. Tests: JwksDocumentsTest +7, JwksControllerTest +4; both classes 100% line- covered. Full mvn verify green; jacoco 95% gate met. AUDIT.md updated. --- AUDIT.md | 2 +- .../api/controller/JwksController.java | 57 +++++++- .../protocol/api/evidence/JwksDocuments.java | 122 ++++++++++++++---- .../src/main/resources/application.properties | 7 + .../api/controller/JwksControllerTest.java | 53 +++++++- .../api/evidence/JwksDocumentsTest.java | 77 ++++++++++- cycles-protocol-service/pom.xml | 2 +- 7 files changed, 284 insertions(+), 36 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index 67f24ed..e638f2a 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -1,6 +1,6 @@ # Cycles Protocol v0.1.25 — Server Implementation Audit -**Date:** 2026-06-15 (getEvidenceJwks live-serving integration test — `JwksEndpointIntegrationTest` (extends `BaseIntegrationTest`: full `@SpringBootTest` RANDOM_PORT, real Tomcat + the Spring Security filter chain ACTIVE, Testcontainers Redis) proves the JWK Set endpoint serves over real HTTP WITHOUT an API key — i.e. the `/v1/.well-known/**` public-path exemption actually holds end-to-end through the filter chain, not just as an array entry (the `JwksControllerTest` `@WebMvcTest` runs with filters disabled, so it can't show this). With the evidence signing identity configured via `@TestPropertySource`, GET `/v1/.well-known/cycles-jwks.json` with no header → 200 + a JWK whose `x` decodes to exactly the configured `signer_did` bytes, correct `kid`/`cycles_nbf_ms`/`status`, and `Cache-Control: public, max-age` (NOT immutable); a bogus API key still yields 200 (public, never 401). The base class's contract-validating interceptor additionally checks the body against the published `CyclesEvidenceJwks` schema (cycles-protocol@main, #113). 2 tests; test-only (no production/wire/spec change; the impl shipped in v0.1.25.32 / #194).), 2026-06-15 (v0.1.25.32 — CyclesEvidence signer-key resolution, publication half: implement `getEvidenceJwks` (`GET /v1/.well-known/cycles-jwks.json`, per `cycles-protocol-v0.yaml` v0.1.25.6 / runcycles/cycles-protocol#113, the v0.2 additive layer designed on #103 / aeoess#43). `JwksController` (public; reads the shared `cycles.evidence.signing.signer-did` + new `cycles.evidence.signing.kid` / `nbf-ms` via `@Value`, holds no injected bean so it loads in every `@WebMvcTest` without extra wiring) serves the JWK Set built by the pure `JwksDocuments.jwkSet(signerDid, kid, nbfMs)`: when `signer-did` is a raw 64-hex key it emits one active Ed25519 OKP JWK — `{kty:OKP, crv:Ed25519, alg:EdDSA, x:base64url(hex-decode(signer_did)), kid (default = first 16 hex of the key), cycles_nbf_ms (default 0), status:active}`, `cycles_exp_ms` omitted ⇒ open-ended; the JWK `x` is the SAME 32 raw bytes `EnvelopeSigner` signs with, so a verifier resolving this set authenticates the emitted signatures. Served with `Cache-Control: public, max-age=300` (NOT immutable — a key set rotates, unlike a content-addressed envelope). 404s via the standard NOT_FOUND `ErrorResponse` when no raw-hex key is configured (evidence off, or a `did:cycles` `signer_did` which carries no key bytes — that, plus retired-key rotation history, is the v0.2-store follow-up); consumers then stay on the raw-hex + `expected_signer` pinning (`binding_only`) path. PUBLIC: `/v1/.well-known/**` added to `SecurityConfig.PUBLIC_PATHS` — public keys only, the private key is never served, and the set is itself the trust anchor. API-base-relative (under `/v1`), per the spec's authority-scope rule (the `did:cycles` hash covers `server_id` WITH its path). Tests: `JwksDocumentsTest` (10 — raw-hex JWK shape, `x` round-trips to the key bytes, default/override kid, nbf carry-through, blank/null/did:cycles/malformed → empty), `JwksControllerTest` (4 — 200 + JWK-set body + short public non-immutable cache via `@WebMvcTest` contract-validated against #113's spec, unconfigured/did:cycles → NOT_FOUND, direct 200 body), `SecurityConfigTest` updated for the new public path. Both new classes 100% line-covered; full `mvn verify` 906 tests green; jacoco 95% gate met. No change to existing endpoints/wire.), 2026-06-14 (v0.1.25.31 — review fix [Medium]: `POST /v1/reservations` re-emitted side-effect events on an idempotent REPLAY. `ReservationController.create` emitted the `RESERVATION_DENIED` deny event and `emitBalanceEvents` (budget-state transitions) unconditionally, while `decide` (`DecisionController`) and `commit`/`release` (`ReservationController`) already skip side-effect emission when `response.isIdempotentReplay()`. A replay (a cached dry-run DENY, or a reserve body-cache replay carrying `idempotentReplay=true`) therefore double-counted those events — the original create already emitted them. FIX: wrap create's whole emission block in `if (!response.isIdempotentReplay())`, matching the other lifecycle endpoints. Test: `ReservationControllerTest` +1 (`shouldNotReemitEventsOnIdempotentReplay` — an idempotent DENY replay re-emits neither `RESERVATION_DENIED` nor balance events). No wire/spec change. NOTE: numbered .31 because v0.1.25.30 is held by the open byte-parity PR #187; merge #187 first.), 2026-06-14 (v0.1.25.30 — byte-parity hardening: extend `EvidenceIdComputerTest`'s golden-fixture coverage from the 3 reserve fixtures to the FULL 13-fixture set (all five artifact types — decide/reserve/commit/release/error), the SAME `cycles-evidence-fixtures` the event-tier `CyclesEvidenceCanonicalizer` verifies against and the APS verifier was generated with (the 10 added fixtures are byte-identical copies from cycles-server-events). This proves cycles-server's SYNCHRONOUS `evidence_id` computation reproduces the canonical id for EVERY artifact SHAPE — `reservation_id` hoisting on commit/release, `endpoint`+`http_status` on error — i.e. the JCS+sha256 recipe is byte-correct across the whole envelope domain, so whenever cycles-server emits a given shape its id matches the worker and the cross-check never dead-letters on drift. (Recipe parity over the canonical/verifier fixture set, NOT the set of shapes cycles-server emits: its emission policy is the budget/lifecycle-denial subset per `GlobalExceptionHandler.EVIDENCE_DENIAL_CODES`, so the error envelope it actually emits is the budget-exceeded kind — `11-reserve-live-budget-exceeded` — while `12-decide-live-forbidden` is a verifier-relevant FORBIDDEN shape cycles-server does NOT emit, included only to prove the computer handles it.) Test-only (no production change): `@ValueSource` 3→13 fixtures; `EvidenceIdComputerTest` 5→15 tests. Full `mvn verify` 431 data + 196 api green; jacoco 95% gate met.), 2026-06-13 (v0.1.25.29 — include the OPTIONAL `request` body in `error` CyclesEvidence (review Minor: cycles-evidence-v0.1 `ErrorPayload.request` SHOULD be present for a full audit trail unless redaction applies; v0.1.25.28 always omitted it). The four core controllers (`create`/`commit`/`release`/`decide`) stash their parsed `@RequestBody` DTO under the `GlobalExceptionHandler.EVIDENCE_REQUEST_ATTRIBUTE` request attribute immediately after binding; `GlobalExceptionHandler` includes it as the `error` payload's `request` field when present (typed DTO → Jackson → JCS-canonical). Present for any post-binding denial on the four core endpoints; absent for pre-binding failures (validation/auth) and non-instrumented routes — a future redaction policy MAY drop it. No wire change to `ErrorResponse` (the `request` lives only inside the evidence payload). The `error` payload is now `{endpoint, http_status, [reservation_id], [request], response}`, completing the draft `ErrorPayload` shape. REVIEW FIX (codex, High — applies to ALL request-bearing artifacts, not just error): the request/response DTOs serialized into evidence payloads can emit null-valued properties (`ReservationCreateRequest.ttl_ms`/`overage_policy`/`metadata`, `CommitRequest.metrics`/`metadata`, `ReleaseRequest.reason` — none carry `@JsonInclude(NON_NULL)`, and the shared `ObjectMapper` does not omit nulls), but the cycles-evidence-v0.1 mirror schemas are `additionalProperties:false` with non-nullable typed fields, so a serialized `null` makes the signed envelope fail mirror validation. This latent defect already affected the merged reserve/commit/release success-path evidence (each `put("request", request)`), not only the new error path. Fix is CENTRALIZED in `EvidenceEmitter`: a private NON_NULL copy of the shared mapper (`evidencePayloadMapper`, lazily derived) null-strips the payload ONCE into a tree used for BOTH the `evidence_id` computation and the queued record, so cycles-server and the event-tier worker agree byte-for-byte. The DTOs and the shared mapper are deliberately NOT changed — the same DTO serialization backs idempotency payload hashes, reserve.lua args, and cached response bodies, so altering it would break those. Byte-parity fixtures unaffected (their requests carry no nulls). Tests: `GlobalExceptionHandlerTest` +2 (stashed-request-included, absent-request-omitted) + `ReservationControllerTest` +1 (end-to-end `@WebMvcTest`: a non-dry reserve denial through the controller stashes the request DTO, emits `error` evidence with `request`+`endpoint`, and surfaces `cycles_evidence` on the 409) + `EvidenceEmitterTest` +2 (null-valued payload properties stripped; `evidence_id` computed over the null-stripped payload). codex round 1 SHIP, round 2 (High) fixed. Full `mvn verify` 421 data + 196 api green; full integration suite 428 api green; jacoco 95% gate met.), 2026-06-13 (v0.1.25.28 — CyclesEvidence fan-out to the `error` artifact, COMPLETING the lifecycle binding loop (decide/reserve/commit/release/error) (per cycles-protocol v0.1.25.5 / runcycles/cycles-protocol#109). `ErrorResponse` gains optional `cycles_evidence` (`CyclesEvidenceRef`). `GlobalExceptionHandler` (the central `CyclesProtocolException` handler) now emits an `error` CyclesEvidence source record over `{endpoint, http_status, response}` (+ `reservation_id` hoisted for the commit/release endpoints, so evidence-only readers can reconstruct the authorization→settlement chain) and stamps the returned ref onto the `ErrorResponse` — but ONLY for budget/lifecycle DENIAL codes raised on the four core endpoints: the budget denials (`BUDGET_EXCEEDED`, `BUDGET_FROZEN`, `BUDGET_CLOSED`, `OVERDRAFT_LIMIT_EXCEEDED`, `DEBT_OUTSTANDING`, `UNIT_MISMATCH`) AND the reservation terminal-state denials on commit/release (`RESERVATION_FINALIZED` 409, `RESERVATION_EXPIRED` 410 — settling an already-finalized/expired reservation; reservation_id is hoisted for these so the authorization→settlement-denial chain is reconstructable) (`POST /v1/decide` | `/v1/reservations` | `.../commit` | `.../release`, matched via `HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE` + method; `reservation_id` from `URI_TEMPLATE_VARIABLES_ATTRIBUTE`). The non-dry reserve 409 `BUDGET_EXCEEDED` is the highest-signal denial APS receipts bind to — the canonical wire shape per §ReservationCreateResponse.decision (NOT a 200 DENY). Pre-evaluation failures (validation/auth/malformed/not-found/idempotency-mismatch) and non-core routes (e.g. extend) emit NOTHING — no decision was made, nothing to attest; matches the spec's `cycles_evidence` "absent for errors raised before evidence could be emitted". The ref is stamped AFTER `evidence_id` is computed over the pre-stamp response (`ErrorResponse` is `@JsonInclude(NON_NULL)`, so the attested `payload.error.response` omits `cycles_evidence` — non-self-referential), and emission is fail-open via `EvidenceEmitter` (a push/identity failure never fails the error response). `GlobalExceptionHandler` switches to constructor injection of `EvidenceEmitter`. REVIEW FIXES (codex): (Low) a commit/release denial whose `reservation_id` path var is somehow absent now SKIPS emission rather than emit a spec-invalid `error` payload missing the required `reservation_id`. (Medium) RESERVATION_FINALIZED/RESERVATION_EXPIRED were initially excluded from the allowlist — they route through `handleScriptError` on commit/release and are genuine settlement-path denials (the spec's `ErrorResponseMirror` enum already includes both, and reservation_id hoisting exists for exactly this chain), so they are now included; the doc states the governing principle (a code belongs iff it is a server DECISION on a core endpoint; pre-evaluation validation/auth/not-found/idempotency-mismatch stay excluded). Tests: `GlobalExceptionHandlerTest` +9 (reserve-denial emit+stamp with payload-shape assertions, commit-denial `reservation_id` hoist, commit-FINALIZED + release-EXPIRED terminal-state emit with reservation_id, unconfigured-emitter-omits-ref, pre-evaluation-no-emit, no-matched-route-no-emit, extend-route-no-emit, commit-denial-without-reservation_id-no-emit); the five controller `@WebMvcTest`s gain `@MockitoBean EvidenceEmitter`. Full `mvn verify` 419 data + 193 api green; full integration suite 426 api green; jacoco 95% gate met. This is the LAST core-artifact fan-out — remaining evidence work is the `evidence.available` webhook + deploy/config of the shared identity env vars.), 2026-06-13 (v0.1.25.27 — CyclesEvidence fan-out to decide + generalized non-persisting idempotency machinery (per cycles-protocol v0.1.25.4 / runcycles/cycles-protocol#108). The `decide` artifact (pre-execution decision, no reservation) now emits CyclesEvidence over `{request, response}` (draft `DecidePayload`) and surfaces `cycles_evidence` on `DecisionResponse`. GENERALIZATION: the dry_run atomic-claim + wait machinery (from #181) is refactored into a shared `kind`-parameterized path serving both dry_run AND decide — `acquireIdempotencyClaim` (SET NX pending-claim), `waitForIdempotentReplayBody` (per-poll wait), `cacheIdempotentBody`, `clearIdempotencyClaim`, `idempotencyCacheKey`/`pendingPrefix`/`pendingMarker`/`isPending`/`validatePendingMarker`/`pendingPayloadHash`, and the `IdemClaim` record. Keys/markers are derived from `kind` so dry_run stays byte-identical (`idem::dry_run:`, `__dry_run_pending__:`) — its behavior + tests unchanged; decide uses `idem::decide:` + `__decide_pending__:`. decide() is restructured into an orchestrator (acquire → replay/pending/fresh; connections released before emit + wait, no pool nesting) + an extracted pure `evaluateDecisionBudget`; concurrent same-key decides now converge to ONE evaluation + ONE envelope (closes the duplicate-emit race, same bar as dry_run). `DecisionController` threads `trace_id`. `DecisionResponse` gains optional `cycles_evidence` + transient `idempotentReplay`. REVIEW FIXES (codex): (a) the decide orchestrator's outer catch now rethrows `CyclesProtocolException` and `RuntimeException` UNCHANGED and only wraps a remaining checked `Exception` — runtime failures propagate unwrapped (no double-wrap). (b) `cacheIdempotentBody` / `clearIdempotencyClaim` no longer require a caller-held connection — each self-acquires its own short-lived pooled `Jedis` and fails open on a Redis error (the dead `clearDecideClaimQuietly` helper removed); callers in both the dry_run and decide paths updated. (c) `DecisionController` guards the `RESERVATION_DENIED` event emission with `!response.isIdempotentReplay()` so a decide replay does not re-emit the original deny event. (d) POOL-NESTING on the DRY_RUN FAILURE PATH — `evaluateDryRun`'s catch previously cleared the pending claim via the self-acquiring `clearIdempotencyClaim` while the caller's evaluation `Jedis` was STILL checked out (peak 2 connections/req under concurrent failing dry-runs — the starvation pattern this work avoids elsewhere). Added a `clearIdempotencyClaim(Jedis, idemKey, marker)` overload that compare-and-deletes on the ALREADY-HELD connection; `evaluateDryRun`'s failure catch now uses it (a failing dry-run checks out exactly ONE pooled connection); the self-acquire overload delegates to it. decide was already correct (clears only after its eval connection closes) and is unchanged. Tests: `RedisReservationDecideEventTest` +4 (fresh decide stamp+cache, decide replay-verbatim-no-reemit, fresh-decide-releases-connection-before-emit `InOrder` guard, fresh-decide-clears-claim-on-eval-failure; the runtime-exception test asserts the original instance propagates unwrapped), `RedisReservationCrudTest` +2 (dry-run clear-eval-also-throws → original error still propagates; dry-run post-eval cache/clear pool-unavailable → still returns the decision) plus strengthened the existing dry-run eval-fail test with a `times(1).getResource()` no-nesting guard, `DecisionControllerTest` +2 (surface cycles_evidence; idempotent-replay DENY does NOT re-emit RESERVATION_DENIED); dry_run/commit/release/reserve tests unchanged + green. Full `mvn verify` 419 data + 184 api green; full integration suite 417 api green; jacoco 95% gate met. Forbidden/validation failures on /v1/decide remain `error`-artifact territory (next slice).), 2026-06-13 (v0.1.25.26 — CyclesEvidence fan-out to commit + release (per cycles-protocol v0.1.25.3 / runcycles/cycles-protocol#107). Extends the reserve evidence pattern to the rest of the budget lifecycle. `commit.lua`/`release.lua` now flag their idempotent-replay branch (`replay = true`) so Java distinguishes fresh from replay. `commitReservation`/`releaseReservation` gain a `traceId` overload (4-/5-arg; 3-/4-arg back-compat retained) and, on a FRESH terminal op, emit a `commit`/`release` CyclesEvidence source record over `{reservation_id, request, response}` (the draft `CommitPayload`/`ReleasePayload` shape; response AS-IS, before `cycles_evidence` is stamped so the attested body is non-self-referential), stamp the ref onto the response, and cache the full body at `commit:body:` / `release:body:` with a 30-DAY TTL matching the terminal reservation-hash TTL (`PEXPIRE 2592000000`). A REPLAY returns the cached body VERBATIM (per-poll connection wait, mirroring the #181 reserve hardening; the main connection is released before waiting), falling back to the Lua-rebuilt response (no evidence) on cache miss. `CommitResponse`/`ReleaseResponse` gain optional `cycles_evidence` + transient `idempotentReplay`. `ReservationController` threads `trace_id` and SKIPS commit event emission on a replay (the original already emitted; the cached body carries no internal event fields). Generic helpers `emitLifecycleEvidence` / `cacheLifecycleBody` / `readCachedLifecycleBody` shared across commit+release. Tests: `RedisReservationCommitReleaseTest` +4 (fresh commit/release stamp+cache, commit/release replay-verbatim-no-reemit), `ReservationControllerTest` +2 (commit surfaces cycles_evidence, commit replay skips overage event). REVIEW FIXES (#183): (a) POOL-NESTING — the fresh reserve/commit/release paths previously held the Lua connection while `emitLifecycleEvidence`→`EvidenceQueueRepository.push` checked out a SECOND pool connection (peak 2/req → pool starvation under load). All three now RELEASE the Lua connection before evidence emit + body-cache, and `cacheReserveResponse`/`cacheLifecycleBody` acquire their own short-lived connection (peak 1 at a time); the reserve path was affected identically and is fixed too. (b) AUDIT-REPLAY — the admin-release audit block is now guarded by `!response.isIdempotentReplay()`, so an admin retry of an already-released reservation no longer writes a second `audit:log` entry. Regression guards: `InOrder` tests assert the Lua connection is `close()`d BEFORE `evidenceEmitter.emit(...)` on all three fresh paths (reserve/commit/release), plus `adminReleaseReplayDoesNotReAudit`. Full `mvn verify` 411 data + 179 api green; full integration suite 414 api green; jacoco 95% gate met.), 2026-06-13 (v0.1.25.25 — CyclesEvidence idempotency-race hardening (runcycles/cycles-server#181, on the v0.1.25.24 centralized flow). Closes two concurrency races: (1) RESERVE replay landing in the window between reserve.lua writing its `idem::reserve:` mapping and `createReservation` writing the `reserve:body:` cache previously fell back to rebuilt current balances; the replay now POLLS the body cache (≤4 attempts × 25ms) before the rebuild fallback. (2) Concurrent fresh DRY_RUN with the same key could both evaluate → duplicate evidence emission + non-idempotent responses; now an atomic `SET … NX PX 60000` pending-claim elects one evaluator and losers wait (`waitForDryRunReplay`) for the winner's cached result. WAIT MECHANICS (addressing review): the wait loops acquire a FRESH Jedis per poll (try-with-resources inside the loop) rather than holding the request connection across `Thread.sleep`, and the budget is 4 attempts (~100ms, not 1s); the reserve replay path closes its main connection before waiting. A waiter that still finds no result returns 500 `INTERNAL_ERROR` ("retry with the same idempotency_key") — transient, resolves on client retry. CLEANUP: the pending claim is released via an atomic compare-and-delete Lua (`GET==marker then DEL`), and is cleared if the caller-side cache write fails-open or evaluation throws (`DryRunResult` carries claimKey/marker). Refactor: `createDryRunReservation` / `rebuildReserveReplay` / `DryRunResult.pending(...)` extracted; no wire/spec change. Tests: `RedisReservationCrudTest` +5 (reserve wait-then-replay, dry-run pending-wait-replay, claim-set-on-store, clear-on-cache-fail, clear-on-eval-fail); full `mvn verify` 407 data + 179 api green; full integration suite 412 api green incl. thundering-herd 3/3; jacoco 95% gate met.), 2026-06-12 (v0.1.25.24 — CyclesEvidence centralized into the reservation-creation flow (review: two more High findings on v0.1.25.23). ROOT CAUSE addressed: evidence had lived in `ReservationController` while idempotency/caching/TTL live in `RedisReservationRepository` — that split caused every prior finding. Evidence is now emitted + stamped + cached INSIDE `createReservation` (the idempotent unit); `EvidenceEmitter` is injected into the repository and `trace_id` is threaded via a new `createReservation(request, tenant, traceId)` overload (2-arg back-compat retained). (1) BODY-CACHE TTL now matches reserve.lua's idempotency mapping — `max(ttl_ms + grace_ms, 86400000)` (was a fixed 24h, which expired before the Lua key for reservations whose tenant `max_reservation_ttl_ms` exceeds 24h, dropping replay back to rebuilt current balances). (2) DRY_RUN now EMITS `reserve` evidence for ALL outcomes (ALLOW and the early DENYs) per spec-authority — `drafts/cycles-evidence-v0.1.yaml` carries the `03-reserve-dry-run-deny` golden fixture and states dry-run ALLOW/DENY are captured as `reserve` evidence (a dry-run DENY is the canonical signed "would this be allowed?" attestation; non-dry denials are 409→`error` artifact). Reverses the v0.1.25.23 suppression. Dry-run evidence is stamped before caching the body under `idem::dry_run:` (now covers DENY outcomes too, making them idempotent), replayed verbatim. `ReservationController` no longer references `EvidenceEmitter`/caching — it just returns the response. Tests: repository gains fresh-reserve evidence+TTL (incl. 48h long-lived → 48h+grace, not 24h) + cache-hit verbatim replay; `ReservationControllerTest` simplified to pass-through (cycles_evidence present/absent + 3-arg threading); full `mvn verify` 402 data + 179 api green; full integration suite 412 api green incl. the thundering-herd idempotency test (3/3). jacoco 95% gate met.), 2026-06-12 (v0.1.25.23 — CyclesEvidence idempotent-replay-body fix (review: two High findings on v0.1.25.22). (1) Reserve replays now return the ORIGINAL full payload: a fresh non-dry-run create stamps `cycles_evidence` then caches the WHOLE response via `RedisReservationRepository.cacheReserveResponse` keyed by `reserve:body:` (NOT idempotency_key — so it can't go stale across an idem-key expiry + re-reserve, and never collides with reserve.lua's own `idem::reserve:` mapping; 24h TTL); the Lua-idempotency-hit path reads that key by the reservation_id the Lua returns and replays the body VERBATIM (original balances + original `cycles_evidence`, so the body matches the envelope the `evidence_id` points to), falling back to rebuild-from-hash only when the cache is absent. Replaces the v0.1.25.22 `persistEvidenceRef`/`storedEvidence*` ref-only approach — which left the rebuilt body's balances drifting from the envelope, still violating the NORMATIVE "return the original successful response payload" rule (cycles-protocol-v0.yaml IDEMPOTENCY). Also fixes the pre-existing balance-drift-on-replay bug. (2) dry_run reserves NO LONGER emit/surface evidence at all: a dry_run neither persists a reservation nor changes any budget, so there is nothing to attest or bind a receipt to — and the dry_run idempotency cache (cached pre-evidence) previously caused every replay to re-emit + recompute a fresh `evidence_id`. The controller now suppresses evidence when `dry_run=true` or `idempotentReplay`. Model: `ReservationCreateResponse` drops `storedEvidence*`, keeps transient `idempotentReplay`. Tests: `ReservationControllerTest` reworked (fresh-surface+cache, replay-verbatim-no-reemit, replay-omit-when-absent, dry-run-no-evidence, emitter-null-still-caches), `RedisReservationCrudTest` +3 (cache-hit verbatim replay, cache write, no-cache-without-key). 402 data + 181 api tests, jacoco 95% gate met.), 2026-06-12 (v0.1.25.22 — CyclesEvidence — synchronous `evidence_id` + `cycles_evidence` on the reserve response — WIP on `feat/evidence-id-sync`, closing the APS binding loop. `EvidenceIdComputer` (`@Component`) reproduces the `cycles-evidence/v0.1` content-hash recipe (RFC 8785 JCS via erdtman + sha256 over the envelope with `evidence_id`/`signature` emptied) byte-for-byte — proven against the 3 reserve golden fixtures the event-tier worker and APS verifier use. `EvidenceEmitter.emit` now computes the id SYNCHRONOUSLY when the PUBLIC identity (`cycles.evidence.server-id` + `cycles.evidence.signing.signer-did` — property names + env vars SHARED with the event-tier worker, so one var configures both; review finding: Low) is configured, stamps it on the source record (for the worker's cross-check) and returns `EvidenceRef {evidence_id, cycles_evidence_url}`; unconfigured → null (record still queued, no id). `cycles_evidence_url` = `{server_id}/evidence/{evidence_id}` — `server_id` already carries the `/v1` base, so no `/v1` double-prefix. `ReservationController.create` stamps `cycles_evidence` on the response AFTER the id is computed, so the attested `payload.reserve.response` never carries the ref (content hash stays non-self-referential). New `CyclesEvidenceRef` model; `ReservationCreateResponse` gains optional `cycles_evidence` (`@JsonInclude(NON_NULL)` — additive, non-breaking per the spec EVOLUTION CONTRACT). IDEMPOTENT (review finding: High): a fresh reserve computes + emits ONCE and persists the ref on the reservation hash (`persistEvidenceRef` → HSET `evidence_id`/`cycles_evidence_url`); an idempotent replay returns that stored ref VERBATIM (transient `idempotentReplay`/`storedEvidence*` on the response) and NEVER recomputes (replay balances reflect current state → would drift to a different `evidence_id`) or re-emits — honouring the reserve "return the original successful response" rule. Per cycles-protocol v0.1.25.1 (#105) + v0.1.25.2 (#106). Tests: `EvidenceIdComputer` x5 (golden-fixture byte-parity), `EvidenceEmitter` +5 (configured/unconfigured/url-join), `ReservationControllerTest` +4 (fresh-surface+persist, replay-verbatim-no-reemit, replay-omit-when-unpersisted, omit). Build needs the serving controller (#176) on main for `getEvidence` spec-coverage. Fan-out to decide/commit/release follows.), 2026-06-12 (CyclesEvidence serving endpoint — WIP on `feat/evidence-serving-impl`. Implements `getEvidence` (`GET /v1/evidence/{evidence_id}`, per `cycles-protocol-v0.yaml` revision 2026-06-12 / runcycles/cycles-protocol#104): `EvidenceController` reads the shared store via `EvidenceStoreReader` (`GET evidence:envelope:`, same `cycles.evidence.store.key-prefix` as the event-tier writer) and serves the signed envelope VERBATIM (bytes, `application/json`) with `Cache-Control: public, immutable`; `404` (`CyclesProtocolException.notFound`) when absent; `@Pattern` 64-hex `evidence_id` → `400`. PUBLIC: `/v1/evidence/**` added to `SecurityConfig.PUBLIC_PATHS` (no API key — capability-URL, per the spec). Tests: `EvidenceControllerTest` (standalone, 200 verbatim + immutable-cache, 404), `EvidenceStoreReaderTest` (get-by-key); the four controller `@WebMvcTest`s gain `@MockitoBean EvidenceStoreReader` (they load all controllers via `ContractValidationConfig`). `OpenApiContractDiffTest` passes against the #104 spec (validated locally via `-Dcontract.spec.url`); CI goes green once #104 merges to main. No change to existing endpoints.), 2026-06-12 (CyclesEvidence source emission, reserve endpoint — WIP on `feat/evidence-emit-reserve`, the producer half of the dedicated-channel emitter. `EvidenceQueueRepository` (LPUSH a source record to `evidence:pending`, key `cycles.evidence.queue.pending-key`) and `EvidenceEmitter` (`@Service`; stamps `artifact_type`, `issued_at_ms` (response time, matching the fixtures), `trace_id`, and the caller-supplied payload body — `server_id`/`signer_did` are added by the event-tier worker). DURABLE: the enqueue is SYNCHRONOUS — the source record is LPUSH'd to the same Redis as the just-committed ledger write before the response returns, so a committed op cannot return without its evidence queued (closes the async pre-Redis loss window; removes the unbounded-executor backlog/OOM risk). Fail-open: a push failure is logged + metered (`cycles.evidence.emit_failed` via `CyclesMetrics`) and never fails the already-committed response; expensive signing stays async in the event tier. `emit` takes the payload body because its shape varies by artifact (reserve/decide = `{request,response}`; commit/release add `reservation_id`; error = `{endpoint,http_status,request,response}`). Wired into `ReservationController.create` (covers ALLOW and DENY). Dedicated channel, not the webhook event stream. 4 tests (`EvidenceEmitter` x3 incl. sync-enqueue + fail-open, `EvidenceQueueRepository` LPUSH); `ReservationControllerTest` gains `@MockitoBean EvidenceEmitter`. No wire/spec change; fan-out to decide/commit/release + the error artifact follows.), 2026-05-22 (v0.1.25.21 — `expires_from`/`expires_to` and `finalized_from`/`finalized_to` ISO-8601 time-window filters on `GET /v1/reservations` per `cycles-protocol-v0.yaml` revision 2026-05-22 (runcycles/cycles-protocol#98); closes runcycles/cycles-server#162. Four new query params mirroring the v0.1.25.20 `from`/`to` shape: `expires_*` binds to `expires_at_ms` (required field, every row), `finalized_*` binds to `finalized_at_ms` (populated only on COMMITTED/RELEASED; ACTIVE and EXPIRED normatively excluded). Three windows compose with AND. `finalized_at_ms` added as an optional field on `ReservationSummary` so clients filtering with `finalized_*` can see the timestamp without a follow-up `getReservation` — strict-schema-compatible because the field is `@JsonInclude(NON_NULL)`. `FilterHasher` extends with four more `Long` args (10 → 14) using independent gated emission per pair — preserves byte-exact back-compat for v0.1.25.18 cursors (golden `2f397ea0e8fb53b7`) AND v0.1.25.20 cursors with from/to set (golden `ad7204d521cfd133`). `RedisReservationRepository.listReservations` signature 14 → 18 args. Two new predicate helpers (`expiresAtInWindow`, `finalizedAtInWindow`) applied in both legacy SCAN-cursor and sorted paths. Validation: each new pair `from > to` → 400; malformed values → 400 with distinct per-param message; blank strings treated as unset. 557 tests pass (384 data + 173 api), +19 vs v0.1.25.20.), +**Date:** 2026-06-18 (v0.1.25.33 — CyclesEvidence signer-key resolution: retired-key rotation history in the published JWK Set (the v0.2-store follow-up flagged in v0.1.25.32). `JwksController` gains a fourth `@Value` `cycles.evidence.signing.retired-keys` (env `EVIDENCE_SIGNING_RETIRED_KEYS`) — a JSON array of `{signer_did (raw 64-hex), kid, nbf_ms, exp_ms}` — parsed via a LOCAL `ObjectMapper` (no injected bean, preserving `@WebMvcTest` loadability) into `JwksDocuments.RetiredKey` records; fail-safe to empty on malformed/non-array config (the active key still publishes — never crashes). `JwksDocuments.jwkSet` gains a 4-arg overload (the old 3-arg delegates with `List.of()`) that appends each retired key AFTER the active one as a bounded-window JWK — `status:retired` carrying `cycles_exp_ms` (EXCLUSIVE window end, REQUIRED for a retired key) — so evidence signed before a rotation still verifies against the key whose `[cycles_nbf_ms, cycles_exp_ms)` covers its `issued_at_ms`. Defensive skips (logged, never fatal): malformed hex; null `exp_ms`; an empty/inverted window (`exp_ms <= nbf_ms`, since `cycles_exp_ms` is EXCLUSIVE); a missing/non-integral `nbf_ms` (NOT coerced to epoch 0 — that would silently widen the validity window; the entry is dropped in `parseRetiredKeys`); the SAME key material as the active key (re-publishing the active `x` with a bounded window would make the window covering an `issued_at_ms` ambiguous); or a `kid` colliding with an already-emitted key (set-wide kid uniqueness — never emit a duplicate kid). Rotation procedure: set the new key as `signer-did` active, append the old one here with `exp_ms` = the rotation time. NO spec change (the published `CyclesEvidenceJwks` schema already supports multi-key + windows + `status:retired`) and NO wire change to existing endpoints. Tests: `JwksDocumentsTest` +7 (retired appended w/ bounded window + retired status, null-exp skipped, malformed-hex skipped, duplicate-kid skipped, empty/inverted-window skipped, same-material-as-active skipped, empty/null list = single active key), `JwksControllerTest` +4 (active+retired publication with windows, malformed JSON still publishes the active key, missing-nbf skipped, non-array config ignored); both `JwksDocuments` and `JwksController` 100% line-covered (codex review: 3 Medium window-validation findings applied — required-integral nbf, empty/inverted window guard, active/retired overlap guard). Full `mvn verify` green; jacoco 95% gate met. SCOPE: publication of operator-configured rotation history only — Redis-backed auto-rotation mechanics and `did:cycles`-form `signer_did` producer stamping remain separate follow-ups.), 2026-06-15 (getEvidenceJwks live-serving integration test — `JwksEndpointIntegrationTest` (extends `BaseIntegrationTest`: full `@SpringBootTest` RANDOM_PORT, real Tomcat + the Spring Security filter chain ACTIVE, Testcontainers Redis) proves the JWK Set endpoint serves over real HTTP WITHOUT an API key — i.e. the `/v1/.well-known/**` public-path exemption actually holds end-to-end through the filter chain, not just as an array entry (the `JwksControllerTest` `@WebMvcTest` runs with filters disabled, so it can't show this). With the evidence signing identity configured via `@TestPropertySource`, GET `/v1/.well-known/cycles-jwks.json` with no header → 200 + a JWK whose `x` decodes to exactly the configured `signer_did` bytes, correct `kid`/`cycles_nbf_ms`/`status`, and `Cache-Control: public, max-age` (NOT immutable); a bogus API key still yields 200 (public, never 401). The base class's contract-validating interceptor additionally checks the body against the published `CyclesEvidenceJwks` schema (cycles-protocol@main, #113). 2 tests; test-only (no production/wire/spec change; the impl shipped in v0.1.25.32 / #194).), 2026-06-15 (v0.1.25.32 — CyclesEvidence signer-key resolution, publication half: implement `getEvidenceJwks` (`GET /v1/.well-known/cycles-jwks.json`, per `cycles-protocol-v0.yaml` v0.1.25.6 / runcycles/cycles-protocol#113, the v0.2 additive layer designed on #103 / aeoess#43). `JwksController` (public; reads the shared `cycles.evidence.signing.signer-did` + new `cycles.evidence.signing.kid` / `nbf-ms` via `@Value`, holds no injected bean so it loads in every `@WebMvcTest` without extra wiring) serves the JWK Set built by the pure `JwksDocuments.jwkSet(signerDid, kid, nbfMs)`: when `signer-did` is a raw 64-hex key it emits one active Ed25519 OKP JWK — `{kty:OKP, crv:Ed25519, alg:EdDSA, x:base64url(hex-decode(signer_did)), kid (default = first 16 hex of the key), cycles_nbf_ms (default 0), status:active}`, `cycles_exp_ms` omitted ⇒ open-ended; the JWK `x` is the SAME 32 raw bytes `EnvelopeSigner` signs with, so a verifier resolving this set authenticates the emitted signatures. Served with `Cache-Control: public, max-age=300` (NOT immutable — a key set rotates, unlike a content-addressed envelope). 404s via the standard NOT_FOUND `ErrorResponse` when no raw-hex key is configured (evidence off, or a `did:cycles` `signer_did` which carries no key bytes — that, plus retired-key rotation history, is the v0.2-store follow-up); consumers then stay on the raw-hex + `expected_signer` pinning (`binding_only`) path. PUBLIC: `/v1/.well-known/**` added to `SecurityConfig.PUBLIC_PATHS` — public keys only, the private key is never served, and the set is itself the trust anchor. API-base-relative (under `/v1`), per the spec's authority-scope rule (the `did:cycles` hash covers `server_id` WITH its path). Tests: `JwksDocumentsTest` (10 — raw-hex JWK shape, `x` round-trips to the key bytes, default/override kid, nbf carry-through, blank/null/did:cycles/malformed → empty), `JwksControllerTest` (4 — 200 + JWK-set body + short public non-immutable cache via `@WebMvcTest` contract-validated against #113's spec, unconfigured/did:cycles → NOT_FOUND, direct 200 body), `SecurityConfigTest` updated for the new public path. Both new classes 100% line-covered; full `mvn verify` 906 tests green; jacoco 95% gate met. No change to existing endpoints/wire.), 2026-06-14 (v0.1.25.31 — review fix [Medium]: `POST /v1/reservations` re-emitted side-effect events on an idempotent REPLAY. `ReservationController.create` emitted the `RESERVATION_DENIED` deny event and `emitBalanceEvents` (budget-state transitions) unconditionally, while `decide` (`DecisionController`) and `commit`/`release` (`ReservationController`) already skip side-effect emission when `response.isIdempotentReplay()`. A replay (a cached dry-run DENY, or a reserve body-cache replay carrying `idempotentReplay=true`) therefore double-counted those events — the original create already emitted them. FIX: wrap create's whole emission block in `if (!response.isIdempotentReplay())`, matching the other lifecycle endpoints. Test: `ReservationControllerTest` +1 (`shouldNotReemitEventsOnIdempotentReplay` — an idempotent DENY replay re-emits neither `RESERVATION_DENIED` nor balance events). No wire/spec change. NOTE: numbered .31 because v0.1.25.30 is held by the open byte-parity PR #187; merge #187 first.), 2026-06-14 (v0.1.25.30 — byte-parity hardening: extend `EvidenceIdComputerTest`'s golden-fixture coverage from the 3 reserve fixtures to the FULL 13-fixture set (all five artifact types — decide/reserve/commit/release/error), the SAME `cycles-evidence-fixtures` the event-tier `CyclesEvidenceCanonicalizer` verifies against and the APS verifier was generated with (the 10 added fixtures are byte-identical copies from cycles-server-events). This proves cycles-server's SYNCHRONOUS `evidence_id` computation reproduces the canonical id for EVERY artifact SHAPE — `reservation_id` hoisting on commit/release, `endpoint`+`http_status` on error — i.e. the JCS+sha256 recipe is byte-correct across the whole envelope domain, so whenever cycles-server emits a given shape its id matches the worker and the cross-check never dead-letters on drift. (Recipe parity over the canonical/verifier fixture set, NOT the set of shapes cycles-server emits: its emission policy is the budget/lifecycle-denial subset per `GlobalExceptionHandler.EVIDENCE_DENIAL_CODES`, so the error envelope it actually emits is the budget-exceeded kind — `11-reserve-live-budget-exceeded` — while `12-decide-live-forbidden` is a verifier-relevant FORBIDDEN shape cycles-server does NOT emit, included only to prove the computer handles it.) Test-only (no production change): `@ValueSource` 3→13 fixtures; `EvidenceIdComputerTest` 5→15 tests. Full `mvn verify` 431 data + 196 api green; jacoco 95% gate met.), 2026-06-13 (v0.1.25.29 — include the OPTIONAL `request` body in `error` CyclesEvidence (review Minor: cycles-evidence-v0.1 `ErrorPayload.request` SHOULD be present for a full audit trail unless redaction applies; v0.1.25.28 always omitted it). The four core controllers (`create`/`commit`/`release`/`decide`) stash their parsed `@RequestBody` DTO under the `GlobalExceptionHandler.EVIDENCE_REQUEST_ATTRIBUTE` request attribute immediately after binding; `GlobalExceptionHandler` includes it as the `error` payload's `request` field when present (typed DTO → Jackson → JCS-canonical). Present for any post-binding denial on the four core endpoints; absent for pre-binding failures (validation/auth) and non-instrumented routes — a future redaction policy MAY drop it. No wire change to `ErrorResponse` (the `request` lives only inside the evidence payload). The `error` payload is now `{endpoint, http_status, [reservation_id], [request], response}`, completing the draft `ErrorPayload` shape. REVIEW FIX (codex, High — applies to ALL request-bearing artifacts, not just error): the request/response DTOs serialized into evidence payloads can emit null-valued properties (`ReservationCreateRequest.ttl_ms`/`overage_policy`/`metadata`, `CommitRequest.metrics`/`metadata`, `ReleaseRequest.reason` — none carry `@JsonInclude(NON_NULL)`, and the shared `ObjectMapper` does not omit nulls), but the cycles-evidence-v0.1 mirror schemas are `additionalProperties:false` with non-nullable typed fields, so a serialized `null` makes the signed envelope fail mirror validation. This latent defect already affected the merged reserve/commit/release success-path evidence (each `put("request", request)`), not only the new error path. Fix is CENTRALIZED in `EvidenceEmitter`: a private NON_NULL copy of the shared mapper (`evidencePayloadMapper`, lazily derived) null-strips the payload ONCE into a tree used for BOTH the `evidence_id` computation and the queued record, so cycles-server and the event-tier worker agree byte-for-byte. The DTOs and the shared mapper are deliberately NOT changed — the same DTO serialization backs idempotency payload hashes, reserve.lua args, and cached response bodies, so altering it would break those. Byte-parity fixtures unaffected (their requests carry no nulls). Tests: `GlobalExceptionHandlerTest` +2 (stashed-request-included, absent-request-omitted) + `ReservationControllerTest` +1 (end-to-end `@WebMvcTest`: a non-dry reserve denial through the controller stashes the request DTO, emits `error` evidence with `request`+`endpoint`, and surfaces `cycles_evidence` on the 409) + `EvidenceEmitterTest` +2 (null-valued payload properties stripped; `evidence_id` computed over the null-stripped payload). codex round 1 SHIP, round 2 (High) fixed. Full `mvn verify` 421 data + 196 api green; full integration suite 428 api green; jacoco 95% gate met.), 2026-06-13 (v0.1.25.28 — CyclesEvidence fan-out to the `error` artifact, COMPLETING the lifecycle binding loop (decide/reserve/commit/release/error) (per cycles-protocol v0.1.25.5 / runcycles/cycles-protocol#109). `ErrorResponse` gains optional `cycles_evidence` (`CyclesEvidenceRef`). `GlobalExceptionHandler` (the central `CyclesProtocolException` handler) now emits an `error` CyclesEvidence source record over `{endpoint, http_status, response}` (+ `reservation_id` hoisted for the commit/release endpoints, so evidence-only readers can reconstruct the authorization→settlement chain) and stamps the returned ref onto the `ErrorResponse` — but ONLY for budget/lifecycle DENIAL codes raised on the four core endpoints: the budget denials (`BUDGET_EXCEEDED`, `BUDGET_FROZEN`, `BUDGET_CLOSED`, `OVERDRAFT_LIMIT_EXCEEDED`, `DEBT_OUTSTANDING`, `UNIT_MISMATCH`) AND the reservation terminal-state denials on commit/release (`RESERVATION_FINALIZED` 409, `RESERVATION_EXPIRED` 410 — settling an already-finalized/expired reservation; reservation_id is hoisted for these so the authorization→settlement-denial chain is reconstructable) (`POST /v1/decide` | `/v1/reservations` | `.../commit` | `.../release`, matched via `HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE` + method; `reservation_id` from `URI_TEMPLATE_VARIABLES_ATTRIBUTE`). The non-dry reserve 409 `BUDGET_EXCEEDED` is the highest-signal denial APS receipts bind to — the canonical wire shape per §ReservationCreateResponse.decision (NOT a 200 DENY). Pre-evaluation failures (validation/auth/malformed/not-found/idempotency-mismatch) and non-core routes (e.g. extend) emit NOTHING — no decision was made, nothing to attest; matches the spec's `cycles_evidence` "absent for errors raised before evidence could be emitted". The ref is stamped AFTER `evidence_id` is computed over the pre-stamp response (`ErrorResponse` is `@JsonInclude(NON_NULL)`, so the attested `payload.error.response` omits `cycles_evidence` — non-self-referential), and emission is fail-open via `EvidenceEmitter` (a push/identity failure never fails the error response). `GlobalExceptionHandler` switches to constructor injection of `EvidenceEmitter`. REVIEW FIXES (codex): (Low) a commit/release denial whose `reservation_id` path var is somehow absent now SKIPS emission rather than emit a spec-invalid `error` payload missing the required `reservation_id`. (Medium) RESERVATION_FINALIZED/RESERVATION_EXPIRED were initially excluded from the allowlist — they route through `handleScriptError` on commit/release and are genuine settlement-path denials (the spec's `ErrorResponseMirror` enum already includes both, and reservation_id hoisting exists for exactly this chain), so they are now included; the doc states the governing principle (a code belongs iff it is a server DECISION on a core endpoint; pre-evaluation validation/auth/not-found/idempotency-mismatch stay excluded). Tests: `GlobalExceptionHandlerTest` +9 (reserve-denial emit+stamp with payload-shape assertions, commit-denial `reservation_id` hoist, commit-FINALIZED + release-EXPIRED terminal-state emit with reservation_id, unconfigured-emitter-omits-ref, pre-evaluation-no-emit, no-matched-route-no-emit, extend-route-no-emit, commit-denial-without-reservation_id-no-emit); the five controller `@WebMvcTest`s gain `@MockitoBean EvidenceEmitter`. Full `mvn verify` 419 data + 193 api green; full integration suite 426 api green; jacoco 95% gate met. This is the LAST core-artifact fan-out — remaining evidence work is the `evidence.available` webhook + deploy/config of the shared identity env vars.), 2026-06-13 (v0.1.25.27 — CyclesEvidence fan-out to decide + generalized non-persisting idempotency machinery (per cycles-protocol v0.1.25.4 / runcycles/cycles-protocol#108). The `decide` artifact (pre-execution decision, no reservation) now emits CyclesEvidence over `{request, response}` (draft `DecidePayload`) and surfaces `cycles_evidence` on `DecisionResponse`. GENERALIZATION: the dry_run atomic-claim + wait machinery (from #181) is refactored into a shared `kind`-parameterized path serving both dry_run AND decide — `acquireIdempotencyClaim` (SET NX pending-claim), `waitForIdempotentReplayBody` (per-poll wait), `cacheIdempotentBody`, `clearIdempotencyClaim`, `idempotencyCacheKey`/`pendingPrefix`/`pendingMarker`/`isPending`/`validatePendingMarker`/`pendingPayloadHash`, and the `IdemClaim` record. Keys/markers are derived from `kind` so dry_run stays byte-identical (`idem::dry_run:`, `__dry_run_pending__:`) — its behavior + tests unchanged; decide uses `idem::decide:` + `__decide_pending__:`. decide() is restructured into an orchestrator (acquire → replay/pending/fresh; connections released before emit + wait, no pool nesting) + an extracted pure `evaluateDecisionBudget`; concurrent same-key decides now converge to ONE evaluation + ONE envelope (closes the duplicate-emit race, same bar as dry_run). `DecisionController` threads `trace_id`. `DecisionResponse` gains optional `cycles_evidence` + transient `idempotentReplay`. REVIEW FIXES (codex): (a) the decide orchestrator's outer catch now rethrows `CyclesProtocolException` and `RuntimeException` UNCHANGED and only wraps a remaining checked `Exception` — runtime failures propagate unwrapped (no double-wrap). (b) `cacheIdempotentBody` / `clearIdempotencyClaim` no longer require a caller-held connection — each self-acquires its own short-lived pooled `Jedis` and fails open on a Redis error (the dead `clearDecideClaimQuietly` helper removed); callers in both the dry_run and decide paths updated. (c) `DecisionController` guards the `RESERVATION_DENIED` event emission with `!response.isIdempotentReplay()` so a decide replay does not re-emit the original deny event. (d) POOL-NESTING on the DRY_RUN FAILURE PATH — `evaluateDryRun`'s catch previously cleared the pending claim via the self-acquiring `clearIdempotencyClaim` while the caller's evaluation `Jedis` was STILL checked out (peak 2 connections/req under concurrent failing dry-runs — the starvation pattern this work avoids elsewhere). Added a `clearIdempotencyClaim(Jedis, idemKey, marker)` overload that compare-and-deletes on the ALREADY-HELD connection; `evaluateDryRun`'s failure catch now uses it (a failing dry-run checks out exactly ONE pooled connection); the self-acquire overload delegates to it. decide was already correct (clears only after its eval connection closes) and is unchanged. Tests: `RedisReservationDecideEventTest` +4 (fresh decide stamp+cache, decide replay-verbatim-no-reemit, fresh-decide-releases-connection-before-emit `InOrder` guard, fresh-decide-clears-claim-on-eval-failure; the runtime-exception test asserts the original instance propagates unwrapped), `RedisReservationCrudTest` +2 (dry-run clear-eval-also-throws → original error still propagates; dry-run post-eval cache/clear pool-unavailable → still returns the decision) plus strengthened the existing dry-run eval-fail test with a `times(1).getResource()` no-nesting guard, `DecisionControllerTest` +2 (surface cycles_evidence; idempotent-replay DENY does NOT re-emit RESERVATION_DENIED); dry_run/commit/release/reserve tests unchanged + green. Full `mvn verify` 419 data + 184 api green; full integration suite 417 api green; jacoco 95% gate met. Forbidden/validation failures on /v1/decide remain `error`-artifact territory (next slice).), 2026-06-13 (v0.1.25.26 — CyclesEvidence fan-out to commit + release (per cycles-protocol v0.1.25.3 / runcycles/cycles-protocol#107). Extends the reserve evidence pattern to the rest of the budget lifecycle. `commit.lua`/`release.lua` now flag their idempotent-replay branch (`replay = true`) so Java distinguishes fresh from replay. `commitReservation`/`releaseReservation` gain a `traceId` overload (4-/5-arg; 3-/4-arg back-compat retained) and, on a FRESH terminal op, emit a `commit`/`release` CyclesEvidence source record over `{reservation_id, request, response}` (the draft `CommitPayload`/`ReleasePayload` shape; response AS-IS, before `cycles_evidence` is stamped so the attested body is non-self-referential), stamp the ref onto the response, and cache the full body at `commit:body:` / `release:body:` with a 30-DAY TTL matching the terminal reservation-hash TTL (`PEXPIRE 2592000000`). A REPLAY returns the cached body VERBATIM (per-poll connection wait, mirroring the #181 reserve hardening; the main connection is released before waiting), falling back to the Lua-rebuilt response (no evidence) on cache miss. `CommitResponse`/`ReleaseResponse` gain optional `cycles_evidence` + transient `idempotentReplay`. `ReservationController` threads `trace_id` and SKIPS commit event emission on a replay (the original already emitted; the cached body carries no internal event fields). Generic helpers `emitLifecycleEvidence` / `cacheLifecycleBody` / `readCachedLifecycleBody` shared across commit+release. Tests: `RedisReservationCommitReleaseTest` +4 (fresh commit/release stamp+cache, commit/release replay-verbatim-no-reemit), `ReservationControllerTest` +2 (commit surfaces cycles_evidence, commit replay skips overage event). REVIEW FIXES (#183): (a) POOL-NESTING — the fresh reserve/commit/release paths previously held the Lua connection while `emitLifecycleEvidence`→`EvidenceQueueRepository.push` checked out a SECOND pool connection (peak 2/req → pool starvation under load). All three now RELEASE the Lua connection before evidence emit + body-cache, and `cacheReserveResponse`/`cacheLifecycleBody` acquire their own short-lived connection (peak 1 at a time); the reserve path was affected identically and is fixed too. (b) AUDIT-REPLAY — the admin-release audit block is now guarded by `!response.isIdempotentReplay()`, so an admin retry of an already-released reservation no longer writes a second `audit:log` entry. Regression guards: `InOrder` tests assert the Lua connection is `close()`d BEFORE `evidenceEmitter.emit(...)` on all three fresh paths (reserve/commit/release), plus `adminReleaseReplayDoesNotReAudit`. Full `mvn verify` 411 data + 179 api green; full integration suite 414 api green; jacoco 95% gate met.), 2026-06-13 (v0.1.25.25 — CyclesEvidence idempotency-race hardening (runcycles/cycles-server#181, on the v0.1.25.24 centralized flow). Closes two concurrency races: (1) RESERVE replay landing in the window between reserve.lua writing its `idem::reserve:` mapping and `createReservation` writing the `reserve:body:` cache previously fell back to rebuilt current balances; the replay now POLLS the body cache (≤4 attempts × 25ms) before the rebuild fallback. (2) Concurrent fresh DRY_RUN with the same key could both evaluate → duplicate evidence emission + non-idempotent responses; now an atomic `SET … NX PX 60000` pending-claim elects one evaluator and losers wait (`waitForDryRunReplay`) for the winner's cached result. WAIT MECHANICS (addressing review): the wait loops acquire a FRESH Jedis per poll (try-with-resources inside the loop) rather than holding the request connection across `Thread.sleep`, and the budget is 4 attempts (~100ms, not 1s); the reserve replay path closes its main connection before waiting. A waiter that still finds no result returns 500 `INTERNAL_ERROR` ("retry with the same idempotency_key") — transient, resolves on client retry. CLEANUP: the pending claim is released via an atomic compare-and-delete Lua (`GET==marker then DEL`), and is cleared if the caller-side cache write fails-open or evaluation throws (`DryRunResult` carries claimKey/marker). Refactor: `createDryRunReservation` / `rebuildReserveReplay` / `DryRunResult.pending(...)` extracted; no wire/spec change. Tests: `RedisReservationCrudTest` +5 (reserve wait-then-replay, dry-run pending-wait-replay, claim-set-on-store, clear-on-cache-fail, clear-on-eval-fail); full `mvn verify` 407 data + 179 api green; full integration suite 412 api green incl. thundering-herd 3/3; jacoco 95% gate met.), 2026-06-12 (v0.1.25.24 — CyclesEvidence centralized into the reservation-creation flow (review: two more High findings on v0.1.25.23). ROOT CAUSE addressed: evidence had lived in `ReservationController` while idempotency/caching/TTL live in `RedisReservationRepository` — that split caused every prior finding. Evidence is now emitted + stamped + cached INSIDE `createReservation` (the idempotent unit); `EvidenceEmitter` is injected into the repository and `trace_id` is threaded via a new `createReservation(request, tenant, traceId)` overload (2-arg back-compat retained). (1) BODY-CACHE TTL now matches reserve.lua's idempotency mapping — `max(ttl_ms + grace_ms, 86400000)` (was a fixed 24h, which expired before the Lua key for reservations whose tenant `max_reservation_ttl_ms` exceeds 24h, dropping replay back to rebuilt current balances). (2) DRY_RUN now EMITS `reserve` evidence for ALL outcomes (ALLOW and the early DENYs) per spec-authority — `drafts/cycles-evidence-v0.1.yaml` carries the `03-reserve-dry-run-deny` golden fixture and states dry-run ALLOW/DENY are captured as `reserve` evidence (a dry-run DENY is the canonical signed "would this be allowed?" attestation; non-dry denials are 409→`error` artifact). Reverses the v0.1.25.23 suppression. Dry-run evidence is stamped before caching the body under `idem::dry_run:` (now covers DENY outcomes too, making them idempotent), replayed verbatim. `ReservationController` no longer references `EvidenceEmitter`/caching — it just returns the response. Tests: repository gains fresh-reserve evidence+TTL (incl. 48h long-lived → 48h+grace, not 24h) + cache-hit verbatim replay; `ReservationControllerTest` simplified to pass-through (cycles_evidence present/absent + 3-arg threading); full `mvn verify` 402 data + 179 api green; full integration suite 412 api green incl. the thundering-herd idempotency test (3/3). jacoco 95% gate met.), 2026-06-12 (v0.1.25.23 — CyclesEvidence idempotent-replay-body fix (review: two High findings on v0.1.25.22). (1) Reserve replays now return the ORIGINAL full payload: a fresh non-dry-run create stamps `cycles_evidence` then caches the WHOLE response via `RedisReservationRepository.cacheReserveResponse` keyed by `reserve:body:` (NOT idempotency_key — so it can't go stale across an idem-key expiry + re-reserve, and never collides with reserve.lua's own `idem::reserve:` mapping; 24h TTL); the Lua-idempotency-hit path reads that key by the reservation_id the Lua returns and replays the body VERBATIM (original balances + original `cycles_evidence`, so the body matches the envelope the `evidence_id` points to), falling back to rebuild-from-hash only when the cache is absent. Replaces the v0.1.25.22 `persistEvidenceRef`/`storedEvidence*` ref-only approach — which left the rebuilt body's balances drifting from the envelope, still violating the NORMATIVE "return the original successful response payload" rule (cycles-protocol-v0.yaml IDEMPOTENCY). Also fixes the pre-existing balance-drift-on-replay bug. (2) dry_run reserves NO LONGER emit/surface evidence at all: a dry_run neither persists a reservation nor changes any budget, so there is nothing to attest or bind a receipt to — and the dry_run idempotency cache (cached pre-evidence) previously caused every replay to re-emit + recompute a fresh `evidence_id`. The controller now suppresses evidence when `dry_run=true` or `idempotentReplay`. Model: `ReservationCreateResponse` drops `storedEvidence*`, keeps transient `idempotentReplay`. Tests: `ReservationControllerTest` reworked (fresh-surface+cache, replay-verbatim-no-reemit, replay-omit-when-absent, dry-run-no-evidence, emitter-null-still-caches), `RedisReservationCrudTest` +3 (cache-hit verbatim replay, cache write, no-cache-without-key). 402 data + 181 api tests, jacoco 95% gate met.), 2026-06-12 (v0.1.25.22 — CyclesEvidence — synchronous `evidence_id` + `cycles_evidence` on the reserve response — WIP on `feat/evidence-id-sync`, closing the APS binding loop. `EvidenceIdComputer` (`@Component`) reproduces the `cycles-evidence/v0.1` content-hash recipe (RFC 8785 JCS via erdtman + sha256 over the envelope with `evidence_id`/`signature` emptied) byte-for-byte — proven against the 3 reserve golden fixtures the event-tier worker and APS verifier use. `EvidenceEmitter.emit` now computes the id SYNCHRONOUSLY when the PUBLIC identity (`cycles.evidence.server-id` + `cycles.evidence.signing.signer-did` — property names + env vars SHARED with the event-tier worker, so one var configures both; review finding: Low) is configured, stamps it on the source record (for the worker's cross-check) and returns `EvidenceRef {evidence_id, cycles_evidence_url}`; unconfigured → null (record still queued, no id). `cycles_evidence_url` = `{server_id}/evidence/{evidence_id}` — `server_id` already carries the `/v1` base, so no `/v1` double-prefix. `ReservationController.create` stamps `cycles_evidence` on the response AFTER the id is computed, so the attested `payload.reserve.response` never carries the ref (content hash stays non-self-referential). New `CyclesEvidenceRef` model; `ReservationCreateResponse` gains optional `cycles_evidence` (`@JsonInclude(NON_NULL)` — additive, non-breaking per the spec EVOLUTION CONTRACT). IDEMPOTENT (review finding: High): a fresh reserve computes + emits ONCE and persists the ref on the reservation hash (`persistEvidenceRef` → HSET `evidence_id`/`cycles_evidence_url`); an idempotent replay returns that stored ref VERBATIM (transient `idempotentReplay`/`storedEvidence*` on the response) and NEVER recomputes (replay balances reflect current state → would drift to a different `evidence_id`) or re-emits — honouring the reserve "return the original successful response" rule. Per cycles-protocol v0.1.25.1 (#105) + v0.1.25.2 (#106). Tests: `EvidenceIdComputer` x5 (golden-fixture byte-parity), `EvidenceEmitter` +5 (configured/unconfigured/url-join), `ReservationControllerTest` +4 (fresh-surface+persist, replay-verbatim-no-reemit, replay-omit-when-unpersisted, omit). Build needs the serving controller (#176) on main for `getEvidence` spec-coverage. Fan-out to decide/commit/release follows.), 2026-06-12 (CyclesEvidence serving endpoint — WIP on `feat/evidence-serving-impl`. Implements `getEvidence` (`GET /v1/evidence/{evidence_id}`, per `cycles-protocol-v0.yaml` revision 2026-06-12 / runcycles/cycles-protocol#104): `EvidenceController` reads the shared store via `EvidenceStoreReader` (`GET evidence:envelope:`, same `cycles.evidence.store.key-prefix` as the event-tier writer) and serves the signed envelope VERBATIM (bytes, `application/json`) with `Cache-Control: public, immutable`; `404` (`CyclesProtocolException.notFound`) when absent; `@Pattern` 64-hex `evidence_id` → `400`. PUBLIC: `/v1/evidence/**` added to `SecurityConfig.PUBLIC_PATHS` (no API key — capability-URL, per the spec). Tests: `EvidenceControllerTest` (standalone, 200 verbatim + immutable-cache, 404), `EvidenceStoreReaderTest` (get-by-key); the four controller `@WebMvcTest`s gain `@MockitoBean EvidenceStoreReader` (they load all controllers via `ContractValidationConfig`). `OpenApiContractDiffTest` passes against the #104 spec (validated locally via `-Dcontract.spec.url`); CI goes green once #104 merges to main. No change to existing endpoints.), 2026-06-12 (CyclesEvidence source emission, reserve endpoint — WIP on `feat/evidence-emit-reserve`, the producer half of the dedicated-channel emitter. `EvidenceQueueRepository` (LPUSH a source record to `evidence:pending`, key `cycles.evidence.queue.pending-key`) and `EvidenceEmitter` (`@Service`; stamps `artifact_type`, `issued_at_ms` (response time, matching the fixtures), `trace_id`, and the caller-supplied payload body — `server_id`/`signer_did` are added by the event-tier worker). DURABLE: the enqueue is SYNCHRONOUS — the source record is LPUSH'd to the same Redis as the just-committed ledger write before the response returns, so a committed op cannot return without its evidence queued (closes the async pre-Redis loss window; removes the unbounded-executor backlog/OOM risk). Fail-open: a push failure is logged + metered (`cycles.evidence.emit_failed` via `CyclesMetrics`) and never fails the already-committed response; expensive signing stays async in the event tier. `emit` takes the payload body because its shape varies by artifact (reserve/decide = `{request,response}`; commit/release add `reservation_id`; error = `{endpoint,http_status,request,response}`). Wired into `ReservationController.create` (covers ALLOW and DENY). Dedicated channel, not the webhook event stream. 4 tests (`EvidenceEmitter` x3 incl. sync-enqueue + fail-open, `EvidenceQueueRepository` LPUSH); `ReservationControllerTest` gains `@MockitoBean EvidenceEmitter`. No wire/spec change; fan-out to decide/commit/release + the error artifact follows.), 2026-05-22 (v0.1.25.21 — `expires_from`/`expires_to` and `finalized_from`/`finalized_to` ISO-8601 time-window filters on `GET /v1/reservations` per `cycles-protocol-v0.yaml` revision 2026-05-22 (runcycles/cycles-protocol#98); closes runcycles/cycles-server#162. Four new query params mirroring the v0.1.25.20 `from`/`to` shape: `expires_*` binds to `expires_at_ms` (required field, every row), `finalized_*` binds to `finalized_at_ms` (populated only on COMMITTED/RELEASED; ACTIVE and EXPIRED normatively excluded). Three windows compose with AND. `finalized_at_ms` added as an optional field on `ReservationSummary` so clients filtering with `finalized_*` can see the timestamp without a follow-up `getReservation` — strict-schema-compatible because the field is `@JsonInclude(NON_NULL)`. `FilterHasher` extends with four more `Long` args (10 → 14) using independent gated emission per pair — preserves byte-exact back-compat for v0.1.25.18 cursors (golden `2f397ea0e8fb53b7`) AND v0.1.25.20 cursors with from/to set (golden `ad7204d521cfd133`). `RedisReservationRepository.listReservations` signature 14 → 18 args. Two new predicate helpers (`expiresAtInWindow`, `finalizedAtInWindow`) applied in both legacy SCAN-cursor and sorted paths. Validation: each new pair `from > to` → 400; malformed values → 400 with distinct per-param message; blank strings treated as unset. 557 tests pass (384 data + 173 api), +19 vs v0.1.25.20.), 2026-05-21 (v0.1.25.20 — `from` / `to` ISO-8601 time-window filter on `GET /v1/reservations` per cycles-protocol revision 2026-05-21; closes runcycles/cycles-server#159. Two new query params on `listReservations`, both `string`/`format: date-time`, both inclusive bounds on `created_at_ms`, both bind to `created_at_ms` regardless of `sort_by`. Implemented in both the legacy SCAN-cursor and sorted paths. `FilterHasher.hash(...)` now folds `fromMs`/`toMs` into the canonical hash so sorted-path cursors invalidate on window change (the legacy Redis-SCAN cursor is not window-validated, matching how it already treats every other filter). Validation: malformed values → 400, `from > to` → 400 before any repository call, blank strings treated as unset, missing/unparseable `created_at` rows defensively excluded when either bound supplied. Pure additive wire change — all v0.1.25.x clients that don't send the params continue to work byte-for-byte. 538 tests pass (375 data + 163 api).), 2026-05-21 (v0.1.25.19 — supply-chain CVE patch; re-pin `tomcat.version=10.1.55` in `cycles-protocol-service/pom.xml` to close 7 new CVEs flagged by Trivy against `tomcat-embed-core 10.1.54` (CRITICAL: CVE-2026-43512, CVE-2026-43515, CVE-2026-41293; HIGH: CVE-2026-43513, CVE-2026-42498, CVE-2026-41284; LOW: CVE-2026-43514 — all fixed in 10.1.55 / 11.0.22). Mirrors the v0.1.25.16 pattern; the override was dropped in v0.1.25.18 when SB 3.5.14's BOM caught up to 10.1.54, now re-added one patch higher because Trivy DB updates between 2026-05-11 (last green main run) and 2026-05-21 surfaced a new wave on the same artifact. Removable once Spring Boot ships with 10.1.55+ as its managed version. `commons-lang3.version=3.18.0` retained (CVE-2025-48924 still unfixed in SB 3.5.14's managed 3.17.0). No production code or test changes; all 537 protocol-service tests pass.), 2026-04-26 (v0.1.25.18 — dependency hygiene matching `cycles-server-events` v0.1.25.12: bump `spring-boot-starter-parent` 3.5.13 → 3.5.14 (patch with upstream security hardening — constant-time comparison for remote DevTools secret, `RandomValuePropertySource` SecureRandom, hostname verification applied consistently for Cassandra/RabbitMQ SSL, plus symlink-handling fixes); **drop `10.1.54` override** since Spring Boot 3.5.14's BOM now manages 10.1.54 directly (verified against `spring-boot-dependencies-3.5.14.pom`); commons-lang3 3.18.0 override retained — Spring Boot 3.5.14's BOM still manages 3.17.0. **Jedis 7.4.1 → 6.2.0** to align all three services on the same Redis client major (events at 6.2.0 since v0.1.25.12, admin at 6.2.0 in v0.1.25.41); all call sites use stable APIs (`Jedis`, `JedisPool`, `Pipeline`, `Response`, `ScanParams`, `ScanResult`, `JedisNoScriptException`) — no 7.x-only API usage. No code changes; all 152 tests pass.), diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java index d242833..6d13a5c 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java +++ b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java @@ -1,6 +1,9 @@ package io.runcycles.protocol.api.controller; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import io.runcycles.protocol.api.evidence.JwksDocuments; +import io.runcycles.protocol.api.evidence.JwksDocuments.RetiredKey; import io.runcycles.protocol.data.exception.CyclesProtocolException; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -14,6 +17,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -48,26 +53,74 @@ public class JwksController { private final String signerDid; private final String kid; private final long nbfMs; + private final List retiredKeys; public JwksController( @Value("${cycles.evidence.signing.signer-did:}") String signerDid, @Value("${cycles.evidence.signing.kid:}") String kid, - @Value("${cycles.evidence.signing.nbf-ms:0}") long nbfMs) { + @Value("${cycles.evidence.signing.nbf-ms:0}") long nbfMs, + @Value("${cycles.evidence.signing.retired-keys:}") String retiredKeysJson) { this.signerDid = signerDid == null ? "" : signerDid.trim(); this.kid = kid == null ? "" : kid.trim(); this.nbfMs = nbfMs; + this.retiredKeys = parseRetiredKeys(retiredKeysJson); if (!this.signerDid.isBlank() && !JwksDocuments.isRawHexKey(this.signerDid)) { LOG.info("evidence signer_did is not a raw 64-hex key (did:cycles or other); JWKS " + "publication needs a raw-hex public key, so GET /v1/.well-known/cycles-jwks.json " + "will return 404 until one is configured"); } + if (!this.retiredKeys.isEmpty()) { + LOG.info("evidence JWKS: {} retired key(s) configured for rotation history", this.retiredKeys.size()); + } + } + + /** + * Parse {@code cycles.evidence.signing.retired-keys} — a JSON array of + * {@code {"signer_did","kid","nbf_ms","exp_ms"}} — into retired-key records. + * Malformed/incomplete entries are dropped here (logged) or skipped later by + * {@link JwksDocuments}; a parse failure yields no retired keys (the active + * key still publishes), never a crash. + */ + private static List parseRetiredKeys(String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + List out = new ArrayList<>(); + try { + JsonNode arr = new ObjectMapper().readTree(json); + if (!arr.isArray()) { + LOG.warn("cycles.evidence.signing.retired-keys is not a JSON array; ignoring"); + return List.of(); + } + for (JsonNode n : arr) { + JsonNode nbfNode = n.path("nbf_ms"); + JsonNode expNode = n.path("exp_ms"); + // Both window bounds MUST be explicit integral epoch-ms. A missing + // or non-integral nbf_ms is NOT coerced to 0 (epoch) — that would + // silently widen the validity window; drop the entry instead. + if (!nbfNode.isIntegralNumber() || !expNode.isIntegralNumber()) { + LOG.warn("retired key '{}' has a missing/non-integral nbf_ms or exp_ms; skipping", + n.path("kid").asText("")); + continue; + } + out.add(new RetiredKey( + n.path("signer_did").asText(""), + n.path("kid").asText(""), + nbfNode.asLong(), + expNode.asLong())); + } + } catch (Exception e) { + LOG.warn("could not parse cycles.evidence.signing.retired-keys; ignoring: {}", e.getMessage()); + return List.of(); + } + return out; } @GetMapping(value = "/cycles-jwks.json", produces = MediaType.APPLICATION_JSON_VALUE) @Operation(operationId = "getEvidenceJwks", summary = "Fetch the signer's CyclesEvidence JWK Set (signer-key resolution)") public ResponseEntity> getEvidenceJwks() { - Map jwks = JwksDocuments.jwkSet(signerDid, kid, nbfMs) + Map jwks = JwksDocuments.jwkSet(signerDid, kid, nbfMs, retiredKeys) .orElseThrow(() -> CyclesProtocolException.notFound("cycles-jwks.json")); // Short, public cache — the set changes only on key rotation, so unlike a // content-addressed envelope it MUST NOT be immutable. diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/evidence/JwksDocuments.java b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/evidence/JwksDocuments.java index 5bb650d..343001d 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/evidence/JwksDocuments.java +++ b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/evidence/JwksDocuments.java @@ -1,11 +1,14 @@ package io.runcycles.protocol.api.evidence; +import java.util.ArrayList; import java.util.Base64; import java.util.HexFormat; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.regex.Pattern; /** @@ -15,17 +18,19 @@ * (cycles-evidence v0.2). Pure function over the configured signing identity; * no Spring, no I/O. * - *

v0.1 scope: publishes the single currently-configured RAW-HEX - * {@code signer_did} as one active Ed25519 OKP JWK. A {@code did:cycles} - * {@code signer_did} carries no key bytes, so it cannot be published from - * {@code signer_did} alone — that (and retired-key rotation history) is the - * v0.2-store follow-up; until then this returns {@link Optional#empty()} and - * the endpoint 404s, leaving consumers on the raw-hex + {@code expected_signer} - * pinning path. + *

Publishes the currently-configured RAW-HEX {@code signer_did} as the single + * ACTIVE Ed25519 OKP JWK (open-ended window), PLUS any configured RETIRED keys — + * each with a bounded {@code [cycles_nbf_ms, cycles_exp_ms)} window — so that + * evidence signed before a key rotation still verifies against the key that was + * valid at its {@code issued_at_ms}. Retaining retired keys is the load-bearing + * rotation rule: a verifier selects the key whose window covers the envelope's + * issuance time, never "the current key". A {@code did:cycles} {@code signer_did} + * carries no key bytes, so the active key cannot be published from it alone; + * the set is empty (endpoint 404s) until a raw-hex active key is configured. * - *

Key bytes match what {@code EnvelopeSigner} signs with: the JWK {@code x} - * is {@code base64url(hex-decode(signer_did))}, the same 32 raw public-key - * bytes — so a verifier resolving this set authenticates the same signatures. + *

Key bytes match what {@code EnvelopeSigner} signs with: a JWK {@code x} is + * {@code base64url(hex-decode())}, the same 32 raw bytes — so a + * verifier resolving this set authenticates the same signatures. */ public final class JwksDocuments { @@ -34,41 +39,102 @@ public final class JwksDocuments { private JwksDocuments() { } + /** + * A previously-active signing key, retained in the published set so evidence + * signed during its validity window still resolves after rotation. + * + * @param signerDid the retired key as a raw 64-hex Ed25519 public key + * @param kid its stable key id (must be unique across the set) + * @param nbfMs valid-from (epoch ms, inclusive) + * @param expMs valid-until (epoch ms, EXCLUSIVE) — REQUIRED for a retired + * key (a retired key has a closed window); a null exp is a + * config error and the entry is skipped. + */ + public record RetiredKey(String signerDid, String kid, long nbfMs, Long expMs) { + } + /** True when {@code signerDid} is a publishable raw 64-hex Ed25519 key. */ public static boolean isRawHexKey(String signerDid) { return signerDid != null && RAW_HEX_32.matcher(signerDid.trim()).matches(); } + /** Single active-key set (no rotation history). */ + public static Optional> jwkSet(String signerDid, String kid, long nbfMs) { + return jwkSet(signerDid, kid, nbfMs, List.of()); + } + /** - * The signer's JWK Set, or empty when no raw-hex signing key is configured - * (blank, or a {@code did:cycles} form that carries no key bytes). + * The signer's JWK Set — the active key plus any retired keys — or empty + * when no raw-hex active key is configured. Invalid retired entries are + * skipped defensively (a bad history entry never breaks publication of the + * active key): malformed hex; a missing {@code expMs} (a retired key needs a + * closed window); an empty/inverted window ({@code expMs <= nbfMs}, since + * {@code cycles_exp_ms} is EXCLUSIVE); the SAME key material as the active + * key (same {@code x} republished with a bounded window would make the + * window covering an {@code issued_at_ms} ambiguous); or a {@code kid} + * colliding with the active key or an earlier retired key (a duplicate + * {@code kid} is never emitted — set-wide kid uniqueness is required). * - * @param signerDid the configured {@code cycles.evidence.signing.signer-did} - * @param kid configured key id, or blank to derive a stable default - * @param nbfMs {@code cycles_nbf_ms} validity-from (epoch ms, inclusive) + * @param signerDid the active key ({@code cycles.evidence.signing.signer-did}) + * @param kid active key id, or blank to derive a stable default + * @param nbfMs active {@code cycles_nbf_ms} (epoch ms, inclusive) + * @param retired retired keys to retain in the set (may be empty/null) */ - public static Optional> jwkSet(String signerDid, String kid, long nbfMs) { + public static Optional> jwkSet( + String signerDid, String kid, long nbfMs, List retired) { if (!isRawHexKey(signerDid)) { return Optional.empty(); } - String did = signerDid.trim(); - byte[] publicKey = HexFormat.of().parseHex(did); - String x = Base64.getUrlEncoder().withoutPadding().encodeToString(publicKey); + String activeDid = signerDid.trim(); + String activeKid = (kid == null || kid.isBlank()) ? defaultKid(activeDid) : kid.trim(); + + List> keys = new ArrayList<>(); + Set kids = new LinkedHashSet<>(); + keys.add(buildJwk(activeDid, activeKid, nbfMs, null, "active")); + kids.add(activeKid); + + if (retired != null) { + for (RetiredKey r : retired) { + if (r == null || !isRawHexKey(r.signerDid()) || r.expMs() == null) { + continue; // malformed hex or no closed window — skip + } + if (r.expMs() <= r.nbfMs()) { + continue; // empty or inverted window (exp is EXCLUSIVE) — skip + } + String rDid = r.signerDid().trim(); + if (rDid.equalsIgnoreCase(activeDid)) { + continue; // same key material as the active key — ambiguous window, skip + } + String rKid = (r.kid() == null || r.kid().isBlank()) ? defaultKid(rDid) : r.kid().trim(); + if (!kids.add(rKid)) { + continue; // duplicate kid — never emit (set-wide uniqueness) + } + keys.add(buildJwk(rDid, rKid, r.nbfMs(), r.expMs(), "retired")); + } + } + + Map jwks = new LinkedHashMap<>(); + jwks.put("keys", keys); + return Optional.of(jwks); + } + /** One Ed25519 OKP JWK. {@code expMs == null} ⇒ active (open-ended, exp + * omitted); otherwise a bounded window with {@code status: retired}. */ + private static Map buildJwk(String didHex, String kid, long nbfMs, Long expMs, String status) { + byte[] publicKey = HexFormat.of().parseHex(didHex); Map jwk = new LinkedHashMap<>(); jwk.put("kty", "OKP"); jwk.put("crv", "Ed25519"); jwk.put("alg", "EdDSA"); - jwk.put("x", x); - jwk.put("kid", (kid == null || kid.isBlank()) ? defaultKid(did) : kid.trim()); + jwk.put("x", Base64.getUrlEncoder().withoutPadding().encodeToString(publicKey)); + jwk.put("kid", kid); jwk.put("cycles_nbf_ms", nbfMs); - // cycles_exp_ms omitted ⇒ active (open-ended); `status` is advisory only — - // selection is by validity window, never by status. - jwk.put("status", "active"); - - Map jwks = new LinkedHashMap<>(); - jwks.put("keys", List.of(jwk)); - return Optional.of(jwks); + if (expMs != null) { + jwk.put("cycles_exp_ms", expMs); + } + // `status` is advisory only — selection is by validity window, never by status. + jwk.put("status", status); + return jwk; } /** Stable default key id when none is configured: the first 16 hex chars of diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/main/resources/application.properties b/cycles-protocol-service/cycles-protocol-service-api/src/main/resources/application.properties index 110010f..3d92495 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/main/resources/application.properties +++ b/cycles-protocol-service/cycles-protocol-service-api/src/main/resources/application.properties @@ -88,3 +88,10 @@ cycles.evidence.signing.signer-did=${EVIDENCE_SIGNING_SIGNER_DID:} # (epoch ms, inclusive; default 0 = valid since epoch, correct for a never-rotated key). cycles.evidence.signing.kid=${EVIDENCE_SIGNING_KID:} cycles.evidence.signing.nbf-ms=${EVIDENCE_SIGNING_NBF_MS:0} +# Retired signing keys retained in the published set (rotation history) so +# evidence signed before a rotation still verifies against the key valid at its +# issued_at_ms. JSON array of {"signer_did":,"kid":...,"nbf_ms":..., +# "exp_ms":...}; exp_ms (EXCLUSIVE end of the key's window) is required per entry. +# On rotation: set the new active key as signer-did and append the old one here +# with exp_ms = the rotation time. Empty = single active key (never rotated). +cycles.evidence.signing.retired-keys=${EVIDENCE_SIGNING_RETIRED_KEYS:} diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java index 8542777..83cb953 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java +++ b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java @@ -18,6 +18,9 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import java.util.List; +import java.util.Map; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -74,7 +77,7 @@ void unconfiguredSigner_throwsNotFound() { // Direct construction (no key configured) — the endpoint 404s via the // standard NOT_FOUND ErrorResponse path; a server not doing signer-key // resolution publishes nothing. - JwksController controller = new JwksController("", "", 0L); + JwksController controller = new JwksController("", "", 0L, ""); assertThatThrownBy(controller::getEvidenceJwks) .isInstanceOf(CyclesProtocolException.class); } @@ -82,15 +85,59 @@ void unconfiguredSigner_throwsNotFound() { @Test void didCyclesSigner_throwsNotFound() { JwksController controller = new JwksController( - "did:cycles:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08#k", "", 0L); + "did:cycles:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08#k", "", 0L, ""); assertThatThrownBy(controller::getEvidenceJwks) .isInstanceOf(CyclesProtocolException.class); } @Test void configuredSigner_returnsBodyDirectly() { - JwksController controller = new JwksController(SIGNER_DID, "k1", 5L); + JwksController controller = new JwksController(SIGNER_DID, "k1", 5L, ""); assertThat(controller.getEvidenceJwks().getStatusCode().value()).isEqualTo(200); assertThat(controller.getEvidenceJwks().getBody()).containsKey("keys"); } + + @Test + @SuppressWarnings("unchecked") + void retiredKeysJson_publishesActivePlusRetiredWithWindows() { + String retired = "[{\"signer_did\":\"" + "ab".repeat(32) + "\",\"kid\":\"2025-h2\"," + + "\"nbf_ms\":0,\"exp_ms\":1700000000000}]"; + JwksController controller = new JwksController(SIGNER_DID, "2026-06", 1700000000000L, retired); + Map body = controller.getEvidenceJwks().getBody(); + List> keys = (List>) body.get("keys"); + assertThat(keys).hasSize(2); + assertThat(keys.get(0)).containsEntry("kid", "2026-06").containsEntry("status", "active"); + assertThat(keys.get(0)).doesNotContainKey("cycles_exp_ms"); // active = open-ended + assertThat(keys.get(1)).containsEntry("kid", "2025-h2").containsEntry("status", "retired") + .containsEntry("cycles_exp_ms", 1700000000000L); + } + + @Test + @SuppressWarnings("unchecked") + void malformedRetiredKeysJson_stillPublishesActiveKey() { + // A bad retired-keys config must never break publication of the active key. + JwksController controller = new JwksController(SIGNER_DID, "2026-06", 0L, "{not valid json"); + Map body = controller.getEvidenceJwks().getBody(); + assertThat((List>) body.get("keys")).hasSize(1); + } + + @Test + @SuppressWarnings("unchecked") + void retiredKeyWithMissingNbf_isSkipped() { + // A missing/non-integral nbf_ms must NOT be coerced to epoch 0 (which would + // silently widen the window) — the retired entry is dropped, active still publishes. + String retired = "[{\"signer_did\":\"" + "ab".repeat(32) + "\",\"kid\":\"no-nbf\",\"exp_ms\":100}]"; + JwksController controller = new JwksController(SIGNER_DID, "2026-06", 0L, retired); + Map body = controller.getEvidenceJwks().getBody(); + assertThat((List>) body.get("keys")).hasSize(1); + } + + @Test + @SuppressWarnings("unchecked") + void nonArrayRetiredKeysJson_isIgnored() { + // Valid JSON but not an array → ignored, active key still published. + JwksController controller = new JwksController(SIGNER_DID, "2026-06", 0L, "{\"oops\":true}"); + Map body = controller.getEvidenceJwks().getBody(); + assertThat((List>) body.get("keys")).hasSize(1); + } } diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/evidence/JwksDocumentsTest.java b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/evidence/JwksDocumentsTest.java index 383ed10..4597ab9 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/evidence/JwksDocumentsTest.java +++ b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/evidence/JwksDocumentsTest.java @@ -90,10 +90,85 @@ void isRawHexKey_acceptsTrimmedAndMixedCase() { assertThat(JwksDocuments.isRawHexKey(" " + RAW_HEX.toUpperCase() + " ")).isTrue(); } + // ── Retired-key rotation history ── + private static final String RETIRED_HEX = + "ec52b49b81eb29ef6f62947cade245c715bf943b7ef2a5f2789288574466fc43"; + + @Test + void retiredKeys_appendedWithBoundedWindowAndRetiredStatus() { + List> keys = allKeys(JwksDocuments.jwkSet( + RAW_HEX, "2026-06", 1700000000000L, + List.of(new JwksDocuments.RetiredKey(RETIRED_HEX, "2025-h2", 0L, 1700000000000L)))); + assertThat(keys).hasSize(2); + assertThat(keys.get(0)).containsEntry("kid", "2026-06").containsEntry("status", "active") + .doesNotContainKey("cycles_exp_ms"); + assertThat(keys.get(1)).containsEntry("kid", "2025-h2").containsEntry("status", "retired") + .containsEntry("cycles_nbf_ms", 0L).containsEntry("cycles_exp_ms", 1700000000000L); + } + + @Test + void retiredKey_withNullExp_isSkipped() { + // A retired key needs a closed window; a null exp is a config error. + List> keys = allKeys(JwksDocuments.jwkSet( + RAW_HEX, "k", 0L, + List.of(new JwksDocuments.RetiredKey(RETIRED_HEX, "no-exp", 0L, null)))); + assertThat(keys).hasSize(1); // active only + } + + @Test + void retiredKey_malformedHex_isSkipped() { + List> keys = allKeys(JwksDocuments.jwkSet( + RAW_HEX, "k", 0L, + List.of(new JwksDocuments.RetiredKey("not-hex", "bad", 0L, 100L)))); + assertThat(keys).hasSize(1); + } + + @Test + void retiredKey_duplicateKid_isSkipped() { + // kid MUST be unique set-wide; a retired key colliding with the active + // key's kid is dropped (never emit a duplicate kid). + List> keys = allKeys(JwksDocuments.jwkSet( + RAW_HEX, "dup", 0L, + List.of(new JwksDocuments.RetiredKey(RETIRED_HEX, "dup", 0L, 100L)))); + assertThat(keys).hasSize(1); + assertThat(keys.get(0)).containsEntry("status", "active"); + } + + @Test + void retiredKey_emptyOrInvertedWindow_isSkipped() { + // cycles_exp_ms is EXCLUSIVE, so exp == nbf is an empty window and + // exp < nbf is inverted; neither is publishable. + assertThat(allKeys(JwksDocuments.jwkSet(RAW_HEX, "k", 0L, + List.of(new JwksDocuments.RetiredKey(RETIRED_HEX, "empty", 100L, 100L))))).hasSize(1); + assertThat(allKeys(JwksDocuments.jwkSet(RAW_HEX, "k", 0L, + List.of(new JwksDocuments.RetiredKey(RETIRED_HEX, "inverted", 200L, 100L))))).hasSize(1); + } + + @Test + void retiredKey_sameMaterialAsActiveKey_isSkipped() { + // Republishing the active key's own bytes with a bounded window would make + // the window covering an issued_at_ms ambiguous — drop it (case-insensitive). + List> keys = allKeys(JwksDocuments.jwkSet( + RAW_HEX, "active", 0L, + List.of(new JwksDocuments.RetiredKey(RAW_HEX.toUpperCase(), "dup-material", 0L, 100L)))); + assertThat(keys).hasSize(1); + assertThat(keys.get(0)).containsEntry("status", "active"); + } + + @Test + void emptyRetiredList_isSingleActiveKey() { + assertThat(allKeys(JwksDocuments.jwkSet(RAW_HEX, "k", 0L, List.of()))).hasSize(1); + assertThat(allKeys(JwksDocuments.jwkSet(RAW_HEX, "k", 0L, null))).hasSize(1); + } + private static Map firstKey(Optional> set) { + return allKeys(set).get(0); + } + + private static List> allKeys(Optional> set) { assertThat(set).isPresent(); @SuppressWarnings("unchecked") List> keys = (List>) set.get().get("keys"); - return keys.get(0); + return keys; } } diff --git a/cycles-protocol-service/pom.xml b/cycles-protocol-service/pom.xml index fe8e8f2..186fd28 100644 --- a/cycles-protocol-service/pom.xml +++ b/cycles-protocol-service/pom.xml @@ -18,7 +18,7 @@ cycles-protocol-service-api - 0.1.25.32 + 0.1.25.33 21 21 21 From 4937367c912f78d96cbae18fc7f7ade0250d18c9 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Thu, 18 Jun 2026 07:57:37 -0400 Subject: [PATCH 2/6] docs(audit): condense + one-per-line the CyclesEvidence changelog entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CyclesEvidence run (the getEvidenceJwks integration test, v0.1.25.22 through .33, and the two WIP entries) had been crammed onto a single physical line as one run-on paragraph, and each entry was a 130-360 word wall of nested parentheticals, ALL-CAPS, and review-round play-by-play — inconsistent with the clean ~110-170 word prose entries from v0.1.25.21 down. - Split the run-on line so each entry sits on its own line, matching the format used for v0.1.25.20 and below. - Rewrite each CyclesEvidence entry to the established compact voice: flowing prose, one level of parentheses, no ALL-CAPS, a simple closing test/build line. Every entry's version, what-changed, why, and test outcome are preserved; only the verbosity is cut. Docs-only; no code, spec, or wire change. --- AUDIT.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/AUDIT.md b/AUDIT.md index e638f2a..f7efcb8 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -1,6 +1,21 @@ # Cycles Protocol v0.1.25 — Server Implementation Audit -**Date:** 2026-06-18 (v0.1.25.33 — CyclesEvidence signer-key resolution: retired-key rotation history in the published JWK Set (the v0.2-store follow-up flagged in v0.1.25.32). `JwksController` gains a fourth `@Value` `cycles.evidence.signing.retired-keys` (env `EVIDENCE_SIGNING_RETIRED_KEYS`) — a JSON array of `{signer_did (raw 64-hex), kid, nbf_ms, exp_ms}` — parsed via a LOCAL `ObjectMapper` (no injected bean, preserving `@WebMvcTest` loadability) into `JwksDocuments.RetiredKey` records; fail-safe to empty on malformed/non-array config (the active key still publishes — never crashes). `JwksDocuments.jwkSet` gains a 4-arg overload (the old 3-arg delegates with `List.of()`) that appends each retired key AFTER the active one as a bounded-window JWK — `status:retired` carrying `cycles_exp_ms` (EXCLUSIVE window end, REQUIRED for a retired key) — so evidence signed before a rotation still verifies against the key whose `[cycles_nbf_ms, cycles_exp_ms)` covers its `issued_at_ms`. Defensive skips (logged, never fatal): malformed hex; null `exp_ms`; an empty/inverted window (`exp_ms <= nbf_ms`, since `cycles_exp_ms` is EXCLUSIVE); a missing/non-integral `nbf_ms` (NOT coerced to epoch 0 — that would silently widen the validity window; the entry is dropped in `parseRetiredKeys`); the SAME key material as the active key (re-publishing the active `x` with a bounded window would make the window covering an `issued_at_ms` ambiguous); or a `kid` colliding with an already-emitted key (set-wide kid uniqueness — never emit a duplicate kid). Rotation procedure: set the new key as `signer-did` active, append the old one here with `exp_ms` = the rotation time. NO spec change (the published `CyclesEvidenceJwks` schema already supports multi-key + windows + `status:retired`) and NO wire change to existing endpoints. Tests: `JwksDocumentsTest` +7 (retired appended w/ bounded window + retired status, null-exp skipped, malformed-hex skipped, duplicate-kid skipped, empty/inverted-window skipped, same-material-as-active skipped, empty/null list = single active key), `JwksControllerTest` +4 (active+retired publication with windows, malformed JSON still publishes the active key, missing-nbf skipped, non-array config ignored); both `JwksDocuments` and `JwksController` 100% line-covered (codex review: 3 Medium window-validation findings applied — required-integral nbf, empty/inverted window guard, active/retired overlap guard). Full `mvn verify` green; jacoco 95% gate met. SCOPE: publication of operator-configured rotation history only — Redis-backed auto-rotation mechanics and `did:cycles`-form `signer_did` producer stamping remain separate follow-ups.), 2026-06-15 (getEvidenceJwks live-serving integration test — `JwksEndpointIntegrationTest` (extends `BaseIntegrationTest`: full `@SpringBootTest` RANDOM_PORT, real Tomcat + the Spring Security filter chain ACTIVE, Testcontainers Redis) proves the JWK Set endpoint serves over real HTTP WITHOUT an API key — i.e. the `/v1/.well-known/**` public-path exemption actually holds end-to-end through the filter chain, not just as an array entry (the `JwksControllerTest` `@WebMvcTest` runs with filters disabled, so it can't show this). With the evidence signing identity configured via `@TestPropertySource`, GET `/v1/.well-known/cycles-jwks.json` with no header → 200 + a JWK whose `x` decodes to exactly the configured `signer_did` bytes, correct `kid`/`cycles_nbf_ms`/`status`, and `Cache-Control: public, max-age` (NOT immutable); a bogus API key still yields 200 (public, never 401). The base class's contract-validating interceptor additionally checks the body against the published `CyclesEvidenceJwks` schema (cycles-protocol@main, #113). 2 tests; test-only (no production/wire/spec change; the impl shipped in v0.1.25.32 / #194).), 2026-06-15 (v0.1.25.32 — CyclesEvidence signer-key resolution, publication half: implement `getEvidenceJwks` (`GET /v1/.well-known/cycles-jwks.json`, per `cycles-protocol-v0.yaml` v0.1.25.6 / runcycles/cycles-protocol#113, the v0.2 additive layer designed on #103 / aeoess#43). `JwksController` (public; reads the shared `cycles.evidence.signing.signer-did` + new `cycles.evidence.signing.kid` / `nbf-ms` via `@Value`, holds no injected bean so it loads in every `@WebMvcTest` without extra wiring) serves the JWK Set built by the pure `JwksDocuments.jwkSet(signerDid, kid, nbfMs)`: when `signer-did` is a raw 64-hex key it emits one active Ed25519 OKP JWK — `{kty:OKP, crv:Ed25519, alg:EdDSA, x:base64url(hex-decode(signer_did)), kid (default = first 16 hex of the key), cycles_nbf_ms (default 0), status:active}`, `cycles_exp_ms` omitted ⇒ open-ended; the JWK `x` is the SAME 32 raw bytes `EnvelopeSigner` signs with, so a verifier resolving this set authenticates the emitted signatures. Served with `Cache-Control: public, max-age=300` (NOT immutable — a key set rotates, unlike a content-addressed envelope). 404s via the standard NOT_FOUND `ErrorResponse` when no raw-hex key is configured (evidence off, or a `did:cycles` `signer_did` which carries no key bytes — that, plus retired-key rotation history, is the v0.2-store follow-up); consumers then stay on the raw-hex + `expected_signer` pinning (`binding_only`) path. PUBLIC: `/v1/.well-known/**` added to `SecurityConfig.PUBLIC_PATHS` — public keys only, the private key is never served, and the set is itself the trust anchor. API-base-relative (under `/v1`), per the spec's authority-scope rule (the `did:cycles` hash covers `server_id` WITH its path). Tests: `JwksDocumentsTest` (10 — raw-hex JWK shape, `x` round-trips to the key bytes, default/override kid, nbf carry-through, blank/null/did:cycles/malformed → empty), `JwksControllerTest` (4 — 200 + JWK-set body + short public non-immutable cache via `@WebMvcTest` contract-validated against #113's spec, unconfigured/did:cycles → NOT_FOUND, direct 200 body), `SecurityConfigTest` updated for the new public path. Both new classes 100% line-covered; full `mvn verify` 906 tests green; jacoco 95% gate met. No change to existing endpoints/wire.), 2026-06-14 (v0.1.25.31 — review fix [Medium]: `POST /v1/reservations` re-emitted side-effect events on an idempotent REPLAY. `ReservationController.create` emitted the `RESERVATION_DENIED` deny event and `emitBalanceEvents` (budget-state transitions) unconditionally, while `decide` (`DecisionController`) and `commit`/`release` (`ReservationController`) already skip side-effect emission when `response.isIdempotentReplay()`. A replay (a cached dry-run DENY, or a reserve body-cache replay carrying `idempotentReplay=true`) therefore double-counted those events — the original create already emitted them. FIX: wrap create's whole emission block in `if (!response.isIdempotentReplay())`, matching the other lifecycle endpoints. Test: `ReservationControllerTest` +1 (`shouldNotReemitEventsOnIdempotentReplay` — an idempotent DENY replay re-emits neither `RESERVATION_DENIED` nor balance events). No wire/spec change. NOTE: numbered .31 because v0.1.25.30 is held by the open byte-parity PR #187; merge #187 first.), 2026-06-14 (v0.1.25.30 — byte-parity hardening: extend `EvidenceIdComputerTest`'s golden-fixture coverage from the 3 reserve fixtures to the FULL 13-fixture set (all five artifact types — decide/reserve/commit/release/error), the SAME `cycles-evidence-fixtures` the event-tier `CyclesEvidenceCanonicalizer` verifies against and the APS verifier was generated with (the 10 added fixtures are byte-identical copies from cycles-server-events). This proves cycles-server's SYNCHRONOUS `evidence_id` computation reproduces the canonical id for EVERY artifact SHAPE — `reservation_id` hoisting on commit/release, `endpoint`+`http_status` on error — i.e. the JCS+sha256 recipe is byte-correct across the whole envelope domain, so whenever cycles-server emits a given shape its id matches the worker and the cross-check never dead-letters on drift. (Recipe parity over the canonical/verifier fixture set, NOT the set of shapes cycles-server emits: its emission policy is the budget/lifecycle-denial subset per `GlobalExceptionHandler.EVIDENCE_DENIAL_CODES`, so the error envelope it actually emits is the budget-exceeded kind — `11-reserve-live-budget-exceeded` — while `12-decide-live-forbidden` is a verifier-relevant FORBIDDEN shape cycles-server does NOT emit, included only to prove the computer handles it.) Test-only (no production change): `@ValueSource` 3→13 fixtures; `EvidenceIdComputerTest` 5→15 tests. Full `mvn verify` 431 data + 196 api green; jacoco 95% gate met.), 2026-06-13 (v0.1.25.29 — include the OPTIONAL `request` body in `error` CyclesEvidence (review Minor: cycles-evidence-v0.1 `ErrorPayload.request` SHOULD be present for a full audit trail unless redaction applies; v0.1.25.28 always omitted it). The four core controllers (`create`/`commit`/`release`/`decide`) stash their parsed `@RequestBody` DTO under the `GlobalExceptionHandler.EVIDENCE_REQUEST_ATTRIBUTE` request attribute immediately after binding; `GlobalExceptionHandler` includes it as the `error` payload's `request` field when present (typed DTO → Jackson → JCS-canonical). Present for any post-binding denial on the four core endpoints; absent for pre-binding failures (validation/auth) and non-instrumented routes — a future redaction policy MAY drop it. No wire change to `ErrorResponse` (the `request` lives only inside the evidence payload). The `error` payload is now `{endpoint, http_status, [reservation_id], [request], response}`, completing the draft `ErrorPayload` shape. REVIEW FIX (codex, High — applies to ALL request-bearing artifacts, not just error): the request/response DTOs serialized into evidence payloads can emit null-valued properties (`ReservationCreateRequest.ttl_ms`/`overage_policy`/`metadata`, `CommitRequest.metrics`/`metadata`, `ReleaseRequest.reason` — none carry `@JsonInclude(NON_NULL)`, and the shared `ObjectMapper` does not omit nulls), but the cycles-evidence-v0.1 mirror schemas are `additionalProperties:false` with non-nullable typed fields, so a serialized `null` makes the signed envelope fail mirror validation. This latent defect already affected the merged reserve/commit/release success-path evidence (each `put("request", request)`), not only the new error path. Fix is CENTRALIZED in `EvidenceEmitter`: a private NON_NULL copy of the shared mapper (`evidencePayloadMapper`, lazily derived) null-strips the payload ONCE into a tree used for BOTH the `evidence_id` computation and the queued record, so cycles-server and the event-tier worker agree byte-for-byte. The DTOs and the shared mapper are deliberately NOT changed — the same DTO serialization backs idempotency payload hashes, reserve.lua args, and cached response bodies, so altering it would break those. Byte-parity fixtures unaffected (their requests carry no nulls). Tests: `GlobalExceptionHandlerTest` +2 (stashed-request-included, absent-request-omitted) + `ReservationControllerTest` +1 (end-to-end `@WebMvcTest`: a non-dry reserve denial through the controller stashes the request DTO, emits `error` evidence with `request`+`endpoint`, and surfaces `cycles_evidence` on the 409) + `EvidenceEmitterTest` +2 (null-valued payload properties stripped; `evidence_id` computed over the null-stripped payload). codex round 1 SHIP, round 2 (High) fixed. Full `mvn verify` 421 data + 196 api green; full integration suite 428 api green; jacoco 95% gate met.), 2026-06-13 (v0.1.25.28 — CyclesEvidence fan-out to the `error` artifact, COMPLETING the lifecycle binding loop (decide/reserve/commit/release/error) (per cycles-protocol v0.1.25.5 / runcycles/cycles-protocol#109). `ErrorResponse` gains optional `cycles_evidence` (`CyclesEvidenceRef`). `GlobalExceptionHandler` (the central `CyclesProtocolException` handler) now emits an `error` CyclesEvidence source record over `{endpoint, http_status, response}` (+ `reservation_id` hoisted for the commit/release endpoints, so evidence-only readers can reconstruct the authorization→settlement chain) and stamps the returned ref onto the `ErrorResponse` — but ONLY for budget/lifecycle DENIAL codes raised on the four core endpoints: the budget denials (`BUDGET_EXCEEDED`, `BUDGET_FROZEN`, `BUDGET_CLOSED`, `OVERDRAFT_LIMIT_EXCEEDED`, `DEBT_OUTSTANDING`, `UNIT_MISMATCH`) AND the reservation terminal-state denials on commit/release (`RESERVATION_FINALIZED` 409, `RESERVATION_EXPIRED` 410 — settling an already-finalized/expired reservation; reservation_id is hoisted for these so the authorization→settlement-denial chain is reconstructable) (`POST /v1/decide` | `/v1/reservations` | `.../commit` | `.../release`, matched via `HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE` + method; `reservation_id` from `URI_TEMPLATE_VARIABLES_ATTRIBUTE`). The non-dry reserve 409 `BUDGET_EXCEEDED` is the highest-signal denial APS receipts bind to — the canonical wire shape per §ReservationCreateResponse.decision (NOT a 200 DENY). Pre-evaluation failures (validation/auth/malformed/not-found/idempotency-mismatch) and non-core routes (e.g. extend) emit NOTHING — no decision was made, nothing to attest; matches the spec's `cycles_evidence` "absent for errors raised before evidence could be emitted". The ref is stamped AFTER `evidence_id` is computed over the pre-stamp response (`ErrorResponse` is `@JsonInclude(NON_NULL)`, so the attested `payload.error.response` omits `cycles_evidence` — non-self-referential), and emission is fail-open via `EvidenceEmitter` (a push/identity failure never fails the error response). `GlobalExceptionHandler` switches to constructor injection of `EvidenceEmitter`. REVIEW FIXES (codex): (Low) a commit/release denial whose `reservation_id` path var is somehow absent now SKIPS emission rather than emit a spec-invalid `error` payload missing the required `reservation_id`. (Medium) RESERVATION_FINALIZED/RESERVATION_EXPIRED were initially excluded from the allowlist — they route through `handleScriptError` on commit/release and are genuine settlement-path denials (the spec's `ErrorResponseMirror` enum already includes both, and reservation_id hoisting exists for exactly this chain), so they are now included; the doc states the governing principle (a code belongs iff it is a server DECISION on a core endpoint; pre-evaluation validation/auth/not-found/idempotency-mismatch stay excluded). Tests: `GlobalExceptionHandlerTest` +9 (reserve-denial emit+stamp with payload-shape assertions, commit-denial `reservation_id` hoist, commit-FINALIZED + release-EXPIRED terminal-state emit with reservation_id, unconfigured-emitter-omits-ref, pre-evaluation-no-emit, no-matched-route-no-emit, extend-route-no-emit, commit-denial-without-reservation_id-no-emit); the five controller `@WebMvcTest`s gain `@MockitoBean EvidenceEmitter`. Full `mvn verify` 419 data + 193 api green; full integration suite 426 api green; jacoco 95% gate met. This is the LAST core-artifact fan-out — remaining evidence work is the `evidence.available` webhook + deploy/config of the shared identity env vars.), 2026-06-13 (v0.1.25.27 — CyclesEvidence fan-out to decide + generalized non-persisting idempotency machinery (per cycles-protocol v0.1.25.4 / runcycles/cycles-protocol#108). The `decide` artifact (pre-execution decision, no reservation) now emits CyclesEvidence over `{request, response}` (draft `DecidePayload`) and surfaces `cycles_evidence` on `DecisionResponse`. GENERALIZATION: the dry_run atomic-claim + wait machinery (from #181) is refactored into a shared `kind`-parameterized path serving both dry_run AND decide — `acquireIdempotencyClaim` (SET NX pending-claim), `waitForIdempotentReplayBody` (per-poll wait), `cacheIdempotentBody`, `clearIdempotencyClaim`, `idempotencyCacheKey`/`pendingPrefix`/`pendingMarker`/`isPending`/`validatePendingMarker`/`pendingPayloadHash`, and the `IdemClaim` record. Keys/markers are derived from `kind` so dry_run stays byte-identical (`idem::dry_run:`, `__dry_run_pending__:`) — its behavior + tests unchanged; decide uses `idem::decide:` + `__decide_pending__:`. decide() is restructured into an orchestrator (acquire → replay/pending/fresh; connections released before emit + wait, no pool nesting) + an extracted pure `evaluateDecisionBudget`; concurrent same-key decides now converge to ONE evaluation + ONE envelope (closes the duplicate-emit race, same bar as dry_run). `DecisionController` threads `trace_id`. `DecisionResponse` gains optional `cycles_evidence` + transient `idempotentReplay`. REVIEW FIXES (codex): (a) the decide orchestrator's outer catch now rethrows `CyclesProtocolException` and `RuntimeException` UNCHANGED and only wraps a remaining checked `Exception` — runtime failures propagate unwrapped (no double-wrap). (b) `cacheIdempotentBody` / `clearIdempotencyClaim` no longer require a caller-held connection — each self-acquires its own short-lived pooled `Jedis` and fails open on a Redis error (the dead `clearDecideClaimQuietly` helper removed); callers in both the dry_run and decide paths updated. (c) `DecisionController` guards the `RESERVATION_DENIED` event emission with `!response.isIdempotentReplay()` so a decide replay does not re-emit the original deny event. (d) POOL-NESTING on the DRY_RUN FAILURE PATH — `evaluateDryRun`'s catch previously cleared the pending claim via the self-acquiring `clearIdempotencyClaim` while the caller's evaluation `Jedis` was STILL checked out (peak 2 connections/req under concurrent failing dry-runs — the starvation pattern this work avoids elsewhere). Added a `clearIdempotencyClaim(Jedis, idemKey, marker)` overload that compare-and-deletes on the ALREADY-HELD connection; `evaluateDryRun`'s failure catch now uses it (a failing dry-run checks out exactly ONE pooled connection); the self-acquire overload delegates to it. decide was already correct (clears only after its eval connection closes) and is unchanged. Tests: `RedisReservationDecideEventTest` +4 (fresh decide stamp+cache, decide replay-verbatim-no-reemit, fresh-decide-releases-connection-before-emit `InOrder` guard, fresh-decide-clears-claim-on-eval-failure; the runtime-exception test asserts the original instance propagates unwrapped), `RedisReservationCrudTest` +2 (dry-run clear-eval-also-throws → original error still propagates; dry-run post-eval cache/clear pool-unavailable → still returns the decision) plus strengthened the existing dry-run eval-fail test with a `times(1).getResource()` no-nesting guard, `DecisionControllerTest` +2 (surface cycles_evidence; idempotent-replay DENY does NOT re-emit RESERVATION_DENIED); dry_run/commit/release/reserve tests unchanged + green. Full `mvn verify` 419 data + 184 api green; full integration suite 417 api green; jacoco 95% gate met. Forbidden/validation failures on /v1/decide remain `error`-artifact territory (next slice).), 2026-06-13 (v0.1.25.26 — CyclesEvidence fan-out to commit + release (per cycles-protocol v0.1.25.3 / runcycles/cycles-protocol#107). Extends the reserve evidence pattern to the rest of the budget lifecycle. `commit.lua`/`release.lua` now flag their idempotent-replay branch (`replay = true`) so Java distinguishes fresh from replay. `commitReservation`/`releaseReservation` gain a `traceId` overload (4-/5-arg; 3-/4-arg back-compat retained) and, on a FRESH terminal op, emit a `commit`/`release` CyclesEvidence source record over `{reservation_id, request, response}` (the draft `CommitPayload`/`ReleasePayload` shape; response AS-IS, before `cycles_evidence` is stamped so the attested body is non-self-referential), stamp the ref onto the response, and cache the full body at `commit:body:` / `release:body:` with a 30-DAY TTL matching the terminal reservation-hash TTL (`PEXPIRE 2592000000`). A REPLAY returns the cached body VERBATIM (per-poll connection wait, mirroring the #181 reserve hardening; the main connection is released before waiting), falling back to the Lua-rebuilt response (no evidence) on cache miss. `CommitResponse`/`ReleaseResponse` gain optional `cycles_evidence` + transient `idempotentReplay`. `ReservationController` threads `trace_id` and SKIPS commit event emission on a replay (the original already emitted; the cached body carries no internal event fields). Generic helpers `emitLifecycleEvidence` / `cacheLifecycleBody` / `readCachedLifecycleBody` shared across commit+release. Tests: `RedisReservationCommitReleaseTest` +4 (fresh commit/release stamp+cache, commit/release replay-verbatim-no-reemit), `ReservationControllerTest` +2 (commit surfaces cycles_evidence, commit replay skips overage event). REVIEW FIXES (#183): (a) POOL-NESTING — the fresh reserve/commit/release paths previously held the Lua connection while `emitLifecycleEvidence`→`EvidenceQueueRepository.push` checked out a SECOND pool connection (peak 2/req → pool starvation under load). All three now RELEASE the Lua connection before evidence emit + body-cache, and `cacheReserveResponse`/`cacheLifecycleBody` acquire their own short-lived connection (peak 1 at a time); the reserve path was affected identically and is fixed too. (b) AUDIT-REPLAY — the admin-release audit block is now guarded by `!response.isIdempotentReplay()`, so an admin retry of an already-released reservation no longer writes a second `audit:log` entry. Regression guards: `InOrder` tests assert the Lua connection is `close()`d BEFORE `evidenceEmitter.emit(...)` on all three fresh paths (reserve/commit/release), plus `adminReleaseReplayDoesNotReAudit`. Full `mvn verify` 411 data + 179 api green; full integration suite 414 api green; jacoco 95% gate met.), 2026-06-13 (v0.1.25.25 — CyclesEvidence idempotency-race hardening (runcycles/cycles-server#181, on the v0.1.25.24 centralized flow). Closes two concurrency races: (1) RESERVE replay landing in the window between reserve.lua writing its `idem::reserve:` mapping and `createReservation` writing the `reserve:body:` cache previously fell back to rebuilt current balances; the replay now POLLS the body cache (≤4 attempts × 25ms) before the rebuild fallback. (2) Concurrent fresh DRY_RUN with the same key could both evaluate → duplicate evidence emission + non-idempotent responses; now an atomic `SET … NX PX 60000` pending-claim elects one evaluator and losers wait (`waitForDryRunReplay`) for the winner's cached result. WAIT MECHANICS (addressing review): the wait loops acquire a FRESH Jedis per poll (try-with-resources inside the loop) rather than holding the request connection across `Thread.sleep`, and the budget is 4 attempts (~100ms, not 1s); the reserve replay path closes its main connection before waiting. A waiter that still finds no result returns 500 `INTERNAL_ERROR` ("retry with the same idempotency_key") — transient, resolves on client retry. CLEANUP: the pending claim is released via an atomic compare-and-delete Lua (`GET==marker then DEL`), and is cleared if the caller-side cache write fails-open or evaluation throws (`DryRunResult` carries claimKey/marker). Refactor: `createDryRunReservation` / `rebuildReserveReplay` / `DryRunResult.pending(...)` extracted; no wire/spec change. Tests: `RedisReservationCrudTest` +5 (reserve wait-then-replay, dry-run pending-wait-replay, claim-set-on-store, clear-on-cache-fail, clear-on-eval-fail); full `mvn verify` 407 data + 179 api green; full integration suite 412 api green incl. thundering-herd 3/3; jacoco 95% gate met.), 2026-06-12 (v0.1.25.24 — CyclesEvidence centralized into the reservation-creation flow (review: two more High findings on v0.1.25.23). ROOT CAUSE addressed: evidence had lived in `ReservationController` while idempotency/caching/TTL live in `RedisReservationRepository` — that split caused every prior finding. Evidence is now emitted + stamped + cached INSIDE `createReservation` (the idempotent unit); `EvidenceEmitter` is injected into the repository and `trace_id` is threaded via a new `createReservation(request, tenant, traceId)` overload (2-arg back-compat retained). (1) BODY-CACHE TTL now matches reserve.lua's idempotency mapping — `max(ttl_ms + grace_ms, 86400000)` (was a fixed 24h, which expired before the Lua key for reservations whose tenant `max_reservation_ttl_ms` exceeds 24h, dropping replay back to rebuilt current balances). (2) DRY_RUN now EMITS `reserve` evidence for ALL outcomes (ALLOW and the early DENYs) per spec-authority — `drafts/cycles-evidence-v0.1.yaml` carries the `03-reserve-dry-run-deny` golden fixture and states dry-run ALLOW/DENY are captured as `reserve` evidence (a dry-run DENY is the canonical signed "would this be allowed?" attestation; non-dry denials are 409→`error` artifact). Reverses the v0.1.25.23 suppression. Dry-run evidence is stamped before caching the body under `idem::dry_run:` (now covers DENY outcomes too, making them idempotent), replayed verbatim. `ReservationController` no longer references `EvidenceEmitter`/caching — it just returns the response. Tests: repository gains fresh-reserve evidence+TTL (incl. 48h long-lived → 48h+grace, not 24h) + cache-hit verbatim replay; `ReservationControllerTest` simplified to pass-through (cycles_evidence present/absent + 3-arg threading); full `mvn verify` 402 data + 179 api green; full integration suite 412 api green incl. the thundering-herd idempotency test (3/3). jacoco 95% gate met.), 2026-06-12 (v0.1.25.23 — CyclesEvidence idempotent-replay-body fix (review: two High findings on v0.1.25.22). (1) Reserve replays now return the ORIGINAL full payload: a fresh non-dry-run create stamps `cycles_evidence` then caches the WHOLE response via `RedisReservationRepository.cacheReserveResponse` keyed by `reserve:body:` (NOT idempotency_key — so it can't go stale across an idem-key expiry + re-reserve, and never collides with reserve.lua's own `idem::reserve:` mapping; 24h TTL); the Lua-idempotency-hit path reads that key by the reservation_id the Lua returns and replays the body VERBATIM (original balances + original `cycles_evidence`, so the body matches the envelope the `evidence_id` points to), falling back to rebuild-from-hash only when the cache is absent. Replaces the v0.1.25.22 `persistEvidenceRef`/`storedEvidence*` ref-only approach — which left the rebuilt body's balances drifting from the envelope, still violating the NORMATIVE "return the original successful response payload" rule (cycles-protocol-v0.yaml IDEMPOTENCY). Also fixes the pre-existing balance-drift-on-replay bug. (2) dry_run reserves NO LONGER emit/surface evidence at all: a dry_run neither persists a reservation nor changes any budget, so there is nothing to attest or bind a receipt to — and the dry_run idempotency cache (cached pre-evidence) previously caused every replay to re-emit + recompute a fresh `evidence_id`. The controller now suppresses evidence when `dry_run=true` or `idempotentReplay`. Model: `ReservationCreateResponse` drops `storedEvidence*`, keeps transient `idempotentReplay`. Tests: `ReservationControllerTest` reworked (fresh-surface+cache, replay-verbatim-no-reemit, replay-omit-when-absent, dry-run-no-evidence, emitter-null-still-caches), `RedisReservationCrudTest` +3 (cache-hit verbatim replay, cache write, no-cache-without-key). 402 data + 181 api tests, jacoco 95% gate met.), 2026-06-12 (v0.1.25.22 — CyclesEvidence — synchronous `evidence_id` + `cycles_evidence` on the reserve response — WIP on `feat/evidence-id-sync`, closing the APS binding loop. `EvidenceIdComputer` (`@Component`) reproduces the `cycles-evidence/v0.1` content-hash recipe (RFC 8785 JCS via erdtman + sha256 over the envelope with `evidence_id`/`signature` emptied) byte-for-byte — proven against the 3 reserve golden fixtures the event-tier worker and APS verifier use. `EvidenceEmitter.emit` now computes the id SYNCHRONOUSLY when the PUBLIC identity (`cycles.evidence.server-id` + `cycles.evidence.signing.signer-did` — property names + env vars SHARED with the event-tier worker, so one var configures both; review finding: Low) is configured, stamps it on the source record (for the worker's cross-check) and returns `EvidenceRef {evidence_id, cycles_evidence_url}`; unconfigured → null (record still queued, no id). `cycles_evidence_url` = `{server_id}/evidence/{evidence_id}` — `server_id` already carries the `/v1` base, so no `/v1` double-prefix. `ReservationController.create` stamps `cycles_evidence` on the response AFTER the id is computed, so the attested `payload.reserve.response` never carries the ref (content hash stays non-self-referential). New `CyclesEvidenceRef` model; `ReservationCreateResponse` gains optional `cycles_evidence` (`@JsonInclude(NON_NULL)` — additive, non-breaking per the spec EVOLUTION CONTRACT). IDEMPOTENT (review finding: High): a fresh reserve computes + emits ONCE and persists the ref on the reservation hash (`persistEvidenceRef` → HSET `evidence_id`/`cycles_evidence_url`); an idempotent replay returns that stored ref VERBATIM (transient `idempotentReplay`/`storedEvidence*` on the response) and NEVER recomputes (replay balances reflect current state → would drift to a different `evidence_id`) or re-emits — honouring the reserve "return the original successful response" rule. Per cycles-protocol v0.1.25.1 (#105) + v0.1.25.2 (#106). Tests: `EvidenceIdComputer` x5 (golden-fixture byte-parity), `EvidenceEmitter` +5 (configured/unconfigured/url-join), `ReservationControllerTest` +4 (fresh-surface+persist, replay-verbatim-no-reemit, replay-omit-when-unpersisted, omit). Build needs the serving controller (#176) on main for `getEvidence` spec-coverage. Fan-out to decide/commit/release follows.), 2026-06-12 (CyclesEvidence serving endpoint — WIP on `feat/evidence-serving-impl`. Implements `getEvidence` (`GET /v1/evidence/{evidence_id}`, per `cycles-protocol-v0.yaml` revision 2026-06-12 / runcycles/cycles-protocol#104): `EvidenceController` reads the shared store via `EvidenceStoreReader` (`GET evidence:envelope:`, same `cycles.evidence.store.key-prefix` as the event-tier writer) and serves the signed envelope VERBATIM (bytes, `application/json`) with `Cache-Control: public, immutable`; `404` (`CyclesProtocolException.notFound`) when absent; `@Pattern` 64-hex `evidence_id` → `400`. PUBLIC: `/v1/evidence/**` added to `SecurityConfig.PUBLIC_PATHS` (no API key — capability-URL, per the spec). Tests: `EvidenceControllerTest` (standalone, 200 verbatim + immutable-cache, 404), `EvidenceStoreReaderTest` (get-by-key); the four controller `@WebMvcTest`s gain `@MockitoBean EvidenceStoreReader` (they load all controllers via `ContractValidationConfig`). `OpenApiContractDiffTest` passes against the #104 spec (validated locally via `-Dcontract.spec.url`); CI goes green once #104 merges to main. No change to existing endpoints.), 2026-06-12 (CyclesEvidence source emission, reserve endpoint — WIP on `feat/evidence-emit-reserve`, the producer half of the dedicated-channel emitter. `EvidenceQueueRepository` (LPUSH a source record to `evidence:pending`, key `cycles.evidence.queue.pending-key`) and `EvidenceEmitter` (`@Service`; stamps `artifact_type`, `issued_at_ms` (response time, matching the fixtures), `trace_id`, and the caller-supplied payload body — `server_id`/`signer_did` are added by the event-tier worker). DURABLE: the enqueue is SYNCHRONOUS — the source record is LPUSH'd to the same Redis as the just-committed ledger write before the response returns, so a committed op cannot return without its evidence queued (closes the async pre-Redis loss window; removes the unbounded-executor backlog/OOM risk). Fail-open: a push failure is logged + metered (`cycles.evidence.emit_failed` via `CyclesMetrics`) and never fails the already-committed response; expensive signing stays async in the event tier. `emit` takes the payload body because its shape varies by artifact (reserve/decide = `{request,response}`; commit/release add `reservation_id`; error = `{endpoint,http_status,request,response}`). Wired into `ReservationController.create` (covers ALLOW and DENY). Dedicated channel, not the webhook event stream. 4 tests (`EvidenceEmitter` x3 incl. sync-enqueue + fail-open, `EvidenceQueueRepository` LPUSH); `ReservationControllerTest` gains `@MockitoBean EvidenceEmitter`. No wire/spec change; fan-out to decide/commit/release + the error artifact follows.), 2026-05-22 (v0.1.25.21 — `expires_from`/`expires_to` and `finalized_from`/`finalized_to` ISO-8601 time-window filters on `GET /v1/reservations` per `cycles-protocol-v0.yaml` revision 2026-05-22 (runcycles/cycles-protocol#98); closes runcycles/cycles-server#162. Four new query params mirroring the v0.1.25.20 `from`/`to` shape: `expires_*` binds to `expires_at_ms` (required field, every row), `finalized_*` binds to `finalized_at_ms` (populated only on COMMITTED/RELEASED; ACTIVE and EXPIRED normatively excluded). Three windows compose with AND. `finalized_at_ms` added as an optional field on `ReservationSummary` so clients filtering with `finalized_*` can see the timestamp without a follow-up `getReservation` — strict-schema-compatible because the field is `@JsonInclude(NON_NULL)`. `FilterHasher` extends with four more `Long` args (10 → 14) using independent gated emission per pair — preserves byte-exact back-compat for v0.1.25.18 cursors (golden `2f397ea0e8fb53b7`) AND v0.1.25.20 cursors with from/to set (golden `ad7204d521cfd133`). `RedisReservationRepository.listReservations` signature 14 → 18 args. Two new predicate helpers (`expiresAtInWindow`, `finalizedAtInWindow`) applied in both legacy SCAN-cursor and sorted paths. Validation: each new pair `from > to` → 400; malformed values → 400 with distinct per-param message; blank strings treated as unset. 557 tests pass (384 data + 173 api), +19 vs v0.1.25.20.), +**Date:** 2026-06-18 (v0.1.25.33 — retired-key rotation history in the published CyclesEvidence JWK Set, the v0.2 signer-key-resolution follow-up to v0.1.25.32. A new `cycles.evidence.signing.retired-keys` property (env `EVIDENCE_SIGNING_RETIRED_KEYS`) takes a JSON array of `{signer_did, kid, nbf_ms, exp_ms}`; `JwksController` parses it fail-safe so bad config never stops the active key publishing, and `JwksDocuments.jwkSet` appends each retired key as a bounded `[cycles_nbf_ms, cycles_exp_ms)` JWK with `status: retired`, letting a verifier resolve the key that was valid at an envelope's `issued_at_ms` rather than only the current one. Retired entries are skipped when invalid: malformed hex, an absent or non-integral window bound, an empty or inverted window, key material matching the active key, or a duplicate `kid`. No spec or wire change — `CyclesEvidenceJwks` already allowed multiple keys with windows and a retired status. `JwksDocumentsTest` +7 and `JwksControllerTest` +4; full `mvn verify` green, 95% coverage gate met.), +2026-06-15 (getEvidenceJwks live-serving integration test — `JwksEndpointIntegrationTest` (full `@SpringBootTest` with real Tomcat, the Spring Security filter chain active, and Testcontainers Redis) proves the JWK Set endpoint serves over real HTTP with no API key, i.e. the `/v1/.well-known/**` public-path exemption holds end-to-end through the filter chain — something the filters-disabled `JwksControllerTest` `@WebMvcTest` can't show. With the signing identity configured, a no-header GET returns 200 and a JWK whose `x` decodes to the configured `signer_did` bytes, with the right `kid`/`cycles_nbf_ms`/`status` and a public, non-immutable cache; a bogus key still returns 200. 2 tests, test-only — the implementation shipped in v0.1.25.32 / #194.), +2026-06-15 (v0.1.25.32 — CyclesEvidence signer-key resolution, publication half: `getEvidenceJwks` (`GET /v1/.well-known/cycles-jwks.json`, per cycles-protocol v0.1.25.6 / runcycles/cycles-protocol#113). When `cycles.evidence.signing.signer-did` is a raw 64-hex key, the public `JwksController` serves a one-key JWK Set built by the pure `JwksDocuments.jwkSet` — an active Ed25519 OKP JWK whose `x` is the same 32 bytes `EnvelopeSigner` signs with, so a verifier resolving the set authenticates the emitted signatures — with a short public, non-immutable cache. It 404s when no raw-hex key is configured (evidence off, or a `did:cycles` signer that carries no key bytes); consumers then stay on the raw-hex `expected_signer` pinning path. `/v1/.well-known/**` is public (public keys only) and API-base-relative under `/v1` per the spec's authority-scope rule. `JwksDocumentsTest` (10) and `JwksControllerTest` (4); `mvn verify` 906 tests green.), +2026-06-14 (v0.1.25.31 — review fix [Medium]: `POST /v1/reservations` re-emitted side-effect events on an idempotent replay. `create` emitted the `RESERVATION_DENIED` event and balance-transition events unconditionally, while `decide`, `commit`, and `release` already skip them on a replay, so a replayed create double-counted them. Fixed by wrapping create's emission block in `if (!response.isIdempotentReplay())` to match the other endpoints. `ReservationControllerTest` +1. No wire/spec change. Numbered .31 because .30 is held by the open byte-parity PR #187 — merge that first.), +2026-06-14 (v0.1.25.30 — byte-parity hardening: extend `EvidenceIdComputerTest` from the 3 reserve fixtures to the full 13-fixture set covering all five artifact types, the same fixtures the event-tier canonicalizer and the APS verifier use. This proves cycles-server's synchronous `evidence_id` computation reproduces the canonical id for every envelope shape, so a given shape's id always matches the worker and the cross-check never dead-letters on drift. Test-only: `@ValueSource` 3→13, `EvidenceIdComputerTest` 5→15 tests; `mvn verify` green.), +2026-06-13 (v0.1.25.29 — include the optional `request` body in `error` CyclesEvidence (review Minor): the four core controllers stash their parsed request DTO in a request attribute, and `GlobalExceptionHandler` includes it in the `error` payload when present, completing the `{endpoint, http_status, [reservation_id], [request], response}` shape. Review fix (codex, High): request/response DTOs can serialize null-valued properties, but the evidence mirror schemas are `additionalProperties:false` with non-nullable fields, so a serialized null failed mirror validation — affecting the already-merged reserve/commit/release evidence too. Fixed centrally in `EvidenceEmitter` with a NON_NULL mapper that null-strips the payload once, used for both the `evidence_id` and the queued record; the shared mapper and DTOs are left unchanged so idempotency hashes and cached bodies stay byte-stable. `GlobalExceptionHandlerTest` +2, `ReservationControllerTest` +1, `EvidenceEmitterTest` +2; `mvn verify` green.), +2026-06-13 (v0.1.25.28 — CyclesEvidence fan-out to the `error` artifact, completing the lifecycle binding loop (decide/reserve/commit/release/error), per cycles-protocol v0.1.25.5 / #109. `GlobalExceptionHandler` emits an `error` source record over `{endpoint, http_status, response}` (plus a hoisted `reservation_id` for commit/release) and stamps the ref onto `ErrorResponse`, but only for budget and terminal-state denials raised on the four core endpoints — a code qualifies iff it is a server decision on a core endpoint. Pre-evaluation failures (validation, auth, not-found, idempotency mismatch) and non-core routes emit nothing, matching the spec's rule that `cycles_evidence` is absent for errors raised before evidence could be emitted. The ref is stamped after the id is computed, so the attested response stays non-self-referential, and emission is fail-open. `GlobalExceptionHandlerTest` +9; the five controller tests gain a mocked emitter; `mvn verify` green.), +2026-06-13 (v0.1.25.27 — CyclesEvidence fan-out to decide plus a generalized non-persisting idempotency path, per cycles-protocol v0.1.25.4 / #108. `decide` now emits evidence over `{request, response}` and surfaces `cycles_evidence`. The dry_run atomic-claim-and-wait machinery is refactored into a shared `kind`-parameterized path serving both dry_run and decide, with keys derived from `kind` so dry_run stays byte-identical; concurrent same-key decides converge to one evaluation and one envelope. Review fixes (codex): the orchestrator rethrows runtime exceptions unwrapped; the claim cache/clear helpers self-acquire a short-lived connection and fail open; decide replays don't re-emit the deny event; and a pool-nesting bug on the dry_run failure path is fixed with an already-held-connection clear overload. `RedisReservationDecideEventTest` +4, `RedisReservationCrudTest` +2, `DecisionControllerTest` +2; `mvn verify` green.), +2026-06-13 (v0.1.25.26 — CyclesEvidence fan-out to commit and release, per cycles-protocol v0.1.25.3 / #107, extending the reserve pattern across the budget lifecycle. `commit.lua`/`release.lua` flag their replay branch; on a fresh terminal op Java emits a `commit`/`release` record over `{reservation_id, request, response}`, stamps the ref, and caches the full body with a 30-day TTL matching the terminal reservation hash, replaying it verbatim on idempotent retry. Review fixes (#183): all three fresh paths (reserve/commit/release) release the Lua connection before evidence emit and body-cache so peak pool use stays at one connection, and the admin-release audit write is guarded against replay. `RedisReservationCommitReleaseTest` +4, `ReservationControllerTest` +2, plus `InOrder` regression guards; `mvn verify` green.), +2026-06-13 (v0.1.25.25 — CyclesEvidence idempotency-race hardening (#181). Closes two races: a reserve replay landing between reserve.lua's mapping write and the body-cache write now polls the body cache (≤4×25ms) before falling back to rebuilt balances; and concurrent fresh dry_runs with the same key now elect one evaluator via an atomic `SET NX` pending-claim while the losers wait, preventing duplicate evidence emission. The wait loops acquire a fresh connection per poll rather than holding the request connection across a sleep, and a waiter that still finds nothing returns a transient 500 that resolves on retry. The pending claim is released via an atomic compare-and-delete and cleared on cache-write or evaluation failure. `RedisReservationCrudTest` +5; `mvn verify` green incl. the thundering-herd test.), +2026-06-12 (v0.1.25.24 — CyclesEvidence centralized into the reservation-creation flow (review, two High findings). Evidence is now emitted, stamped, and cached inside `createReservation` — the idempotent unit — rather than in the controller, with `EvidenceEmitter` injected into the repository and `trace_id` threaded through a new overload. The body-cache TTL now matches reserve.lua's idempotency mapping (`max(ttl+grace, 24h)`) instead of a fixed 24h that expired early for long-lived reservations. dry_run now emits `reserve` evidence for all outcomes per spec authority (a dry-run DENY is the canonical signed "would this be allowed?" attestation), reversing the .23 suppression, and caches the body so DENY replays are idempotent. `mvn verify` 402 data + 179 api green incl. the thundering-herd test.), +2026-06-12 (v0.1.25.23 — CyclesEvidence idempotent-replay-body fix (review, two High findings). A fresh non-dry create now stamps `cycles_evidence` and caches the whole response keyed by `reserve:body:` (not the idempotency key, so it can't go stale across an idem-key expiry); the Lua idempotency-hit path replays that body verbatim, falling back to rebuild-from-hash only when the cache is absent. This replaces the .22 ref-only approach, whose rebuilt balances drifted from the envelope the `evidence_id` pointed to — violating the normative "return the original successful response" rule — and also fixes the pre-existing balance-drift-on-replay bug. dry_run no longer emits or surfaces evidence, since it persists nothing and changes no budget. `ReservationControllerTest` reworked, `RedisReservationCrudTest` +3; 402 data + 181 api tests green.), +2026-06-12 (v0.1.25.22 — synchronous `evidence_id` and `cycles_evidence` on the reserve response, closing the APS binding loop. `EvidenceIdComputer` reproduces the cycles-evidence/v0.1 content-hash recipe (RFC 8785 JCS + sha256 over the envelope with `evidence_id`/`signature` emptied) byte-for-byte, proven against the reserve golden fixtures. When the public identity is configured, `EvidenceEmitter` computes the id synchronously, stamps it on the source record for the worker's cross-check, and returns the ref; `ReservationController` stamps `cycles_evidence` on the response after the id is computed so the attested body stays non-self-referential. A fresh reserve computes and emits once and persists the ref on the reservation hash; an idempotent replay returns it verbatim and never recomputes, since replay balances would drift to a different id. Per cycles-protocol v0.1.25.1–.2. `EvidenceIdComputer` x5, `EvidenceEmitter` +5, `ReservationControllerTest` +4.), +2026-06-12 (CyclesEvidence serving endpoint — `getEvidence` (`GET /v1/evidence/{evidence_id}`, per cycles-protocol revision 2026-06-12 / #104). `EvidenceController` reads the shared store via `EvidenceStoreReader` and serves the signed envelope verbatim with a public, immutable cache; 404 when absent, 400 on a non-64-hex id. `/v1/evidence/**` is public (a capability URL, no API key). `EvidenceControllerTest`, `EvidenceStoreReaderTest`, and a mocked reader added to the four controller tests; no change to existing endpoints.), +2026-06-12 (CyclesEvidence source emission, reserve endpoint — the producer half of the dedicated-channel emitter. `EvidenceQueueRepository` LPUSHes a source record to `evidence:pending` and `EvidenceEmitter` stamps `artifact_type`, `issued_at_ms`, `trace_id`, and the artifact-specific payload (the worker adds `server_id`/`signer_did`). The enqueue is synchronous — the record is pushed to the same Redis as the committed ledger write before the response returns, so a committed op can't return without its evidence queued — and fail-open: a push failure is logged and metered but never fails the response, with signing left to the event tier. Wired into `create` for both ALLOW and DENY. 4 tests; `ReservationControllerTest` gains a mocked emitter. No wire/spec change.), +2026-05-22 (v0.1.25.21 — `expires_from`/`expires_to` and `finalized_from`/`finalized_to` ISO-8601 time-window filters on `GET /v1/reservations` per `cycles-protocol-v0.yaml` revision 2026-05-22 (runcycles/cycles-protocol#98); closes runcycles/cycles-server#162. Four new query params mirroring the v0.1.25.20 `from`/`to` shape: `expires_*` binds to `expires_at_ms` (required field, every row), `finalized_*` binds to `finalized_at_ms` (populated only on COMMITTED/RELEASED; ACTIVE and EXPIRED normatively excluded). Three windows compose with AND. `finalized_at_ms` added as an optional field on `ReservationSummary` so clients filtering with `finalized_*` can see the timestamp without a follow-up `getReservation` — strict-schema-compatible because the field is `@JsonInclude(NON_NULL)`. `FilterHasher` extends with four more `Long` args (10 → 14) using independent gated emission per pair — preserves byte-exact back-compat for v0.1.25.18 cursors (golden `2f397ea0e8fb53b7`) AND v0.1.25.20 cursors with from/to set (golden `ad7204d521cfd133`). `RedisReservationRepository.listReservations` signature 14 → 18 args. Two new predicate helpers (`expiresAtInWindow`, `finalizedAtInWindow`) applied in both legacy SCAN-cursor and sorted paths. Validation: each new pair `from > to` → 400; malformed values → 400 with distinct per-param message; blank strings treated as unset. 557 tests pass (384 data + 173 api), +19 vs v0.1.25.20.),, 2026-05-21 (v0.1.25.20 — `from` / `to` ISO-8601 time-window filter on `GET /v1/reservations` per cycles-protocol revision 2026-05-21; closes runcycles/cycles-server#159. Two new query params on `listReservations`, both `string`/`format: date-time`, both inclusive bounds on `created_at_ms`, both bind to `created_at_ms` regardless of `sort_by`. Implemented in both the legacy SCAN-cursor and sorted paths. `FilterHasher.hash(...)` now folds `fromMs`/`toMs` into the canonical hash so sorted-path cursors invalidate on window change (the legacy Redis-SCAN cursor is not window-validated, matching how it already treats every other filter). Validation: malformed values → 400, `from > to` → 400 before any repository call, blank strings treated as unset, missing/unparseable `created_at` rows defensively excluded when either bound supplied. Pure additive wire change — all v0.1.25.x clients that don't send the params continue to work byte-for-byte. 538 tests pass (375 data + 163 api).), 2026-05-21 (v0.1.25.19 — supply-chain CVE patch; re-pin `tomcat.version=10.1.55` in `cycles-protocol-service/pom.xml` to close 7 new CVEs flagged by Trivy against `tomcat-embed-core 10.1.54` (CRITICAL: CVE-2026-43512, CVE-2026-43515, CVE-2026-41293; HIGH: CVE-2026-43513, CVE-2026-42498, CVE-2026-41284; LOW: CVE-2026-43514 — all fixed in 10.1.55 / 11.0.22). Mirrors the v0.1.25.16 pattern; the override was dropped in v0.1.25.18 when SB 3.5.14's BOM caught up to 10.1.54, now re-added one patch higher because Trivy DB updates between 2026-05-11 (last green main run) and 2026-05-21 surfaced a new wave on the same artifact. Removable once Spring Boot ships with 10.1.55+ as its managed version. `commons-lang3.version=3.18.0` retained (CVE-2025-48924 still unfixed in SB 3.5.14's managed 3.17.0). No production code or test changes; all 537 protocol-service tests pass.), 2026-04-26 (v0.1.25.18 — dependency hygiene matching `cycles-server-events` v0.1.25.12: bump `spring-boot-starter-parent` 3.5.13 → 3.5.14 (patch with upstream security hardening — constant-time comparison for remote DevTools secret, `RandomValuePropertySource` SecureRandom, hostname verification applied consistently for Cassandra/RabbitMQ SSL, plus symlink-handling fixes); **drop `10.1.54` override** since Spring Boot 3.5.14's BOM now manages 10.1.54 directly (verified against `spring-boot-dependencies-3.5.14.pom`); commons-lang3 3.18.0 override retained — Spring Boot 3.5.14's BOM still manages 3.17.0. **Jedis 7.4.1 → 6.2.0** to align all three services on the same Redis client major (events at 6.2.0 since v0.1.25.12, admin at 6.2.0 in v0.1.25.41); all call sites use stable APIs (`Jedis`, `JedisPool`, `Pipeline`, `Response`, `ScanParams`, `ScanResult`, `JedisNoScriptException`) — no 7.x-only API usage. No code changes; all 152 tests pass.), From 7f8dfdf6f5298275a8c7d1ed0311cf6ad883fde4 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Thu, 18 Jun 2026 09:26:00 -0400 Subject: [PATCH 3/6] fix(evidence): bound active-key window on rotation + skip overlapping retired key material MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the retired-key JWK Set: - Rotation must advance the active key's nbf-ms. The previous rotation doc only set exp_ms on the retiring key; leaving nbf-ms at the default 0 publishes the new active key as valid since epoch, so it could sign a backdated envelope (issued_at_ms before the rotation) that still resolves as authentic — defeating window-gated selection. Fixed the documented procedure and added a startup warning when retired keys are configured but the active nbf-ms is at/before a retired window's end. - Skip same key material with an overlapping window. JwksDocuments now tracks emitted [nbf, exp) windows per key material (including the active key's open-ended window) and skips a retired entry whose window overlaps an already-published one for the same material; disjoint windows for one key (legitimate reuse across non-overlapping periods) are preserved. Replaces the unconditional active-material skip, which wrongly dropped a disjoint retired window. JwksDocumentsTest +10, JwksControllerTest +6; both classes 100% line- covered. Full mvn verify green, jacoco 95% gate met. AUDIT.md updated. Folded into v0.1.25.33 (unmerged). --- AUDIT.md | 2 +- .../api/controller/JwksController.java | 20 +++++++++ .../protocol/api/evidence/JwksDocuments.java | 36 ++++++++++++---- .../src/main/resources/application.properties | 8 +++- .../api/controller/JwksControllerTest.java | 25 +++++++++++ .../api/evidence/JwksDocumentsTest.java | 41 +++++++++++++++++-- 6 files changed, 117 insertions(+), 15 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index f7efcb8..4d9c788 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -1,6 +1,6 @@ # Cycles Protocol v0.1.25 — Server Implementation Audit -**Date:** 2026-06-18 (v0.1.25.33 — retired-key rotation history in the published CyclesEvidence JWK Set, the v0.2 signer-key-resolution follow-up to v0.1.25.32. A new `cycles.evidence.signing.retired-keys` property (env `EVIDENCE_SIGNING_RETIRED_KEYS`) takes a JSON array of `{signer_did, kid, nbf_ms, exp_ms}`; `JwksController` parses it fail-safe so bad config never stops the active key publishing, and `JwksDocuments.jwkSet` appends each retired key as a bounded `[cycles_nbf_ms, cycles_exp_ms)` JWK with `status: retired`, letting a verifier resolve the key that was valid at an envelope's `issued_at_ms` rather than only the current one. Retired entries are skipped when invalid: malformed hex, an absent or non-integral window bound, an empty or inverted window, key material matching the active key, or a duplicate `kid`. No spec or wire change — `CyclesEvidenceJwks` already allowed multiple keys with windows and a retired status. `JwksDocumentsTest` +7 and `JwksControllerTest` +4; full `mvn verify` green, 95% coverage gate met.), +**Date:** 2026-06-18 (v0.1.25.33 — retired-key rotation history in the published CyclesEvidence JWK Set, the v0.2 signer-key-resolution follow-up to v0.1.25.32. A new `cycles.evidence.signing.retired-keys` property (env `EVIDENCE_SIGNING_RETIRED_KEYS`) takes a JSON array of `{signer_did, kid, nbf_ms, exp_ms}`; `JwksController` parses it fail-safe so bad config never stops the active key publishing, and `JwksDocuments.jwkSet` appends each retired key as a bounded `[cycles_nbf_ms, cycles_exp_ms)` JWK with `status: retired`, letting a verifier resolve the key that was valid at an envelope's `issued_at_ms` rather than only the current one. Retired entries are skipped when invalid: malformed hex, an absent or non-integral window bound, an empty or inverted window, key material matching the active key, an overlapping window for already-published key material whether active or retired (disjoint windows for one key are fine), or a duplicate `kid`. On rotation the active key's `nbf-ms` must be set to the rotation time so it isn't published as valid for pre-rotation evidence; the documented procedure says so and the endpoint warns when retired keys are configured but `nbf-ms` doesn't reflect a rotation. No spec or wire change — `CyclesEvidenceJwks` already allowed multiple keys with windows and a retired status. `JwksDocumentsTest` +10 and `JwksControllerTest` +6; full `mvn verify` green, 95% coverage gate met.), 2026-06-15 (getEvidenceJwks live-serving integration test — `JwksEndpointIntegrationTest` (full `@SpringBootTest` with real Tomcat, the Spring Security filter chain active, and Testcontainers Redis) proves the JWK Set endpoint serves over real HTTP with no API key, i.e. the `/v1/.well-known/**` public-path exemption holds end-to-end through the filter chain — something the filters-disabled `JwksControllerTest` `@WebMvcTest` can't show. With the signing identity configured, a no-header GET returns 200 and a JWK whose `x` decodes to the configured `signer_did` bytes, with the right `kid`/`cycles_nbf_ms`/`status` and a public, non-immutable cache; a bogus key still returns 200. 2 tests, test-only — the implementation shipped in v0.1.25.32 / #194.), 2026-06-15 (v0.1.25.32 — CyclesEvidence signer-key resolution, publication half: `getEvidenceJwks` (`GET /v1/.well-known/cycles-jwks.json`, per cycles-protocol v0.1.25.6 / runcycles/cycles-protocol#113). When `cycles.evidence.signing.signer-did` is a raw 64-hex key, the public `JwksController` serves a one-key JWK Set built by the pure `JwksDocuments.jwkSet` — an active Ed25519 OKP JWK whose `x` is the same 32 bytes `EnvelopeSigner` signs with, so a verifier resolving the set authenticates the emitted signatures — with a short public, non-immutable cache. It 404s when no raw-hex key is configured (evidence off, or a `did:cycles` signer that carries no key bytes); consumers then stay on the raw-hex `expected_signer` pinning path. `/v1/.well-known/**` is public (public keys only) and API-base-relative under `/v1` per the spec's authority-scope rule. `JwksDocumentsTest` (10) and `JwksControllerTest` (4); `mvn verify` 906 tests green.), 2026-06-14 (v0.1.25.31 — review fix [Medium]: `POST /v1/reservations` re-emitted side-effect events on an idempotent replay. `create` emitted the `RESERVATION_DENIED` event and balance-transition events unconditionally, while `decide`, `commit`, and `release` already skip them on a replay, so a replayed create double-counted them. Fixed by wrapping create's emission block in `if (!response.isIdempotentReplay())` to match the other endpoints. `ReservationControllerTest` +1. No wire/spec change. Numbered .31 because .30 is held by the open byte-parity PR #187 — merge that first.), diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java index 6d13a5c..565a2a9 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java +++ b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java @@ -71,9 +71,29 @@ public JwksController( } if (!this.retiredKeys.isEmpty()) { LOG.info("evidence JWKS: {} retired key(s) configured for rotation history", this.retiredKeys.size()); + if (activeKeyWindowPredatesRetirement(this.nbfMs, this.retiredKeys)) { + LOG.warn("evidence JWKS: active key cycles_nbf_ms ({}) is at/before the end of a retired key's " + + "window — on rotation set cycles.evidence.signing.nbf-ms to the rotation time, or the " + + "active key is published as valid for pre-rotation evidence", this.nbfMs); + } } } + /** + * True when the active key's {@code nbf-ms} starts before a retired key's + * window ends — i.e. retired keys exist (a rotation happened) but the active + * key's window was not advanced to the rotation time, so the active key is + * still published as authoritative for pre-rotation {@code issued_at_ms}. + */ + static boolean activeKeyWindowPredatesRetirement(long activeNbfMs, List retired) { + long latestRetiredExp = retired.stream() + .filter(r -> r != null && r.expMs() != null) + .mapToLong(RetiredKey::expMs) + .max() + .orElse(Long.MIN_VALUE); + return activeNbfMs < latestRetiredExp; + } + /** * Parse {@code cycles.evidence.signing.retired-keys} — a JSON array of * {@code {"signer_did","kid","nbf_ms","exp_ms"}} — into retired-key records. diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/evidence/JwksDocuments.java b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/evidence/JwksDocuments.java index 343001d..47950c0 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/evidence/JwksDocuments.java +++ b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/evidence/JwksDocuments.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.Base64; +import java.util.HashMap; import java.util.HexFormat; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -69,11 +70,13 @@ public static Optional> jwkSet(String signerDid, String kid, * skipped defensively (a bad history entry never breaks publication of the * active key): malformed hex; a missing {@code expMs} (a retired key needs a * closed window); an empty/inverted window ({@code expMs <= nbfMs}, since - * {@code cycles_exp_ms} is EXCLUSIVE); the SAME key material as the active - * key (same {@code x} republished with a bounded window would make the - * window covering an {@code issued_at_ms} ambiguous); or a {@code kid} - * colliding with the active key or an earlier retired key (a duplicate - * {@code kid} is never emitted — set-wide kid uniqueness is required). + * {@code cycles_exp_ms} is EXCLUSIVE); the SAME key material as an + * already-published key (the active key's open-ended window or an earlier + * retired key) with an OVERLAPPING window — raw-hex selection by key bytes + * plus {@code issued_at_ms} would be ambiguous, though disjoint windows for + * one key are fine; or a {@code kid} colliding with the active key or an + * earlier retired key (a duplicate {@code kid} is never emitted — set-wide + * kid uniqueness is required). * * @param signerDid the active key ({@code cycles.evidence.signing.signer-did}) * @param kid active key id, or blank to derive a stable default @@ -90,26 +93,41 @@ public static Optional> jwkSet( List> keys = new ArrayList<>(); Set kids = new LinkedHashSet<>(); + // Emitted [nbf, exp) windows per key material (lowercased hex), so the + // same key republished with an OVERLAPPING window is never emitted twice + // (raw-hex selection is key-bytes + issued_at_ms, which would be ambiguous). + Map> windowsByMaterial = new HashMap<>(); keys.add(buildJwk(activeDid, activeKid, nbfMs, null, "active")); kids.add(activeKid); + // The active key occupies [nbfMs, +inf); seed it so a retired entry of the + // same material publishes only when its window is DISJOINT from the active + // one (overlap would be ambiguous) — the same rule applied between retired + // entries, so a key reused across non-overlapping periods is preserved. + windowsByMaterial.computeIfAbsent(activeDid.toLowerCase(), k -> new ArrayList<>()) + .add(new long[]{nbfMs, Long.MAX_VALUE}); if (retired != null) { for (RetiredKey r : retired) { if (r == null || !isRawHexKey(r.signerDid()) || r.expMs() == null) { continue; // malformed hex or no closed window — skip } - if (r.expMs() <= r.nbfMs()) { + long rNbf = r.nbfMs(); + long rExp = r.expMs(); + if (rExp <= rNbf) { continue; // empty or inverted window (exp is EXCLUSIVE) — skip } String rDid = r.signerDid().trim(); - if (rDid.equalsIgnoreCase(activeDid)) { - continue; // same key material as the active key — ambiguous window, skip + String material = rDid.toLowerCase(); + List seen = windowsByMaterial.get(material); + if (seen != null && seen.stream().anyMatch(w -> rNbf < w[1] && w[0] < rExp)) { + continue; // same key material with an overlapping window (active or retired) — ambiguous, skip } String rKid = (r.kid() == null || r.kid().isBlank()) ? defaultKid(rDid) : r.kid().trim(); if (!kids.add(rKid)) { continue; // duplicate kid — never emit (set-wide uniqueness) } - keys.add(buildJwk(rDid, rKid, r.nbfMs(), r.expMs(), "retired")); + keys.add(buildJwk(rDid, rKid, rNbf, rExp, "retired")); + windowsByMaterial.computeIfAbsent(material, k -> new ArrayList<>()).add(new long[]{rNbf, rExp}); } } diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/main/resources/application.properties b/cycles-protocol-service/cycles-protocol-service-api/src/main/resources/application.properties index 3d92495..2c58d9e 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/main/resources/application.properties +++ b/cycles-protocol-service/cycles-protocol-service-api/src/main/resources/application.properties @@ -92,6 +92,10 @@ cycles.evidence.signing.nbf-ms=${EVIDENCE_SIGNING_NBF_MS:0} # evidence signed before a rotation still verifies against the key valid at its # issued_at_ms. JSON array of {"signer_did":,"kid":...,"nbf_ms":..., # "exp_ms":...}; exp_ms (EXCLUSIVE end of the key's window) is required per entry. -# On rotation: set the new active key as signer-did and append the old one here -# with exp_ms = the rotation time. Empty = single active key (never rotated). +# On rotation: set the new active key as signer-did, set nbf-ms (above) to the +# rotation time, and append the old key here with exp_ms = that same rotation +# time, so the windows meet without overlapping. Leaving nbf-ms at 0 publishes +# the new active key as valid since epoch, so it could sign a backdated envelope +# (issued_at_ms before the rotation) that still resolves as authentic — defeating +# the point of window-gated key selection. Empty = single active key (never rotated). cycles.evidence.signing.retired-keys=${EVIDENCE_SIGNING_RETIRED_KEYS:} diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java index 83cb953..a93ac85 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java +++ b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java @@ -132,6 +132,31 @@ void retiredKeyWithMissingNbf_isSkipped() { assertThat((List>) body.get("keys")).hasSize(1); } + @Test + @SuppressWarnings("unchecked") + void retiredKeyOutlastingActiveNbf_stillPublishesBothKeys() { + // Active nbf 0 but a retired window ends at 100 — the rotation-window + // warning fires at construction, and both keys still publish. + String retired = "[{\"signer_did\":\"" + "ab".repeat(32) + "\",\"kid\":\"old\",\"nbf_ms\":0,\"exp_ms\":100}]"; + JwksController controller = new JwksController(SIGNER_DID, "2026-06", 0L, retired); + Map body = controller.getEvidenceJwks().getBody(); + assertThat((List>) body.get("keys")).hasSize(2); + } + + @Test + void activeKeyWindowPredatesRetirement_flagsDefaultNbfAfterRotation() { + java.util.List retired = + java.util.List.of(new io.runcycles.protocol.api.evidence.JwksDocuments.RetiredKey( + "ab".repeat(32), "old", 0L, 100L)); + // Default active nbf 0 < retired exp 100 → the active key still covers + // pre-rotation time: flagged. + assertThat(JwksController.activeKeyWindowPredatesRetirement(0L, retired)).isTrue(); + // Active nbf advanced to the rotation time (= retired exp) → contiguous, not flagged. + assertThat(JwksController.activeKeyWindowPredatesRetirement(100L, retired)).isFalse(); + // No retired keys → nothing to compare against. + assertThat(JwksController.activeKeyWindowPredatesRetirement(0L, java.util.List.of())).isFalse(); + } + @Test @SuppressWarnings("unchecked") void nonArrayRetiredKeysJson_isIgnored() { diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/evidence/JwksDocumentsTest.java b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/evidence/JwksDocumentsTest.java index 4597ab9..add9412 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/evidence/JwksDocumentsTest.java +++ b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/evidence/JwksDocumentsTest.java @@ -145,9 +145,9 @@ void retiredKey_emptyOrInvertedWindow_isSkipped() { } @Test - void retiredKey_sameMaterialAsActiveKey_isSkipped() { - // Republishing the active key's own bytes with a bounded window would make - // the window covering an issued_at_ms ambiguous — drop it (case-insensitive). + void retiredKey_sameMaterialAsActiveKey_overlappingWindow_isSkipped() { + // The active key's window is [0, inf); a retired entry of the same bytes + // with an overlapping window would be ambiguous — drop it (case-insensitive). List> keys = allKeys(JwksDocuments.jwkSet( RAW_HEX, "active", 0L, List.of(new JwksDocuments.RetiredKey(RAW_HEX.toUpperCase(), "dup-material", 0L, 100L)))); @@ -155,6 +155,41 @@ void retiredKey_sameMaterialAsActiveKey_isSkipped() { assertThat(keys.get(0)).containsEntry("status", "active"); } + @Test + void retiredKey_sameMaterialAsActiveKey_disjointWindow_isPublished() { + // Same key reused before it became active again: active window [200, inf), + // retired [0, 100) — disjoint, so the retired window is preserved and old + // evidence still resolves. + List> keys = allKeys(JwksDocuments.jwkSet( + RAW_HEX, "active", 200L, + List.of(new JwksDocuments.RetiredKey(RAW_HEX.toUpperCase(), "prior", 0L, 100L)))); + assertThat(keys).hasSize(2); + assertThat(keys.get(1)).containsEntry("kid", "prior").containsEntry("status", "retired"); + } + + @Test + void retiredKey_sameMaterialOverlappingWindows_secondIsSkipped() { + // Same key bytes, distinct kids, OVERLAPPING windows — raw-hex selection + // (key bytes + issued_at_ms) would be ambiguous, so only the first emits. + List> keys = allKeys(JwksDocuments.jwkSet( + RAW_HEX, "active", 0L, List.of( + new JwksDocuments.RetiredKey(RETIRED_HEX, "win-a", 0L, 200L), + new JwksDocuments.RetiredKey(RETIRED_HEX, "win-b", 100L, 300L)))); + assertThat(keys).hasSize(2); + assertThat(keys.get(1)).containsEntry("kid", "win-a"); + } + + @Test + void retiredKey_sameMaterialDisjointWindows_bothEmitted() { + // Same key reused across NON-overlapping periods is legitimate and + // unambiguous (exp is exclusive, so [0,100) and [100,200) do not overlap). + List> keys = allKeys(JwksDocuments.jwkSet( + RAW_HEX, "active", 0L, List.of( + new JwksDocuments.RetiredKey(RETIRED_HEX, "early", 0L, 100L), + new JwksDocuments.RetiredKey(RETIRED_HEX, "later", 100L, 200L)))); + assertThat(keys).hasSize(3); + } + @Test void emptyRetiredList_isSingleActiveKey() { assertThat(allKeys(JwksDocuments.jwkSet(RAW_HEX, "k", 0L, List.of()))).hasSize(1); From 0ac4d6381af628b0a64b4085599da93cf8453fb6 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Thu, 18 Jun 2026 09:44:18 -0400 Subject: [PATCH 4/6] fix(evidence): clamp active key window to the rotation boundary (fail-safe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: detecting active nbf-ms < latest retired exp_ms but only warning still publishes the unsafe JWKS — a logged warning doesn't close the hole, so the current key can sign backdated evidence (issued_at_ms before the rotation) that resolves as authentic. Fail closed by clamping instead of failing the endpoint: JwksDocuments advances the published active cycles_nbf_ms up to the latest declared retired exp_ms (Math.max), so the active key is never valid for pre-rotation issued_at_ms while the endpoint stays up and post-rotation evidence still resolves. The floor counts EVERY declared bounded retired window — including one whose key material is malformed and therefore not published — so a typo in rotation history can't reopen the hole. The controller warning is kept as an operator nudge and its predicate aligned to the same bounded-window filter. Also drops the now-redundant active-key overlap seed: the clamp guarantees the active window starts at/after every retired exp, so a same-material retired key is always disjoint (its history is preserved, not dropped). JwksDocumentsTest +13, JwksControllerTest +6; both classes 100% line- covered. Full mvn verify green, jacoco 95% gate met. AUDIT.md updated. Folded into v0.1.25.33 (unmerged). --- AUDIT.md | 2 +- .../api/controller/JwksController.java | 9 ++-- .../protocol/api/evidence/JwksDocuments.java | 43 +++++++++++------- .../api/evidence/JwksDocumentsTest.java | 45 ++++++++++++++++--- 4 files changed, 74 insertions(+), 25 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index 4d9c788..1a51ed0 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -1,6 +1,6 @@ # Cycles Protocol v0.1.25 — Server Implementation Audit -**Date:** 2026-06-18 (v0.1.25.33 — retired-key rotation history in the published CyclesEvidence JWK Set, the v0.2 signer-key-resolution follow-up to v0.1.25.32. A new `cycles.evidence.signing.retired-keys` property (env `EVIDENCE_SIGNING_RETIRED_KEYS`) takes a JSON array of `{signer_did, kid, nbf_ms, exp_ms}`; `JwksController` parses it fail-safe so bad config never stops the active key publishing, and `JwksDocuments.jwkSet` appends each retired key as a bounded `[cycles_nbf_ms, cycles_exp_ms)` JWK with `status: retired`, letting a verifier resolve the key that was valid at an envelope's `issued_at_ms` rather than only the current one. Retired entries are skipped when invalid: malformed hex, an absent or non-integral window bound, an empty or inverted window, key material matching the active key, an overlapping window for already-published key material whether active or retired (disjoint windows for one key are fine), or a duplicate `kid`. On rotation the active key's `nbf-ms` must be set to the rotation time so it isn't published as valid for pre-rotation evidence; the documented procedure says so and the endpoint warns when retired keys are configured but `nbf-ms` doesn't reflect a rotation. No spec or wire change — `CyclesEvidenceJwks` already allowed multiple keys with windows and a retired status. `JwksDocumentsTest` +10 and `JwksControllerTest` +6; full `mvn verify` green, 95% coverage gate met.), +**Date:** 2026-06-18 (v0.1.25.33 — retired-key rotation history in the published CyclesEvidence JWK Set, the v0.2 signer-key-resolution follow-up to v0.1.25.32. A new `cycles.evidence.signing.retired-keys` property (env `EVIDENCE_SIGNING_RETIRED_KEYS`) takes a JSON array of `{signer_did, kid, nbf_ms, exp_ms}`; `JwksController` parses it fail-safe so bad config never stops the active key publishing, and `JwksDocuments.jwkSet` appends each retired key as a bounded `[cycles_nbf_ms, cycles_exp_ms)` JWK with `status: retired`, letting a verifier resolve the key that was valid at an envelope's `issued_at_ms` rather than only the current one. Retired entries are skipped when invalid: malformed hex, an absent or non-integral window bound, an empty or inverted window, key material matching the active key, an overlapping window for already-published retired key material (disjoint windows for a reused key are fine), or a duplicate `kid`. On rotation the active key's `nbf-ms` should be set to the rotation time; if it's left lower, the published active window is clamped up to the latest retired key's `exp_ms` (fail-safe, with a warning) so the current key can't resolve as valid for pre-rotation evidence. No spec or wire change — `CyclesEvidenceJwks` already allowed multiple keys with windows and a retired status. `JwksDocumentsTest` +13 and `JwksControllerTest` +6; full `mvn verify` green, 95% coverage gate met.), 2026-06-15 (getEvidenceJwks live-serving integration test — `JwksEndpointIntegrationTest` (full `@SpringBootTest` with real Tomcat, the Spring Security filter chain active, and Testcontainers Redis) proves the JWK Set endpoint serves over real HTTP with no API key, i.e. the `/v1/.well-known/**` public-path exemption holds end-to-end through the filter chain — something the filters-disabled `JwksControllerTest` `@WebMvcTest` can't show. With the signing identity configured, a no-header GET returns 200 and a JWK whose `x` decodes to the configured `signer_did` bytes, with the right `kid`/`cycles_nbf_ms`/`status` and a public, non-immutable cache; a bogus key still returns 200. 2 tests, test-only — the implementation shipped in v0.1.25.32 / #194.), 2026-06-15 (v0.1.25.32 — CyclesEvidence signer-key resolution, publication half: `getEvidenceJwks` (`GET /v1/.well-known/cycles-jwks.json`, per cycles-protocol v0.1.25.6 / runcycles/cycles-protocol#113). When `cycles.evidence.signing.signer-did` is a raw 64-hex key, the public `JwksController` serves a one-key JWK Set built by the pure `JwksDocuments.jwkSet` — an active Ed25519 OKP JWK whose `x` is the same 32 bytes `EnvelopeSigner` signs with, so a verifier resolving the set authenticates the emitted signatures — with a short public, non-immutable cache. It 404s when no raw-hex key is configured (evidence off, or a `did:cycles` signer that carries no key bytes); consumers then stay on the raw-hex `expected_signer` pinning path. `/v1/.well-known/**` is public (public keys only) and API-base-relative under `/v1` per the spec's authority-scope rule. `JwksDocumentsTest` (10) and `JwksControllerTest` (4); `mvn verify` 906 tests green.), 2026-06-14 (v0.1.25.31 — review fix [Medium]: `POST /v1/reservations` re-emitted side-effect events on an idempotent replay. `create` emitted the `RESERVATION_DENIED` event and balance-transition events unconditionally, while `decide`, `commit`, and `release` already skip them on a replay, so a replayed create double-counted them. Fixed by wrapping create's emission block in `if (!response.isIdempotentReplay())` to match the other endpoints. `ReservationControllerTest` +1. No wire/spec change. Numbered .31 because .30 is held by the open byte-parity PR #187 — merge that first.), diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java index 565a2a9..9abe72a 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java +++ b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java @@ -72,9 +72,10 @@ public JwksController( if (!this.retiredKeys.isEmpty()) { LOG.info("evidence JWKS: {} retired key(s) configured for rotation history", this.retiredKeys.size()); if (activeKeyWindowPredatesRetirement(this.nbfMs, this.retiredKeys)) { - LOG.warn("evidence JWKS: active key cycles_nbf_ms ({}) is at/before the end of a retired key's " - + "window — on rotation set cycles.evidence.signing.nbf-ms to the rotation time, or the " - + "active key is published as valid for pre-rotation evidence", this.nbfMs); + LOG.warn("evidence JWKS: configured active key cycles_nbf_ms ({}) is at/before a retired key's " + + "window end; the published active window is advanced to the latest retired exp so the " + + "current key cannot resolve as valid for pre-rotation evidence. Set " + + "cycles.evidence.signing.nbf-ms to the rotation time to make this explicit.", this.nbfMs); } } } @@ -87,7 +88,7 @@ public JwksController( */ static boolean activeKeyWindowPredatesRetirement(long activeNbfMs, List retired) { long latestRetiredExp = retired.stream() - .filter(r -> r != null && r.expMs() != null) + .filter(r -> r != null && r.expMs() != null && r.expMs() > r.nbfMs()) .mapToLong(RetiredKey::expMs) .max() .orElse(Long.MIN_VALUE); diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/evidence/JwksDocuments.java b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/evidence/JwksDocuments.java index 47950c0..38b33c9 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/evidence/JwksDocuments.java +++ b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/evidence/JwksDocuments.java @@ -70,17 +70,19 @@ public static Optional> jwkSet(String signerDid, String kid, * skipped defensively (a bad history entry never breaks publication of the * active key): malformed hex; a missing {@code expMs} (a retired key needs a * closed window); an empty/inverted window ({@code expMs <= nbfMs}, since - * {@code cycles_exp_ms} is EXCLUSIVE); the SAME key material as an - * already-published key (the active key's open-ended window or an earlier - * retired key) with an OVERLAPPING window — raw-hex selection by key bytes + * {@code cycles_exp_ms} is EXCLUSIVE); the SAME key material as an earlier + * retired key with an OVERLAPPING window — raw-hex selection by key bytes * plus {@code issued_at_ms} would be ambiguous, though disjoint windows for - * one key are fine; or a {@code kid} colliding with the active key or an + * a reused key are fine; or a {@code kid} colliding with the active key or an * earlier retired key (a duplicate {@code kid} is never emitted — set-wide * kid uniqueness is required). * * @param signerDid the active key ({@code cycles.evidence.signing.signer-did}) * @param kid active key id, or blank to derive a stable default - * @param nbfMs active {@code cycles_nbf_ms} (epoch ms, inclusive) + * @param nbfMs active {@code cycles_nbf_ms} (epoch ms, inclusive); if it + * was left below the latest retired key's {@code exp_ms} the + * published active window is advanced to that boundary, so the + * active key is never valid for pre-rotation {@code issued_at_ms} * @param retired retired keys to retain in the set (may be empty/null) */ public static Optional> jwkSet( @@ -91,20 +93,31 @@ public static Optional> jwkSet( String activeDid = signerDid.trim(); String activeKid = (kid == null || kid.isBlank()) ? defaultKid(activeDid) : kid.trim(); + // Safety floor (fail-safe, not just a warning): the active key MUST NOT be + // published as valid before the latest retired key's window ends, or the + // current key could sign a backdated envelope (issued_at_ms before the + // rotation) that still resolves as authentic. If the configured nbf-ms was + // left below that boundary, advance the published active window up to it. + // Floor on EVERY declared bounded retired window — even one whose key + // material is malformed (so it won't be published): a typo in rotation + // history must not reopen the pre-rotation backdating hole on the active key. + long latestRetiredExp = (retired == null ? List.of() : retired).stream() + .filter(r -> r != null && r.expMs() != null && r.expMs() > r.nbfMs()) + .mapToLong(RetiredKey::expMs) + .max() + .orElse(Long.MIN_VALUE); + long activeNbf = Math.max(nbfMs, latestRetiredExp); + List> keys = new ArrayList<>(); Set kids = new LinkedHashSet<>(); - // Emitted [nbf, exp) windows per key material (lowercased hex), so the - // same key republished with an OVERLAPPING window is never emitted twice - // (raw-hex selection is key-bytes + issued_at_ms, which would be ambiguous). + // Emitted [nbf, exp) windows per key material (lowercased hex), so the same + // key republished with an OVERLAPPING window is never emitted twice — raw-hex + // selection is key-bytes + issued_at_ms, which would otherwise be ambiguous. + // The active key needs no entry: the nbf clamp above puts its window at/after + // every retired exp, so it is always disjoint from a same-material retired key. Map> windowsByMaterial = new HashMap<>(); - keys.add(buildJwk(activeDid, activeKid, nbfMs, null, "active")); + keys.add(buildJwk(activeDid, activeKid, activeNbf, null, "active")); kids.add(activeKid); - // The active key occupies [nbfMs, +inf); seed it so a retired entry of the - // same material publishes only when its window is DISJOINT from the active - // one (overlap would be ambiguous) — the same rule applied between retired - // entries, so a key reused across non-overlapping periods is preserved. - windowsByMaterial.computeIfAbsent(activeDid.toLowerCase(), k -> new ArrayList<>()) - .add(new long[]{nbfMs, Long.MAX_VALUE}); if (retired != null) { for (RetiredKey r : retired) { diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/evidence/JwksDocumentsTest.java b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/evidence/JwksDocumentsTest.java index add9412..fcee49e 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/evidence/JwksDocumentsTest.java +++ b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/evidence/JwksDocumentsTest.java @@ -145,14 +145,16 @@ void retiredKey_emptyOrInvertedWindow_isSkipped() { } @Test - void retiredKey_sameMaterialAsActiveKey_overlappingWindow_isSkipped() { - // The active key's window is [0, inf); a retired entry of the same bytes - // with an overlapping window would be ambiguous — drop it (case-insensitive). + void retiredKey_sameMaterialAsActiveKey_publishesAfterNbfClamp() { + // Active nbf 0 with a same-material retired [0,100): clamping the active + // window to 100 makes the retired window disjoint, so the reused key's + // history is preserved (both publish) rather than dropped (case-insensitive). List> keys = allKeys(JwksDocuments.jwkSet( RAW_HEX, "active", 0L, List.of(new JwksDocuments.RetiredKey(RAW_HEX.toUpperCase(), "dup-material", 0L, 100L)))); - assertThat(keys).hasSize(1); - assertThat(keys.get(0)).containsEntry("status", "active"); + assertThat(keys).hasSize(2); + assertThat(keys.get(0)).containsEntry("status", "active").containsEntry("cycles_nbf_ms", 100L); + assertThat(keys.get(1)).containsEntry("kid", "dup-material").containsEntry("status", "retired"); } @Test @@ -190,6 +192,39 @@ void retiredKey_sameMaterialDisjointWindows_bothEmitted() { assertThat(keys).hasSize(3); } + @Test + void activeNbf_belowLatestRetiredExp_isAdvancedToThatBoundary() { + // Configured active nbf 0 with a retired window ending at 100 would leave the + // active key valid since epoch; the published active window is clamped to 100. + List> keys = allKeys(JwksDocuments.jwkSet( + RAW_HEX, "active", 0L, + List.of(new JwksDocuments.RetiredKey(RETIRED_HEX, "old", 0L, 100L)))); + assertThat(keys.get(0)).containsEntry("status", "active").containsEntry("cycles_nbf_ms", 100L); + assertThat(keys.get(1)).containsEntry("kid", "old"); + } + + @Test + void activeNbf_clampedEvenWhenRetiredKeyMaterialIsMalformed() { + // A retired entry with bad hex isn't published, but its declared exp still + // floors the active key — a typo in rotation history must not reopen the + // pre-rotation backdating hole. + List> keys = allKeys(JwksDocuments.jwkSet( + RAW_HEX, "active", 0L, + List.of(new JwksDocuments.RetiredKey("not-hex", "bad", 0L, 100L)))); + assertThat(keys).hasSize(1); // malformed key not published + assertThat(keys.get(0)).containsEntry("status", "active").containsEntry("cycles_nbf_ms", 100L); + } + + @Test + void activeNbf_atOrAboveLatestRetiredExp_isUnchanged() { + // Correctly-configured rotation (active nbf = rotation time = retired exp) is + // not modified. + List> keys = allKeys(JwksDocuments.jwkSet( + RAW_HEX, "active", 100L, + List.of(new JwksDocuments.RetiredKey(RETIRED_HEX, "old", 0L, 100L)))); + assertThat(keys.get(0)).containsEntry("cycles_nbf_ms", 100L); + } + @Test void emptyRetiredList_isSingleActiveKey() { assertThat(allKeys(JwksDocuments.jwkSet(RAW_HEX, "k", 0L, List.of()))).hasSize(1); From 7f6f0d13abb4ab5d0576d065602a88d626ceb578 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Thu, 18 Jun 2026 09:54:25 -0400 Subject: [PATCH 5/6] docs(evidence): correct the rotation nbf-ms comment to match the clamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retired-keys property comment still said leaving nbf-ms at 0 "publishes the new active key as valid since epoch" — stale since the active cycles_nbf_ms is now warned on and fail-safe clamped up to the latest retired exp_ms. Reword to describe the clamp while still recommending an explicit nbf-ms. Comment-only; no code/behavior change. --- .../src/main/resources/application.properties | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/main/resources/application.properties b/cycles-protocol-service/cycles-protocol-service-api/src/main/resources/application.properties index 2c58d9e..0cbceac 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/main/resources/application.properties +++ b/cycles-protocol-service/cycles-protocol-service-api/src/main/resources/application.properties @@ -94,8 +94,9 @@ cycles.evidence.signing.nbf-ms=${EVIDENCE_SIGNING_NBF_MS:0} # "exp_ms":...}; exp_ms (EXCLUSIVE end of the key's window) is required per entry. # On rotation: set the new active key as signer-did, set nbf-ms (above) to the # rotation time, and append the old key here with exp_ms = that same rotation -# time, so the windows meet without overlapping. Leaving nbf-ms at 0 publishes -# the new active key as valid since epoch, so it could sign a backdated envelope -# (issued_at_ms before the rotation) that still resolves as authentic — defeating -# the point of window-gated key selection. Empty = single active key (never rotated). +# time, so the windows meet without overlapping. If nbf-ms is left below the +# latest retired exp_ms, the published active cycles_nbf_ms is warned on and +# fail-safe clamped up to that boundary (so the active key cannot resolve for +# pre-rotation issued_at_ms) — but set nbf-ms explicitly rather than relying on +# the clamp. Empty = single active key (never rotated). cycles.evidence.signing.retired-keys=${EVIDENCE_SIGNING_RETIRED_KEYS:} From ceab9ee182615f85e7f4cdb02e8e1a5aa3c011e4 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Thu, 18 Jun 2026 10:12:30 -0400 Subject: [PATCH 6/6] fix(evidence): reject out-of-long-range retired window bounds + audit wording - parseRetiredKeys checked isIntegralNumber() then asLong(), which wraps out-of-range integers (Jackson treats a BigInteger as integral, and asLong() saturates 2^63 to Long.MIN_VALUE). Also require canConvertToLong() for both nbf_ms and exp_ms so an out-of-range bound is dropped, not wrapped into a corrupt window. JwksControllerTest +1 (out-of-range exp skipped). - AUDIT.md said retired entries are skipped when "key material matching the active key," but same-material entries now publish once the clamp makes their window disjoint. Corrected the skip list (and noted out-of-range bounds are rejected). Both classes 100% line-covered; full mvn verify green, 95% gate met. Folded into v0.1.25.33 (unmerged). --- AUDIT.md | 2 +- .../protocol/api/controller/JwksController.java | 13 ++++++++----- .../protocol/api/controller/JwksControllerTest.java | 12 ++++++++++++ 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index 1a51ed0..7088068 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -1,6 +1,6 @@ # Cycles Protocol v0.1.25 — Server Implementation Audit -**Date:** 2026-06-18 (v0.1.25.33 — retired-key rotation history in the published CyclesEvidence JWK Set, the v0.2 signer-key-resolution follow-up to v0.1.25.32. A new `cycles.evidence.signing.retired-keys` property (env `EVIDENCE_SIGNING_RETIRED_KEYS`) takes a JSON array of `{signer_did, kid, nbf_ms, exp_ms}`; `JwksController` parses it fail-safe so bad config never stops the active key publishing, and `JwksDocuments.jwkSet` appends each retired key as a bounded `[cycles_nbf_ms, cycles_exp_ms)` JWK with `status: retired`, letting a verifier resolve the key that was valid at an envelope's `issued_at_ms` rather than only the current one. Retired entries are skipped when invalid: malformed hex, an absent or non-integral window bound, an empty or inverted window, key material matching the active key, an overlapping window for already-published retired key material (disjoint windows for a reused key are fine), or a duplicate `kid`. On rotation the active key's `nbf-ms` should be set to the rotation time; if it's left lower, the published active window is clamped up to the latest retired key's `exp_ms` (fail-safe, with a warning) so the current key can't resolve as valid for pre-rotation evidence. No spec or wire change — `CyclesEvidenceJwks` already allowed multiple keys with windows and a retired status. `JwksDocumentsTest` +13 and `JwksControllerTest` +6; full `mvn verify` green, 95% coverage gate met.), +**Date:** 2026-06-18 (v0.1.25.33 — retired-key rotation history in the published CyclesEvidence JWK Set, the v0.2 signer-key-resolution follow-up to v0.1.25.32. A new `cycles.evidence.signing.retired-keys` property (env `EVIDENCE_SIGNING_RETIRED_KEYS`) takes a JSON array of `{signer_did, kid, nbf_ms, exp_ms}`; `JwksController` parses it fail-safe so bad config never stops the active key publishing, and `JwksDocuments.jwkSet` appends each retired key as a bounded `[cycles_nbf_ms, cycles_exp_ms)` JWK with `status: retired`, letting a verifier resolve the key that was valid at an envelope's `issued_at_ms` rather than only the current one. Retired entries are skipped when invalid: malformed hex, an absent, non-integral, or out-of-long-range window bound, an empty or inverted window, an overlapping window for already-published retired key material, or a duplicate `kid`; a key whose material matches the active key publishes once the clamp below makes its window disjoint (so reused-key history is preserved). On rotation the active key's `nbf-ms` should be set to the rotation time; if it's left lower, the published active window is clamped up to the latest retired key's `exp_ms` (fail-safe, with a warning) so the current key can't resolve as valid for pre-rotation evidence. No spec or wire change — `CyclesEvidenceJwks` already allowed multiple keys with windows and a retired status. `JwksDocumentsTest` +13 and `JwksControllerTest` +7; full `mvn verify` green, 95% coverage gate met.), 2026-06-15 (getEvidenceJwks live-serving integration test — `JwksEndpointIntegrationTest` (full `@SpringBootTest` with real Tomcat, the Spring Security filter chain active, and Testcontainers Redis) proves the JWK Set endpoint serves over real HTTP with no API key, i.e. the `/v1/.well-known/**` public-path exemption holds end-to-end through the filter chain — something the filters-disabled `JwksControllerTest` `@WebMvcTest` can't show. With the signing identity configured, a no-header GET returns 200 and a JWK whose `x` decodes to the configured `signer_did` bytes, with the right `kid`/`cycles_nbf_ms`/`status` and a public, non-immutable cache; a bogus key still returns 200. 2 tests, test-only — the implementation shipped in v0.1.25.32 / #194.), 2026-06-15 (v0.1.25.32 — CyclesEvidence signer-key resolution, publication half: `getEvidenceJwks` (`GET /v1/.well-known/cycles-jwks.json`, per cycles-protocol v0.1.25.6 / runcycles/cycles-protocol#113). When `cycles.evidence.signing.signer-did` is a raw 64-hex key, the public `JwksController` serves a one-key JWK Set built by the pure `JwksDocuments.jwkSet` — an active Ed25519 OKP JWK whose `x` is the same 32 bytes `EnvelopeSigner` signs with, so a verifier resolving the set authenticates the emitted signatures — with a short public, non-immutable cache. It 404s when no raw-hex key is configured (evidence off, or a `did:cycles` signer that carries no key bytes); consumers then stay on the raw-hex `expected_signer` pinning path. `/v1/.well-known/**` is public (public keys only) and API-base-relative under `/v1` per the spec's authority-scope rule. `JwksDocumentsTest` (10) and `JwksControllerTest` (4); `mvn verify` 906 tests green.), 2026-06-14 (v0.1.25.31 — review fix [Medium]: `POST /v1/reservations` re-emitted side-effect events on an idempotent replay. `create` emitted the `RESERVATION_DENIED` event and balance-transition events unconditionally, while `decide`, `commit`, and `release` already skip them on a replay, so a replayed create double-counted them. Fixed by wrapping create's emission block in `if (!response.isIdempotentReplay())` to match the other endpoints. `ReservationControllerTest` +1. No wire/spec change. Numbered .31 because .30 is held by the open byte-parity PR #187 — merge that first.), diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java index 9abe72a..25c52c4 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java +++ b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java @@ -116,11 +116,14 @@ private static List parseRetiredKeys(String json) { for (JsonNode n : arr) { JsonNode nbfNode = n.path("nbf_ms"); JsonNode expNode = n.path("exp_ms"); - // Both window bounds MUST be explicit integral epoch-ms. A missing - // or non-integral nbf_ms is NOT coerced to 0 (epoch) — that would - // silently widen the validity window; drop the entry instead. - if (!nbfNode.isIntegralNumber() || !expNode.isIntegralNumber()) { - LOG.warn("retired key '{}' has a missing/non-integral nbf_ms or exp_ms; skipping", + // Both window bounds MUST be explicit integral epoch-ms that fit in a + // long. A missing or non-integral nbf_ms is NOT coerced to 0 (epoch) — + // that would silently widen the validity window; and isIntegralNumber() + // alone accepts out-of-long-range integers that asLong() would wrap + // (e.g. 2^63 → Long.MIN_VALUE), so require canConvertToLong() too. + if (!nbfNode.isIntegralNumber() || !nbfNode.canConvertToLong() + || !expNode.isIntegralNumber() || !expNode.canConvertToLong()) { + LOG.warn("retired key '{}' has a missing/non-integral/out-of-range nbf_ms or exp_ms; skipping", n.path("kid").asText("")); continue; } diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java index a93ac85..df5e2a8 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java +++ b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java @@ -157,6 +157,18 @@ void activeKeyWindowPredatesRetirement_flagsDefaultNbfAfterRotation() { assertThat(JwksController.activeKeyWindowPredatesRetirement(0L, java.util.List.of())).isFalse(); } + @Test + @SuppressWarnings("unchecked") + void retiredKeyWithOutOfRangeExp_isSkipped() { + // exp_ms beyond Long range is integral to Jackson but asLong() would wrap it + // (2^63 -> Long.MIN_VALUE); it must be rejected, not silently wrapped. + String retired = "[{\"signer_did\":\"" + "ab".repeat(32) + + "\",\"kid\":\"huge\",\"nbf_ms\":0,\"exp_ms\":9223372036854775808}]"; + JwksController controller = new JwksController(SIGNER_DID, "2026-06", 0L, retired); + Map body = controller.getEvidenceJwks().getBody(); + assertThat((List>) body.get("keys")).hasSize(1); + } + @Test @SuppressWarnings("unchecked") void nonArrayRetiredKeysJson_isIgnored() {