Apply a lifecycle action to every tenant matching a filter
Apply SUSPEND, REACTIVATE, or CLOSE to every tenant matching the provided filter in a single synchronous request. Lets admin dashboards execute "pause all tenants with status ACTIVE matching 'acme'" without paginating the list and issuing one PATCH per row (which produces partial-failure modes and thundering-herd request bursts at scale).
SEMANTICS: - The filter block mirrors the query params of listTenants
(status, parent_tenant_id, observe_mode, search). Filter
semantics (ILIKE match, AND combination) are identical to the
list endpoint — operators can preview the affected set with
GET /v1/admin/tenants and the same filter before invoking
bulk-action.
- Server counts the filter match BEFORE mutation. If
expected_countis supplied and does not equal the server's
count, the server MUST reject with HTTP 409 COUNT_MISMATCH and
perform no writes. This is the anti-footgun gate — the
dashboard shows "49 tenants match" and passes 49; if the
filter drifted (new tenant created between preview and submit),
the mismatch aborts the run. idempotency_key(operator-supplied, typically a UUID v4) is
remembered by the server for 15 minutes. A repeated submit
with the same key returns the ORIGINAL response without
re-applying the action — safe retry on network error or
accidental double-click.- HARD LIMIT: 500 matching rows per invocation. If the filter
matches more than 500, server MUST reject with HTTP 400
LIMIT_EXCEEDED (body includestotal_matched). Operator must
narrow the filter. This bounds the worst-case transaction
size and keeps the request synchronous. - Per-row failure is recorded in the response
failed[]array;
the bulk op does NOT abort on individual row failure. Each
entry carries a row-level error_code (e.g. INVALID_STATE
TRANSITION). Overall HTTP status is 200 even when some rows
fail — the response envelope reports success/failure counts.
ACTION SEMANTICS: - SUSPEND: tenant.status ACTIVE → SUSPENDED. Idempotent when
already SUSPENDED (row goes into skipped[]). Invalid from
CLOSED (row goes into failed[] with INVALID_TRANSITION).
- REACTIVATE: SUSPENDED → ACTIVE. Idempotent when already
ACTIVE. Invalid from CLOSED. - CLOSE: any status → CLOSED. Terminal — not reversible via
this API. Each matched row triggers the full tenant-close
cascade (see CASCADE SEMANTICS in info.description):
owned budgets → CLOSED, API keys → REVOKED, open
reservations → RELEASED, webhook subscriptions →
DISABLED. Per CASCADE SEMANTICS Rule 1 the server MAY
execute the cascade atomically (Mode A) or
flip-first-with-guarded-post-flip-cascade (Mode B).
Per-row failure handling depends on the mode: under Mode
A, cascade failure leaves the row in its prior status
and lands infailed[]with an implementation-specific
error_code; under Mode B, the tenant status flip succeeds
independently of cascade completion, so a partially-
applied cascade leaves the row infailed[]WITH the
tenant already in CLOSED status (cascade converges via
Rule 1(c)). Successful rows land inupdated[]. Other
rows are unaffected (bulk op does not abort on individual
row failure, per SEMANTICS above). Implementations MAY
require additional confirmation (e.g. requiring
allow_close: truein the request body) in future minor
revisions.
EVENTS (document revision 0.1.25.32): - Server MUST emit one Event per successfully-mutated row,
typed by action: SUSPEND → tenant.suspended,
REACTIVATE → tenant.reactivated, CLOSE → tenant.closed.
Skipped rows (ALREADY_IN_TARGET_STATE) and failed rows
MUST NOT produce an Event — emission is bound to actual
state transition, matching the single-op PATCH path.
- Each emitted Event carries
correlation_id = tenant_bulk_action:<action>:<request_id>
(action lowercased, e.g.tenant_bulk_action:close:req_abc).
Operators retrieve the full bulk fan-out via
GET /v1/admin/events?correlation_id=…; the shared
request_id also ties every emitted row to the invocation's
single AuditLogEntry. - For action=CLOSE, the parent
tenant.closedevent and the
per-child cascade fan-out (budget.closed_via_tenant_cascade,
webhook.disabled_via_tenant_cascade,
api_key.revoked_via_tenant_cascade,
reservation.released_via_tenant_cascade) carry DIFFERENT
correlation_ids: the parent uses
tenant_bulk_action:close:<request_id>; the cascade
children retain the Rule 1 identity
tenant_close_cascade:<tenant_id>:<request_id>
(unchanged from document revision 0.1.25.29). The two
identities are complementary — operators tracing a
specific tenant's close query the cascade id; operators
tracing the bulk invocation as a whole query the bulk id.
AUDIT LOG: - One AuditLogEntry per bulk-action invocation (not per
row). The entry records actor_type=ADMIN_ON_BEHALF_OF,
operation=bulkActionTenants, and embeds the full per-row
outcome in the entry payload. Per-row Events (above) are
the operator-visible audit trail; the AuditLogEntry is
the compliance-facing invocation record.
AUTHORIZATION: - AdminKeyAuth only. No ApiKeyAuth path — tenants cannot
bulk-mutate their own peers.
Authorizations
Administrative API key with full system access. Also accepted as an alternative to ApiKeyAuth on an explicit per-operation allowlist — the authoritative list is the union of operations whose security: block declares AdminKeyAuth (consult per-operation security blocks rather than this prose, which has historically drifted as the dual-auth surface expanded). When using AdminKeyAuth on list or fund endpoints, a tenant scoping parameter (typically tenant or tenant_id) is required for scoping (400 if missing) — the per-operation description specifies which. Lookup-style endpoints that uniquely identify a resource by non-tenant key (e.g. GET /v1/admin/budgets/lookup, where the (scope, unit) pair is unique) do NOT require a tenant parameter. Allowlisting is per-operation (exact method:path matching — no prefix matching, no wildcards) so new endpoints do not accidentally inherit admin-accessible status.
Request Body
Responses
Bulk action applied (may include per-row failures)