---
title: ScaiBunker Quota Reference
path: reference/scaibunker-quotas
status: published
---

# ScaiBunker Quota Reference

Profile-based resource quotas for bunker creation. Profiles bundle caps; assignments link them to users or groups; the resolver enforces the most-restrictive cap when multiple profiles apply.

For concepts, see [ScaiBunker → Quotas](/docs/scaigrid/scaibunker#quotas).

## Object model

### Profile

```json
{
  "id": "uuid",
  "tenant_id": "uuid | null",
  "name": "string",
  "description": "string | null",
  "max_concurrent_bunkers": 5,
  "max_persistent_bunkers": 1,
  "max_cpu_millicores": 4000,
  "max_memory_mb": 4096,
  "max_disk_mb": 8192,
  "max_gpu_count": 1,
  "per_bunker_max_cpu": null,
  "per_bunker_max_memory": null,
  "per_bunker_max_disk": null,
  "per_bunker_max_gpu": null,
  "created_at": "2026-05-09T...",
  "updated_at": "2026-05-09T..."
}
```

| Field | Meaning |
|---|---|
| `tenant_id` | `null` for platform-default profiles managed by super-admin; tenant UUID for tenant-scoped profiles |
| `max_concurrent_bunkers` | Cap on the number of active (non-terminal) bunkers in the bucket |
| `max_persistent_bunkers` | Cap on the persistent-mode subset |
| `max_cpu_millicores` | Sum of CPU across the bucket's active bunkers |
| `max_memory_mb` / `max_disk_mb` / `max_gpu_count` | Same, for those resources |
| `per_bunker_max_*` | Single-bunker ceiling; `null` = no per-bunker cap |

### Assignment

```json
{
  "id": "uuid",
  "profile_id": "uuid",
  "target_kind": "user | group",
  "target_id": "uuid",
  "mode": "individual | shared | per_user",
  "created_at": "2026-05-09T..."
}
```

| `target_kind` | `mode` | Bucket key | Meaning |
|---|---|---|---|
| `user` | `individual` | `user:{user_id}` | Direct per-user quota |
| `group` | `shared` | `group:{group_id}` | All members share one bucket |
| `group` | `per_user` | `user:{user_id}` | Each member gets their own copy of the profile |

A target (user or group ID) can have at most one assignment — `UNIQUE(target_kind, target_id)`.

## Resolution algorithm

At bunker create:

1. Collect all profiles applying to the caller:
   - The caller's direct user assignment, if any.
   - Every group assignment whose `target_id` matches a group the
     caller is a transitive member of (ScaiKey nested-group expansion).
2. If nothing matches, fall back to the platform-default profile.
3. For each profile:
   - **Per-bunker checks:** if any `per_bunker_max_*` is non-null and the
     request exceeds it, fail.
   - **Aggregate checks:** read the bucket's current usage from
     Redis-backed counters. If `current + request` exceeds any
     aggregate cap, fail.
4. Most-restrictive wins because every profile's check has to pass.
5. The platform `PLATFORM_MAX_*` constants are an absolute outer
   ceiling; no profile can grant past them.

When a check fails, the response is `BUNKER_QUOTA_EXCEEDED` with a
message naming the cap and bucket (e.g. `"Per-bunker CPU 8000m exceeds
profile 'team-shared' cap of 4000m"` or `"Concurrent bunker limit (5)
reached on group:G1 (profile 'team-shared')"`).

## Endpoints

### GET /v1/modules/scaibunker/quota-profiles

List visible profiles.

- Super-admin: all profiles platform-wide.
- Tenant caller: tenant-owned profiles plus platform defaults.
- Anyone with `scaibunker:create` may read (the assignment dropdowns and
  the resolver fallback both need them).

Response:

```json
{
  "status": "success",
  "data": {
    "items": [
      { "...QuotaProfile..." }
    ]
  }
}
```

### POST /v1/modules/scaibunker/quota-profiles

Create a profile.

- Super-admin → creates a platform-default (`tenant_id: null`).
- Tenant admin (`scaibunker:admin:tenant`) → creates a tenant-scoped
  profile. `tenant_id` is auto-set from the caller's tenant.

Request body:

```json
{
  "name": "team-shared",
  "description": "ML team shared bucket",
  "max_concurrent_bunkers": 10,
  "max_persistent_bunkers": 2,
  "max_cpu_millicores": 16000,
  "max_memory_mb": 32768,
  "max_disk_mb": 65536,
  "max_gpu_count": 4,
  "per_bunker_max_cpu": 4000,
  "per_bunker_max_memory": 8192,
  "per_bunker_max_disk": null,
  "per_bunker_max_gpu": 1
}
```

Returns `201 Created` with the full profile. Fails with
`QUOTA_PROFILE_CONFLICT` if the `(tenant_id, name)` pair already exists.

### GET /v1/modules/scaibunker/quota-profiles/{id}

Fetch one profile. Visibility rules same as list.

### PATCH /v1/modules/scaibunker/quota-profiles/{id}

Partial update. Only fields present in the body are touched.

- Tenant-scoped profile → tenant admin in same tenant, or super-admin.
- Platform-default → super-admin only.

```json
{ "max_concurrent_bunkers": 20 }
```

### DELETE /v1/modules/scaibunker/quota-profiles/{id}

Delete a profile. Same auth rules as `PATCH`. Cascades to assignments;
affected users fall back to the platform default.

### GET /v1/modules/scaibunker/quota-profiles/{id}/assignments

List assignments for one profile.

```json
{
  "status": "success",
  "data": {
    "items": [
      { "...QuotaAssignment..." }
    ]
  }
}
```

### POST /v1/modules/scaibunker/quota-profiles/{id}/assignments

Assign a user or group to a profile.

```json
{
  "target_kind": "user",
  "target_id": "<user-uuid>",
  "mode": "individual"
}
```

or

```json
{
  "target_kind": "group",
  "target_id": "<scaikey-group-id>",
  "mode": "per_user"
}
```

Validation:

- `target_kind: user` ⇒ `mode` must be `individual`.
- `target_kind: group` ⇒ `mode` must be `shared` or `per_user`.

Returns `409 QUOTA_ASSIGNMENT_CONFLICT` if `(target_kind, target_id)` is
already assigned to any profile.

### DELETE /v1/modules/scaibunker/quota-profiles/{id}/assignments/{assignment_id}

Remove an assignment. Idempotent — already-deleted assignments return
`204` rather than `404`.

## Error codes

| Code | HTTP | Cause |
|---|---|---|
| `QUOTA_PROFILE_NOT_FOUND` | 404 | Profile doesn't exist or isn't visible to caller |
| `QUOTA_ASSIGNMENT_CONFLICT` | 409 | Target already has an assignment |
| `BUNKER_QUOTA_EXCEEDED` | 400 | A profile's cap would be exceeded by the request |
| `AUTHZ_PERMISSION_DENIED` | 403 | Caller lacks `scaibunker:admin:tenant` (tenant-scope) or super-admin (platform-default) |

## Worked example

A tenant admin builds a quota plan for the ML team:

1. **Create a `team-shared` profile** capping the ML group's combined
   usage at 16 GPU, 64 CPU, 64 GiB memory, 16 concurrent bunkers, plus
   a per-bunker cap of 4 GPU / 16 CPU / 16 GiB so no single user can
   monopolise. ([POST /quota-profiles](#post-v1modulesscaibunkerquota-profiles))
2. **Assign the ML group with `mode=shared`.**
   ([POST /quota-profiles/{id}/assignments](#post-v1modulesscaibunkerquota-profilesidassignments))
3. **Create a `senior-ml` profile** with bigger per-bunker caps (8 GPU,
   32 CPU) for senior engineers who need to run large training jobs.
4. **Assign individual senior engineers with `mode=individual`.** Their
   direct assignment stacks with the group `shared` assignment — they
   get the bigger per-bunker headroom *and* still consume from the
   shared aggregate bucket. Most-restrictive wins, so the group bucket
   is still the binding constraint on aggregate usage.

Result: the team's collective spend is bounded; juniors can't spawn
giant bunkers; seniors can but still share the team budget with
everyone else.
