Releases: runcycles/cycles-server-admin
v0.1.25.41 — drop tomcat override (SB 3.5.14 BOM-managed) + Jedis fleet alignment
Dependency hygiene release. No application-level code or wire-format changes — pom-only patch.
Changed
- Spring Boot 3.5.13 → 3.5.14. Patch upgrade picking up upstream security hardening (constant-time DevTools secret comparison,
RandomValuePropertySourceSecureRandom, consistent SSL hostname verification,ApplicationPidFileWriter/ApplicationTempsymlink fixes). - Drop
<tomcat.version>10.1.54</tomcat.version>override. SB 3.5.14's BOM now manages Tomcat 10.1.54 directly (verified againstspring-boot-dependencies-3.5.14.pom). The explicit pin from v0.1.25.33 (closing CVE-2026-34483 / CVE-2026-34487) is now redundant. - Jedis 5.2.0 → 6.2.0 (major). Aligns with
cycles-server-events(6.2.0) andcycles-server(6.2.0) on a single Redis-client major across the fleet. Jedis 6.1.0 explicitly restored binary compatibility forSetParams(#4225 upstream); all 782 tests pass on 6.2.0. commons-lang3 3.18.0override retained — SB 3.5.14's BOM still manages 3.17.0 (CVE-2025-48924 unfixed there). Comment updated to reference SB 3.5.14.
See CHANGELOG.md for the full entry.
Fleet alignment
Matching releases: cycles-server-events v0.1.25.12, cycles-server v0.1.25.18.
v0.1.25.40 — webhook lifecycle emit post-review polish
v0.1.25.40 — webhook lifecycle emit post-review polish
Post-review correctness pass on v0.1.25.39 webhook lifecycle Events. No new spec surface, no wire-format changes — strictly better output for listEvents consumers.
Fixed
- B1 — Actor parity on single-op lifecycle emits.
createWebhookSubscription,updateWebhookSubscription, anddeleteWebhookSubscriptionnow populateActor.keyIdfromauthenticated_key_id, matching the bulk-action path. Audit consumers see consistent API-key attribution across all webhook lifecycle Events regardless of code path. - B2/B3 —
changed_fieldsis now a real diff.updateWebhookSubscriptionpreviously listed any field present in the PATCH body even when the value matched the subscription's current value. Each request-provided field is now compared against the prior snapshot; only genuine mutations land inchanged_fields. A full-identity PATCH (every field resent with existing value, no status flip) is a true no-op and emits nothing — aligns with spec v0.1.25.33 §6281.signing_secretkeeps presence-based detection (stored value is encrypted; not safely comparable to plaintext request value). - B4 — Correlation-id uniqueness. The
"no-req"literal fallback inwebhook_update:<sub_id>:<request_id>andwebhook_bulk_action:<action>:<request_id>is replaced withreq_<uuid>fallback. Guarantees uniqueness across concurrent requests ifRequestIdFilterever fails to populate the attribute.
Compatibility
Internal-only fixes. No EventType / schema / endpoint / wire-format changes. Pairs with cycles-server-events v0.1.25.11 dispatcher-side webhook.disabled emit (unchanged).
v0.1.25.39 — admin webhook lifecycle Events
v0.1.25.39 — admin webhook lifecycle Events
Operator-driven emits for webhook.{created,updated,paused,resumed,deleted} wired into single-op + bulk webhook endpoints per cycles-protocol v0.1.25.33 contract, with EventCategory.webhook (spec v0.1.25.34).
Emits
| Operation | EventType | correlation_id |
|---|---|---|
POST /v1/admin/webhooks |
webhook.created |
webhook_create:<subscription_id> |
PATCH /v1/admin/webhooks/{id} (fields) |
webhook.updated |
webhook_update:<subscription_id>:<request_id> |
PATCH /v1/admin/webhooks/{id} (status flip) |
webhook.paused / webhook.resumed |
same |
DELETE /v1/admin/webhooks/{id} |
webhook.deleted |
webhook_delete:<subscription_id> |
POST /v1/admin/webhooks/bulk-action |
webhook.paused / webhook.resumed / webhook.deleted |
webhook_bulk_action:<action>:<request_id> |
- Only fires on actual state transition; skipped/failed rows produce no Event.
- Payload conforms to
EventDataWebhookLifecycle. - Best-effort emit; failures logged, not propagated.
Not here
webhook.disabled— dispatcher-side (already live incycles-server-eventsv0.1.25.11).
Compatibility
Additive. No wire breaks. Pairs with dispatcher emit already running; together closes the v0.1.25.33 webhook audit-trail gap.
v0.1.25.38 — bulk-action event parity
v0.1.25.38 — bulk-action event parity (spec v0.1.25.32)
Added
- Per-row
Eventemission onPOST /v1/admin/budgets/bulk-action— one
budget.funded/budget.debited/budget.reset/budget.reset_spent/
budget.debt_repaidper successfully-mutated row, stamped
correlation_id = budget_bulk_action:<action>:<request_id>. - Per-row
Eventemission onPOST /v1/admin/tenants/bulk-action— one
tenant.suspended/tenant.reactivated/tenant.closedper
successfully-mutated row, stamped
correlation_id = tenant_bulk_action:<action>:<request_id>. Cascade
fan-out retains its owntenant_close_cascade:<tenant_id>:<request_id>
(complementary, unchanged).
Unchanged
- Aggregate
AuditLogEntryper bulk-action invocation (spec v0.1.25.26 rule). EventTypeenum — reuses existing per-op kinds.TenantCloseCascadeServicesemantics.
Notes
- Skipped and failed rows emit no
Event. Event-emission failures are logged
but never abort the bulk op. - Aligns with cycles-protocol spec v0.1.25.32.
See CHANGELOG.md and AUDIT.md for details.
v0.1.25.37 — Rule 1(c) bounded-convergence wired into close paths
v0.1.25.37 — Rule 1(c) bounded-convergence wired into close paths
Closes a spec-conformance gap: the server's OPERATIONS.md §"Tenant-close cascade" documented operator-issued re-close as the recovery path for an interrupted cascade, but the controller code silently short-circuited on already-CLOSED tenants before ever re-invoking the cascade service. Spec v0.1.25.31 Rule 1(c) requires Mode B implementations to both document and implement a bounded convergence mechanism; this release makes the code match the doc.
Fixed
PATCH /v1/admin/tenants/{id}withstatus=CLOSED— cascade now runs on every request regardless of prior status. On a retry against an already-CLOSED tenant, the parent event falls through totenant.updated(no duplicatetenant.closed); straggler children still emit their own*_via_tenant_cascadeevents via the cascade service.POST /v1/admin/tenants/bulk-actionwithaction=CLOSE— skips the redundantrepo.updateon already-CLOSED rows but still runs the cascade. Bucketedsucceededif any child transitioned,skipped(reason=ALREADY_IN_TARGET_STATE) if cascade was a full no-op — keeping the bulk-action response honest about whether state changed.
Operator-visible effect
An operator seeing a budget in FROZEN under a CLOSED tenant can now re-issue PATCH /v1/admin/tenants/{id} { "status": "CLOSED" } (or bulk-action CLOSE) and the straggler will transition, with the cascade emitting its own audit + *_via_tenant_cascade events for the picked-up child under a fresh correlation_id (new request_id). The OPERATIONS.md triage recipe now works as documented.
Unchanged
- No wire / OpenAPI / DTO contract change.
ErrorCode,EventType, and the cascade service's public contract unchanged.- Spec-coverage report:
declared=46 covered=46 missing=0.
Verification
mvn verify: 749 tests, 0 failures, 0 errors, BUILD SUCCESS (jacoco 95% line-coverage gate enforced at verify phase).OpenApiContractDiffTest+SpecCoverageReportTestboth green.- CI: all 8 checks passed (Unit & Contract, Integration with contract validation, CodeQL java-kotlin + actions, Trivy, PR Container Scan).
Spec & docs
- Target spec:
cycles-governance-admin-v0.1.25.yamlinfo.version0.1.25.31(CASCADE SEMANTICS — Mode B flip-first-with-guarded-cascade, Rule 1(c) bounded-convergence MUST). - See
AUDIT.mdv0.1.25.37 entry for before/after, spec citation, and test coverage. - See
CHANGELOG.mdfor the downstream-facing summary.
v0.1.25.36 — Rule 2 mutation guard coverage completed
Rolls up four CHANGELOG entries (0.1.25.33 → 0.1.25.36). Headline: spec v0.1.25.29 Rule 1 + Rule 2 are fully implemented, plus two security patches for CVE coverage. No wire break; additive to the existing error_code, EventType, and audit-payload surfaces.
0.1.25.36 — Rule 2 mutation guard coverage completed
Closes the conformance gap flagged in the v0.1.25.35 AUDIT entry. Every mutating admin-plane operation now returns 409 TENANT_CLOSED when the owning tenant is CLOSED, per spec v0.1.25.29 Rule 2 (MUST).
New TerminalOwnerMutationGuard callsites:
POST /v1/admin/policies,PATCH /v1/admin/policies/{id}POST /v1/admin/api-keys,PATCH /v1/admin/api-keys/{id},DELETE /v1/admin/api-keys/{id}POST /v1/admin/webhooks,PATCH /v1/admin/webhooks/{id},DELETE /v1/admin/webhooks/{id},POST /v1/admin/webhooks/{id}/test,POST /v1/admin/webhooks/{id}/replayDELETE /v1/webhooks/{id},POST /v1/webhooks/{id}/test- Per-row in
POST /v1/admin/budgets/bulk-actionandPOST /v1/admin/webhooks/bulk-action— closed-owner rows land infailed[]witherror_code: "TENANT_CLOSED"; sibling rows still proceed.
Paired with cycles-protocol spec revisions v0.1.25.30 (PRs #70 + #71), which enumerate 409 TENANT_CLOSED on all 10 ops in the spec's response catalog.
0.1.25.35 — Tenant-close cascade (Rule 1) + initial Rule 2 guard
Rule 1 cascade — when a tenant transitions to CLOSED, owned objects transition atomically in the same request:
BudgetLedger→CLOSED(stampsclosed_at; releases any outstandingreserved)WebhookSubscription→DISABLEDApiKey→REVOKED(reasontenant_closed)- Budgets with
reserved > 0emit an aggregatereservation.released_via_tenant_cascadeevent with the total released amount
Every audit + event in the cascade shares the originating request_id + trace_id plus correlation_id = tenant_close_cascade:<tenant_id>:<request_id>. Triggered by both PATCH /admin/tenants/{id} with status=CLOSED and the tenants:bulk-action CLOSE path; idempotent on already-closed tenants.
Initial Rule 2 guard (pre-.36) — budget + webhook-admin-create/update mutations.
Four new event kinds on EventType: BUDGET_CLOSED_VIA_TENANT_CASCADE, RESERVATION_RELEASED_VIA_TENANT_CASCADE, WEBHOOK_DISABLED_VIA_TENANT_CASCADE, API_KEY_REVOKED_VIA_TENANT_CASCADE. New WEBHOOK event category.
Tenant close payload gains cascade_summary (budgets_closed, webhooks_disabled, api_keys_revoked, reservations_released) so downstream consumers read the outcome without a follow-up query.
0.1.25.34 — Security: commons-lang3 3.18.0 pin
CVE-2025-48924 follow-up — Trivy HIGH finding not covered by Spring Boot 3.5.13's managed BOM. Added <commons-lang3.version>3.18.0</commons-lang3.version> override alongside the Tomcat override. Removable when SB ships 3.18.0+ managed. No API surface change.
0.1.25.33 — Security: Spring Boot 3.5.13 + Tomcat 10.1.54 pin
Closes 4 HIGH/CRITICAL CVEs against tomcat-embed-core 10.1.52 (CVE-2026-29145 CRITICAL, CVE-2026-29129 HIGH, CVE-2026-34483 HIGH, CVE-2026-34487 HIGH). SB 3.5.13 → Tomcat 10.1.53 transitively; <tomcat.version>10.1.54</tomcat.version> override picks up the remaining two. Patch-level bump within 3.5.x — no API surface change.
Conformance target
- Protocol:
cycles-protocol v0.1.25 - Admin spec:
cycles-governance-admin v0.1.25.30(full 10-op409 TENANT_CLOSEDcoverage)
Tests / CI
- 747 tests green; spec coverage
declared=46 covered=46 missing=0 OpenApiContractDiffTest+DtoConstraintContractTestgreen against spec v0.1.25.30
Image
ghcr.io/runcycles/cycles-server-admin:0.1.25.36ghcr.io/runcycles/cycles-server-admin:latest
v0.1.25.32 — lenient deserialization on cross-plane read schemas
Cross-plane read tolerance hardening — closes the second of two items from the post-v0.1.25.31 alignment audit against runtime v0.1.25.14 (paired with runcycles/cycles-server#108 which closed the first). No spec change, no wire change — read-side tolerance adjustment in admin only.
Changed
- Lenient deserialization on cross-plane read schemas.
EventandWebhookDeliverynow declare@JsonIgnoreProperties(ignoreUnknown = true)at the class level. Runtime (cycles-server) is the authoritative writer ofevent:*anddelivery:*Redis records; admin only reads them. Previously the admin POJOs wereignoreUnknown = false— so any additive field runtime shipped in a patch release would break admin'slistEvents/listWebhookDeliverieswithUnrecognizedPropertyExceptionuntil admin lockstep-updated the POJO. This violated the "additive fields are safe" invariant the admin/runtime split is built on. Runtime can now ship additive fields in any patch without forcing an admin release. - Hygiene: removed a dead
@JsonIgnoreProperties(ignoreUnknown = false)fromErrorResponse. That annotation was never reachable at runtime (admin writesErrorResponseto the wire; no reader path exists inside admin), so it was inert.
Unchanged (scope discipline)
- Strict mode preserved on admin-owned schemas:
WebhookSubscription,Tenant,Budget,Policy,ApiKey, everyEventData*subtype, everyBulk*Request/Bulk*Filter, every*CreateRequest/*UpdateRequest. Admin writes these — a typo there is an admin-internal bug and must fail loudly. The lenient tolerance is scoped to schemas runtime writes.
Internal
- No wire contract change. No spec edits (
cycles-governance-admin-v0.1.25.yamlstill atinfo.version=0.1.25.28). - Two test cases pin the invariant:
EventModelTest#event_tolerantOfUnknownFieldAddedByRuntimeandWebhookModelTest#webhookDelivery_tolerantOfUnknownFieldAddedByRuntime. A future regression — someone re-addingignoreUnknown = false— would fail these tests immediately.
Compatibility
- No client-visible behavior change. Admin-written request/response payloads still validate strictly.
- No DB migration.
- No upgrade ordering with runtime — that's the whole point of the change.
v0.1.25.31 — W3C Trace Context cross-surface correlation
v0.1.25.31 — W3C Trace Context cross-surface correlation
Server implementation of cycles-protocol admin spec v0.1.25.28 (PRs #56 + #58). Adds a third correlation tier on top of the existing request_id / correlation_id that spans an HTTP request, every event it emits, the audit entry it produces, and any outbound webhook delivery. Operators now have a first-class JOIN key for bulk-action fan-outs, multi-request operator sessions, and upstream distributed traces.
Contract: cycles-governance-admin-v0.1.25.yaml info.version 0.1.25.28 — declares trace_id on Event / AuditLogEntry / ErrorResponse / WebhookDelivery, the X-Cycles-Trace-Id response header, and trace_id / request_id query params on listAuditLogs / listEvents. Fully additive.
Added
-
trace_id(optional,^[0-9a-f]{32}$) on response bodies:ErrorResponse.trace_id— populated on every error response.AuditLogEntry.trace_id— populated on every audit entry written for an HTTP-originated operation.Event.trace_id— populated on every event emitted inside a servlet request (auto-propagated fromRequestContextHolderso none of the 13 existingemit(...)call sites changed signature).WebhookDelivery.trace_id+trace_flags+traceparent_inbound_valid— captured at dispatch time for thecycles-server-eventssidecar to construct a conformant outboundtraceparentwith inbound-flags preservation.
-
X-Cycles-Trace-Idresponse header — emitted on every response (2xx / 4xx / 5xx). Clients ignore unknown response headers per HTTP contract, so non-breaking. -
Inbound precedence — the server extracts
trace_idfrom the first rule that matches:traceparentheader (W3C Trace Context §3.2, version00, non-all-zero trace-id + span-id).X-Cycles-Trace-Idheader (32 lowercase hex, non-all-zero).- Server-generated (16 random bytes → 32 lowercase hex; all-zero re-rolled per W3C §3.2.2.3).
A malformed correlation header is treated as absent (falls through to the next rule); the server never rejects a request on a bad correlation header. Valid inbound trace-flags are preserved for outbound webhook delivery so an upstream
sampled=0opt-out is respected rather than silently re-enabled. -
New list-endpoint filters —
listAuditLogsandlistEventsgaintrace_id+request_idquery params. Exact-match, post-hydration predicate, null-safe (null-field entries never match a supplied filter value so historical pre-upgrade rows aren't silently returned). -
TraceContextFilter— new servlet filter@Order(2)running immediately afterRequestIdFilter @Order(1). Extracts trace_id, stamps request attributes (traceId/traceFlags/traceparentInboundValid), and writes theX-Cycles-Trace-Idresponse header.
Non-breaking by design
- All new wire fields are optional; historical rows persisted before this upgrade continue to round-trip through strict Jackson (
JsonIgnoreProperties(ignoreUnknown = false)is satisfied because the fields ARE declared, just@JsonInclude(NON_NULL)). - Inbound headers are additive; old clients don't send them.
- Outbound
X-Cycles-Trace-Idis additive; clients accepting unknown response headers (all of them, per HTTP contract) are unaffected. - Query-param additions follow the spec's additive-parameter guarantee — older servers that don't implement them MUST ignore; this server implements them.
What this enables
- JOIN an audit entry with the events it caused (same
trace_id). - JOIN N related API calls from one user intent (same client-supplied
traceparent). - Propagate Cycles operations into the subscriber's distributed trace (standard OTel
traceparenton outbound webhook). - Bulk-action fan-out triage — one
request_id+ onetrace_id→ N events + 1 audit entry, all joinable. - Dashboard filter "show all events/audits for trace X" becomes one query across both list endpoints.
Spec gap closure (v0.1.25.27 → v0.1.25.28)
The cross-surface correlation feature originally shipped against admin spec v0.1.25.27 (cycles-protocol#56). PR #116 review surfaced a gap: the WebhookDelivery schema declared additionalProperties: false but did not include the three trace-metadata fields this server persists on every delivery row. Rather than ship a workaround, the spec was patched via cycles-protocol#58 to declare trace_id / trace_flags / traceparent_inbound_valid on WebhookDelivery. This server conforms cleanly against v0.1.25.28.
Coverage
- 719 / 719 api-module unit tests green (14 dedicated
TraceContextFilterTestcases covering precedence, all-zero re-roll, uppercase rejection, flag preservation, uniqueness). - 480 / 480 data-module unit tests green.
- Integration + contract tests green.
- JaCoCo ≥95% per module.
SpecCoverageReportTestclean against cycles-governance-admin-v0.1.25.yaml (46/46 operations).
Upgrade
- Docker image:
ghcr.io/runcycles/cycles-server-admin:0.1.25.31 - No config changes required.
- Client regen recommended if you consume the admin OpenAPI — the new
trace_idfield appears onEvent,AuditLogEntry,ErrorResponse, andWebhookDelivery, and new query params appear onlistAuditLogs/listEvents. Old generated clients continue to work (additive), but won't surfacetrace_idto your application code until regenerated.
Operator runbook
See OPERATIONS.md § Cross-surface correlation (trace_id) for the full trace-id runbook, including worked curl examples for capturing X-Cycles-Trace-Id and JOIN-ing via listAuditLogs / listEvents.
Downstream follow-ups
cycles-server-eventssidecar: construct outboundtraceparentfrom the newWebhookDelivery.trace_id+trace_flags+traceparent_inbound_validtriple on HTTP delivery (preserves inbound flags when valid; defaults to01otherwise).cycles-dashboard: surfacetrace_idas a first-class filter on audit + events pages; add a "JOIN on trace" link from any row.
v0.1.25.30 — bulk-action audit metadata enrichment
v0.1.25.30 — bulk-action audit metadata enrichment for triage
Triage enrichment for all three bulk-action endpoints (bulkActionTenants, bulkActionWebhooks, bulkActionBudgets). Before v0.1.25.30 the single AuditLogEntry emitted per bulk-op carried only bucket counts + idempotency_key in its metadata — post-incident triage required the operator's own copy of the response envelope or re-running the op (not acceptable for destructive actions like DELETE or DEBIT). Now the audit log entry alone is sufficient.
Contract: cycles-governance-admin-v0.1.25.yaml info.version 0.1.25.26 — no spec bump required. AuditLogEntry.metadata is already typed object with additionalProperties: true, so key additions are spec-compatible.
Added metadata keys
Emitted on GET /v1/admin/audit/logs entries where operation ∈ { bulkActionTenants, bulkActionWebhooks, bulkActionBudgets }:
| Key | Type | Purpose |
|---|---|---|
succeeded_ids |
string[] |
Per-row ids of successful operations — paper trail for compliance. |
failed_rows |
BulkActionRowOutcome[] |
Full id + error_code + message per failure — replaces "re-run to see what broke". |
skipped_rows |
BulkActionRowOutcome[] |
Full id + reason per skip — distinguishes ALREADY_IN_TARGET_STATE from ALREADY_DELETED. |
filter |
object | Normalized filter echoed as-is — reconstructs operator intent from audit alone. |
duration_ms |
int64 | Handler-entry → audit-emit wall-clock for SLO triage without needing trace sampling. |
Changed
- Consolidated the three previously-inline
auditMeta.put(...)blocks into a singleBulkActionAuditMetadataBuilder.build(...)helper so future bulk-action endpoints cannot drift on key set or ordering.LinkedHashMappins the documented field order. - Each controller (
TenantController,WebhookAdminController,BudgetController) now capturesSystem.nanoTime()as the first statement of its bulk-action handler soduration_msmeasures match + apply end-to-end.
Unchanged
- Response shape and HTTP semantics of all three bulk-action endpoints. The synchronous envelope returned to callers is byte-identical to v0.1.25.29.
- Existing metadata keys (
action,total_matched,succeeded,failed,skipped,idempotency_key). No rename, no removal. - Auth model, idempotency semantics, safety gates (500-row cap,
expected_count, 15-min replay).
Migration notes
- Worst-case audit row size ~40 KB (500-row bulk-action cap × ~80 B per outcome + filter echo + fixed keys). Within Redis value-size comfort range; no new limits required. Audit-tooling that caps on entry-level JSON size should review.
- Dashboards parsing bulk-action audit entries gain five fields they may or may not display — fully backward compatible for consumers that ignore unknown keys.
Coverage
- 705/705 unit + contract tests green, JaCoCo ≥95% per module.
OpenApiContractDiffTest+SpecCoverageReportTestclean against spec v0.1.25.26 on cycles-protocol/main (46/46 operations).
Triage recipe (new)
See OPERATIONS.md § Bulk-action audit triage for the full runbook:
- Find the entry:
listAuditLogs?operation=bulkAction*&tenant_id=… - Compare
total_matchedvssucceeded + failed + skipped. - Classify each
failed_rows[i]byerror_code. - Narrow the filter to the failing ids, re-run with a new
idempotency_key.
Upgrade
- Docker image:
ghcr.io/runcycles/cycles-server-admin:0.1.25.30 - No config changes required.
- No client regen required (spec version unchanged).
v0.1.25.29 — Budget bulk-action endpoint
v0.1.25.29 — Budget bulk-action endpoint
Closes #99. Operators rolling over a billing period no longer need to iterate listBudgets + per-row fundBudget — one filtered bulk request applies any FundingOperation across up to 500 ledgers with an atomic count-gate. Fifth bulk-action endpoint; same envelope as tenants / webhooks / api-keys / policies.
Contract: cycles-governance-admin-v0.1.25.yaml info.version 0.1.25.26.
Added
POST /v1/admin/budgets/bulk-action— AdminKeyAuth only. ApplyCREDIT / DEBIT / RESET / REPAY_DEBT / RESET_SPENT(reuses existingFundingOperationenum) to every budget matching aBudgetBulkFilter. Returns 200 withsucceeded[] / failed[] / skipped[]envelope carrying per-rowledger_id+error_code.BudgetBulkFilter— mirrorslistBudgetsquery params (scope_prefix,unit,status,over_limit,has_debt,utilization_min/max,search).tenant_idis required for cross-tenant safety. Matcher is shared withlistBudgetsviaBudgetListFilters.fromBulkFilter(...)so operator preview and bulk apply can never diverge.
Safety gates
- 500-row hard cap → 400
LIMIT_EXCEEDED(fail-fast before any writes). expected_countgate → 409COUNT_MISMATCHwhen server-observed match count differs from the caller's preview count (no writes performed).- 15-min idempotency replay via
IdempotencyStore— sameidempotency_keyreturns the cached envelope verbatim, partial failures and all. - Per-row derived idempotency (
bulkKey:scope:unitfed to the existing Lua fund idempotency) — retry-the-failed-set after the envelope TTL cannot double-apply CREDIT/DEBIT/etc. to previously-succeeded rows. - Row-level known codes:
BUDGET_EXCEEDED,INVALID_TRANSITION,NOT_FOUND,PERMISSION_DENIED,INTERNAL_ERROR. Skipped rows carryreason=ALREADY_IN_TARGET_STATE(REPAY_DEBT on zero debt). - AdminKeyAuth enforced —
AuthInterceptorexplicitly routes/v1/admin/budgets/bulk-actionto admin-key auth; tenant keys → 401.
Audit
- One
AuditLogEntryper bulk-op (not per row) withoperation=bulkActionBudgets,resourceId=bulk-action, and metadata counts (action / total_matched / succeeded / failed / skipped / idempotency_key). Per-row detail lives in the synchronous response envelope returned to the caller.
Also in this release (bundled 2026-04-18)
- v0.1.25.28.1 test fix —
AuditFailureSoakIntegrationTestAS4 tier-sum invariant now includes the__admin__sentinel delta (PR #113). v0.1.25.28 split the pre-auth sentinel into__unauth__/__admin__, but the soak-test invariant still summed only the old pair, causing the 2026-04-18 nightly to go red on a legitimate 400-wave. Production audit-write path was always correct — test-only gap.
Coverage
- 698/698 unit + contract tests green, JaCoCo ≥95% per module.
OpenApiContractDiffTest+SpecCoverageReportTestclean against spec v0.1.25.26 on cycles-protocol/main (46/46 operations).
Upgrade notes
- No breaking changes. New endpoint is additive; existing
fundBudgetremains the canonical per-row path. - Clients that want to take advantage of bulk-action should regenerate against admin spec v0.1.25.26.