From c02253fa748213ae3eae52a8399537f9694bca04 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Mon, 15 Jun 2026 09:45:12 -0400 Subject: [PATCH] test(evidence): live-serving integration test for getEvidenceJwks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — 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 set 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 also checks the body against the published CyclesEvidenceJwks schema (cycles-protocol@main, #113). codex review: no findings. 2 tests; test-only (impl shipped in v0.1.25.32 / #194; no production/wire/spec change). --- AUDIT.md | 2 +- .../api/JwksEndpointIntegrationTest.java | 69 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/JwksEndpointIntegrationTest.java diff --git a/AUDIT.md b/AUDIT.md index 7211e91..67f24ed 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -1,6 +1,6 @@ # Cycles Protocol v0.1.25 — Server Implementation Audit -**Date:** 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-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/test/java/io/runcycles/protocol/api/JwksEndpointIntegrationTest.java b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/JwksEndpointIntegrationTest.java new file mode 100644 index 0000000..a216585 --- /dev/null +++ b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/JwksEndpointIntegrationTest.java @@ -0,0 +1,69 @@ +package io.runcycles.protocol.api; + +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.TestPropertySource; + +import java.util.Base64; +import java.util.HexFormat; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Live serving test for {@code getEvidenceJwks} over real HTTP — full + * {@link org.springframework.boot.test.context.SpringBootTest} on a random port + * with the SECURITY FILTER CHAIN ACTIVE (unlike the {@code @WebMvcTest} unit + * test, which disables filters). Proves the JWK Set endpoint is reachable + * WITHOUT an API key (the public-path exemption actually works end-to-end), that + * the served JWK matches the configured signing identity, and — via the base + * class's contract-validating interceptor — that the body conforms to the + * published {@code CyclesEvidenceJwks} schema. + */ +@TestPropertySource(properties = { + "cycles.evidence.signing.signer-did=" + JwksEndpointIntegrationTest.SIGNER_DID, + "cycles.evidence.signing.kid=2026-06", + "cycles.evidence.signing.nbf-ms=1810000000000" +}) +class JwksEndpointIntegrationTest extends BaseIntegrationTest { + + static final String SIGNER_DID = + "ec52b49b81eb29ef6f62947cade245c715bf943b7ef2a5f2789288574466fc43"; + + private static final String JWKS_PATH = "/v1/.well-known/cycles-jwks.json"; + + @Test + void servesJwkSetPubliclyWithoutApiKey() throws Exception { + // No X-Cycles-API-Key header — proves the public-path exemption holds + // through the real Spring Security filter chain (not just the array). + ResponseEntity resp = + restTemplate.getForEntity(baseUrl() + JWKS_PATH, String.class); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(resp.getHeaders().getCacheControl()) + .contains("max-age").contains("public").doesNotContain("immutable"); + + JsonNode jwk = objectMapper.readTree(resp.getBody()).get("keys").get(0); + assertThat(jwk.get("kty").asText()).isEqualTo("OKP"); + assertThat(jwk.get("crv").asText()).isEqualTo("Ed25519"); + assertThat(jwk.get("kid").asText()).isEqualTo("2026-06"); + assertThat(jwk.get("cycles_nbf_ms").asLong()).isEqualTo(1810000000000L); + assertThat(jwk.get("status").asText()).isEqualTo("active"); + // the served x decodes to exactly the configured signer_did bytes + byte[] x = Base64.getUrlDecoder().decode(jwk.get("x").asText()); + assertThat(x).isEqualTo(HexFormat.of().parseHex(SIGNER_DID)); + } + + @Test + void bogusApiKeyStillServesTheSet() { + // A public endpoint must serve regardless of credentials — a junk key + // must not turn a 200 into a 401. + ResponseEntity resp = restTemplate.exchange( + baseUrl() + JWKS_PATH, + org.springframework.http.HttpMethod.GET, + new org.springframework.http.HttpEntity<>(headersForTenant("cyc_not_a_real_key")), + String.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + } +}