Skip to content

Releases: runcycles/cycles-server-admin

v0.1.25.41 — drop tomcat override (SB 3.5.14 BOM-managed) + Jedis fleet alignment

26 Apr 13:31
7fec471

Choose a tag to compare

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, RandomValuePropertySource SecureRandom, consistent SSL hostname verification, ApplicationPidFileWriter/ApplicationTemp symlink fixes).
  • Drop <tomcat.version>10.1.54</tomcat.version> override. SB 3.5.14's BOM now manages Tomcat 10.1.54 directly (verified against spring-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) and cycles-server (6.2.0) on a single Redis-client major across the fleet. Jedis 6.1.0 explicitly restored binary compatibility for SetParams (#4225 upstream); all 782 tests pass on 6.2.0.
  • commons-lang3 3.18.0 override 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

23 Apr 16:31
2fcd496

Choose a tag to compare

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, and deleteWebhookSubscription now populate Actor.keyId from authenticated_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_fields is now a real diff. updateWebhookSubscription previously 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 in changed_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_secret keeps presence-based detection (stored value is encrypted; not safely comparable to plaintext request value).
  • B4 — Correlation-id uniqueness. The "no-req" literal fallback in webhook_update:<sub_id>:<request_id> and webhook_bulk_action:<action>:<request_id> is replaced with req_<uuid> fallback. Guarantees uniqueness across concurrent requests if RequestIdFilter ever 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

23 Apr 14:20
2dc2366

Choose a tag to compare

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 in cycles-server-events v0.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

22 Apr 12:04
2adfa75

Choose a tag to compare

v0.1.25.38 — bulk-action event parity (spec v0.1.25.32)

Added

  • Per-row Event emission on POST /v1/admin/budgets/bulk-action — one
    budget.funded / budget.debited / budget.reset / budget.reset_spent /
    budget.debt_repaid per successfully-mutated row, stamped
    correlation_id = budget_bulk_action:<action>:<request_id>.
  • Per-row Event emission on POST /v1/admin/tenants/bulk-action — one
    tenant.suspended / tenant.reactivated / tenant.closed per
    successfully-mutated row, stamped
    correlation_id = tenant_bulk_action:<action>:<request_id>. Cascade
    fan-out retains its own tenant_close_cascade:<tenant_id>:<request_id>
    (complementary, unchanged).

Unchanged

  • Aggregate AuditLogEntry per bulk-action invocation (spec v0.1.25.26 rule).
  • EventType enum — reuses existing per-op kinds.
  • TenantCloseCascadeService semantics.

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

21 Apr 10:37
17ec7c9

Choose a tag to compare

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} with status=CLOSED — cascade now runs on every request regardless of prior status. On a retry against an already-CLOSED tenant, the parent event falls through to tenant.updated (no duplicate tenant.closed); straggler children still emit their own *_via_tenant_cascade events via the cascade service.
  • POST /v1/admin/tenants/bulk-action with action=CLOSE — skips the redundant repo.update on already-CLOSED rows but still runs the cascade. Bucketed succeeded if 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 + SpecCoverageReportTest both 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.yaml info.version 0.1.25.31 (CASCADE SEMANTICS — Mode B flip-first-with-guarded-cascade, Rule 1(c) bounded-convergence MUST).
  • See AUDIT.md v0.1.25.37 entry for before/after, spec citation, and test coverage.
  • See CHANGELOG.md for the downstream-facing summary.

v0.1.25.36 — Rule 2 mutation guard coverage completed

20 Apr 18:31
fddb9f5

Choose a tag to compare

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}/replay
  • DELETE /v1/webhooks/{id}, POST /v1/webhooks/{id}/test
  • Per-row in POST /v1/admin/budgets/bulk-action and POST /v1/admin/webhooks/bulk-action — closed-owner rows land in failed[] with error_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:

  • BudgetLedgerCLOSED (stamps closed_at; releases any outstanding reserved)
  • WebhookSubscriptionDISABLED
  • ApiKeyREVOKED (reason tenant_closed)
  • Budgets with reserved > 0 emit an aggregate reservation.released_via_tenant_cascade event 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-op 409 TENANT_CLOSED coverage)

Tests / CI

  • 747 tests green; spec coverage declared=46 covered=46 missing=0
  • OpenApiContractDiffTest + DtoConstraintContractTest green against spec v0.1.25.30

Image

  • ghcr.io/runcycles/cycles-server-admin:0.1.25.36
  • ghcr.io/runcycles/cycles-server-admin:latest

v0.1.25.32 — lenient deserialization on cross-plane read schemas

18 Apr 23:39
e9405d0

Choose a tag to compare

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. Event and WebhookDelivery now declare @JsonIgnoreProperties(ignoreUnknown = true) at the class level. Runtime (cycles-server) is the authoritative writer of event:* and delivery:* Redis records; admin only reads them. Previously the admin POJOs were ignoreUnknown = false — so any additive field runtime shipped in a patch release would break admin's listEvents / listWebhookDeliveries with UnrecognizedPropertyException until 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) from ErrorResponse. That annotation was never reachable at runtime (admin writes ErrorResponse to 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, every EventData* subtype, every Bulk*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.yaml still at info.version=0.1.25.28).
  • Two test cases pin the invariant: EventModelTest#event_tolerantOfUnknownFieldAddedByRuntime and WebhookModelTest#webhookDelivery_tolerantOfUnknownFieldAddedByRuntime. A future regression — someone re-adding ignoreUnknown = 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

18 Apr 21:16
ceae762

Choose a tag to compare

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 from RequestContextHolder so none of the 13 existing emit(...) call sites changed signature).
    • WebhookDelivery.trace_id + trace_flags + traceparent_inbound_valid — captured at dispatch time for the cycles-server-events sidecar to construct a conformant outbound traceparent with inbound-flags preservation.
  • X-Cycles-Trace-Id response 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_id from the first rule that matches:

    1. traceparent header (W3C Trace Context §3.2, version 00, non-all-zero trace-id + span-id).
    2. X-Cycles-Trace-Id header (32 lowercase hex, non-all-zero).
    3. 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=0 opt-out is respected rather than silently re-enabled.

  • New list-endpoint filterslistAuditLogs and listEvents gain trace_id + request_id query 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 after RequestIdFilter @Order(1). Extracts trace_id, stamps request attributes (traceId / traceFlags / traceparentInboundValid), and writes the X-Cycles-Trace-Id response 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-Id is 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 traceparent on outbound webhook).
  • Bulk-action fan-out triage — one request_id + one trace_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 TraceContextFilterTest cases 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.
  • SpecCoverageReportTest clean 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_id field appears on Event, AuditLogEntry, ErrorResponse, and WebhookDelivery, and new query params appear on listAuditLogs / listEvents. Old generated clients continue to work (additive), but won't surface trace_id to 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-events sidecar: construct outbound traceparent from the new WebhookDelivery.trace_id + trace_flags + traceparent_inbound_valid triple on HTTP delivery (preserves inbound flags when valid; defaults to 01 otherwise).
  • cycles-dashboard: surface trace_id as 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

18 Apr 13:59
9951830

Choose a tag to compare

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.26no 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 single BulkActionAuditMetadataBuilder.build(...) helper so future bulk-action endpoints cannot drift on key set or ordering. LinkedHashMap pins the documented field order.
  • Each controller (TenantController, WebhookAdminController, BudgetController) now captures System.nanoTime() as the first statement of its bulk-action handler so duration_ms measures 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 + SpecCoverageReportTest clean 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:

  1. Find the entry: listAuditLogs?operation=bulkAction*&tenant_id=…
  2. Compare total_matched vs succeeded + failed + skipped.
  3. Classify each failed_rows[i] by error_code.
  4. 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

18 Apr 11:06
fce3712

Choose a tag to compare

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. Apply CREDIT / DEBIT / RESET / REPAY_DEBT / RESET_SPENT (reuses existing FundingOperation enum) to every budget matching a BudgetBulkFilter. Returns 200 with succeeded[] / failed[] / skipped[] envelope carrying per-row ledger_id + error_code.
  • BudgetBulkFilter — mirrors listBudgets query params (scope_prefix, unit, status, over_limit, has_debt, utilization_min/max, search). tenant_id is required for cross-tenant safety. Matcher is shared with listBudgets via BudgetListFilters.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_count gate → 409 COUNT_MISMATCH when server-observed match count differs from the caller's preview count (no writes performed).
  • 15-min idempotency replay via IdempotencyStore — same idempotency_key returns the cached envelope verbatim, partial failures and all.
  • Per-row derived idempotency (bulkKey:scope:unit fed 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 carry reason=ALREADY_IN_TARGET_STATE (REPAY_DEBT on zero debt).
  • AdminKeyAuth enforcedAuthInterceptor explicitly routes /v1/admin/budgets/bulk-action to admin-key auth; tenant keys → 401.

Audit

  • One AuditLogEntry per bulk-op (not per row) with operation=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 fixAuditFailureSoakIntegrationTest AS4 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 + SpecCoverageReportTest clean against spec v0.1.25.26 on cycles-protocol/main (46/46 operations).

Upgrade notes

  • No breaking changes. New endpoint is additive; existing fundBudget remains the canonical per-row path.
  • Clients that want to take advantage of bulk-action should regenerate against admin spec v0.1.25.26.