# Workspace CRD

The Workspace custom resource defines a multi-tenant workspace with isolated namespace, RBAC, and network isolation in Kubernetes.

## API version

```yaml
apiVersion: omnia.altairalabs.ai/v1alpha1
kind: Workspace
```

**Short name:** `ws` (e.g. `kubectl get ws`).

## Resource scope

Workspace is a **cluster-scoped** resource. It creates and manages resources in its associated namespace.

## Spec fields

### `displayName`

Human-readable name for the workspace shown in the dashboard.

| Field | Type | Required |
|-------|------|----------|
| `displayName` | string | Yes |

```yaml
spec:
  displayName: "Customer Support Team"
```

### `description`

Optional description of the workspace.

| Field | Type | Required |
|-------|------|----------|
| `description` | string | No |

```yaml
spec:
  description: "Team responsible for customer support AI agents"
```

### `environment`

Environment tier for the workspace. The controller writes it to the workspace
namespace as the `omnia.altairalabs.ai/environment` label, and it is shown in the
`Environment` column of `kubectl get workspaces` and on the dashboard's workspace
overview.

| Field | Type | Default | Required |
|-------|------|---------|----------|
| `environment` | string | development | No |

Allowed values: `development`, `staging`, `production`.

```yaml
spec:
  environment: production
```

### `defaultTags`

Labels applied to the workspace namespace, alongside `namespace.labels`. The
controller adds them when it creates the namespace and reconciles them onto an
existing one. Use them for cost attribution and resource organization.

| Field | Type | Required |
|-------|------|----------|
| `defaultTags` | map[string]string | No |

```yaml
spec:
  defaultTags:
    team: "customer-support"
    cost-center: "CC-1234"
    business-unit: "support-ops"
```

### `namespace`

Kubernetes namespace configuration for the workspace.

| Field | Type | Required |
|-------|------|----------|
| `namespace.name` | string | Yes |
| `namespace.create` | boolean | No (default: false) |
| `namespace.labels` | map[string]string | No |
| `namespace.annotations` | map[string]string | No |

`namespace.name` must be a valid DNS label (1–63 characters). When the namespace
does not exist and `create` is false, reconciliation fails with a
`NamespaceReady=False` condition. `labels` are applied at creation and reconciled
onto an existing namespace; `annotations` are applied only when the controller
creates the namespace.

A namespace belongs to exactly one Workspace. When the operator's admission
webhooks are enabled (Helm `webhook.enabled`), a Workspace whose `namespace.name`
is already claimed by another Workspace is rejected at admission. Either way, the
controller refuses to adopt a namespace labelled as owned by a different Workspace
(`NamespaceReady=False`, reason `NamespaceConflict`).

```yaml
spec:
  namespace:
    name: omnia-customer-support
    create: true
    labels:
      environment: production
    annotations:
      cost-center: "cc-12345"
```

### `roleBindings`

Maps IdP groups and ServiceAccounts to workspace roles.

| Field | Type | Required |
|-------|------|----------|
| `roleBindings[].groups` | []string | No |
| `roleBindings[].serviceAccounts` | []ServiceAccountRef | No |
| `roleBindings[].role` | string | Yes |

ServiceAccountRef:

| Field | Type | Required |
|-------|------|----------|
| `name` | string | Yes |
| `namespace` | string | Yes |

Available roles:

| Role | Description |
|------|-------------|
| `owner` | Full workspace control including member management |
| `editor` | Create/modify resources but cannot manage members |
| `viewer` | Read-only access to resources |

Group entries are resolved at request time against the caller's IdP group claims,
by exact, case-sensitive match; a user gets the highest role of any matching binding. For each ServiceAccount
entry the controller creates a Kubernetes RoleBinding in the workspace namespace
to the matching `omnia-workspace-<role>` ClusterRole, so the ServiceAccount can
call the Kubernetes API directly.

```yaml
spec:
  roleBindings:
    # Map IdP groups to roles
    - groups:
        - "omnia-admins@acme.com"
      role: owner

    - groups:
        - "omnia-engineers@acme.com"
        - "engineering-team"
      role: editor

    - groups:
        - "contractors@acme.com"
      role: viewer

    # Grant access to ServiceAccounts for CI/CD
    - serviceAccounts:
        - name: github-actions
          namespace: ci-system
        - name: argocd-application-controller
          namespace: argocd
      role: editor
```

### `directGrants`

Per-user grants for exceptions to `roleBindings`.

| Field | Type | Required |
|-------|------|----------|
| `directGrants[].user` | string | Yes |
| `directGrants[].role` | string | Yes |
| `directGrants[].expires` | string (RFC3339) | No |

`user` is matched case-insensitively against the caller's email or username. A
grant whose `expires` time has passed confers no role.

```yaml
spec:
  directGrants:
    - user: emergency-admin@acme.com
      role: owner
      expires: "2026-02-01T00:00:00Z"  # Temporary access
```

### `anonymousAccess`

Configures access for unauthenticated users.

| Field | Type | Required |
|-------|------|----------|
| `anonymousAccess.enabled` | boolean | Yes |
| `anonymousAccess.role` | string | No (default: viewer) |

When enabled, the anonymous role is also a floor for authenticated users: a
signed-in user never holds less than an anonymous visitor would, while a higher
granted role is kept.

> **Warning**: Granting editor or owner access allows anonymous users to modify resources. Only use in isolated development environments.

```yaml
spec:
  anonymousAccess:
    enabled: true
    role: viewer  # Read-only for anonymous users
```

### `runtime`

Workspace-wide pod defaults applied to every AgentRuntime in the workspace: the ServiceAccount and pod labels that bind a runtime pod to a cloud workload identity (Azure Workload Identity, AWS IRSA, GKE Workload Identity), so providers with `auth.type: workloadIdentity` authenticate without a stored secret. It also carries the RuntimeClass those pods run under.

| Field | Type | Required |
|-------|------|----------|
| `runtime.serviceAccountName` | string | No |
| `runtime.podLabels` | map[string]string | No |
| `runtime.podAnnotations` | map[string]string | No |
| `runtime.runtimeClassName` | string | No |

| Field | Description |
|-------|-------------|
| `serviceAccountName` | The ServiceAccount every agent runtime pod in this workspace runs as, provisioned out of band (IaC) with the cloud identity annotations (`azure.workload.identity/client-id`, `eks.amazonaws.com/role-arn`, `iam.gke.io/gcp-service-account`). Empty means no default; agents fall back to the operator-created per-agent SA. |
| `podLabels` | Labels added to every agent runtime pod, e.g. `azure.workload.identity/use: "true"` to opt into the Azure webhook. AWS IRSA and GKE WLI need none. |
| `podAnnotations` | Annotations added to every agent runtime pod. |
| `runtimeClassName` | The RuntimeClass every agent runtime pod in this workspace runs under, such as a gVisor or Kata class. Empty means the cluster default. An agent's own `spec.podOverrides.runtimeClassName` replaces it. Unlike the three fields above, an agent that sets its own `serviceAccountName` still inherits it. It applies to AgentRuntime pods only: tool sandboxes take theirs from `SandboxClass.spec.runtimeClassName`, and session-api, memory-api and eval-workers from their own `podOverrides`. See [Run agents under gVisor or Kata](/how-to/security/run-agents-under-gvisor-or-kata/). |

```yaml
spec:
  runtime:
    serviceAccountName: ws-runtime-sa
    podLabels:
      azure.workload.identity/use: "true"
```

**Precedence.** An AgentRuntime that sets its own `spec.podOverrides.serviceAccountName` opts out of these defaults as a unit: neither the workspace SA nor the workspace pod labels are applied to it. Agents that set no SA inherit these defaults.

:::caution[Agents sharing one ServiceAccount are one identity]
Omnia identifies a calling agent by its ServiceAccount. Agents that share `runtime.serviceAccountName` can obtain delegation tokens naming any of the others, and each holds the union of their per-agent RBAC. The Sandbox API is the exception: it also checks the pod a caller's token is bound to, so an agent cannot reach another agent's sandboxes through it. Share a ServiceAccount only among agents you trust as one. For isolation, give an agent its own ServiceAccount with `spec.podOverrides.serviceAccountName` and federate that ServiceAccount: a federated identity credential on AKS, a trust-policy subject on EKS IRSA, a Pod Identity association on EKS, or a `roles/iam.workloadIdentityUser` binding on GKE.
:::

#### Workload-identity federation is provisioned out of band (IaC)

`runtime` is opaque passthrough: the operator references the ServiceAccount by name and applies the pod labels, but does not create the cloud-side federated trust (Azure FIC, AWS IRSA role/trust policy, or GKE WLI binding).

Each cloud's trust statement is per-exact-subject (`system:serviceaccount:<namespace>:<sa>` on Azure, the role trust policy on AWS, a specific KSA↔GSA binding on GKE): there are no wildcard subjects, so every workspace needs its own federated credential, even when sharing one managed identity and one SA name.

:::caution[Keyless providers require an IaC-provisioned workspace]
The annotated ServiceAccount and the cloud-side federated credential must be provisioned out of band by the same IaC (Terraform/Bicep/Helm/GitOps) that creates the `Workspace`. A workspace created dynamically (dashboard UI or API) has no IaC step, so nothing provisions the cloud-side credential for its runtime SA: agents there cannot use `auth.type: workloadIdentity` until the FIC/IRSA role/WLI binding is created out of band. Use secret-based provider auth (`servicePrincipal`, `accessKey`, `serviceAccount`) instead, or pre-provision a shared federated ServiceAccount that the workspace references.
:::

### `services`

Named service groups for the workspace. Each group bundles the session-api and
memory-api endpoints (and related defaults) that its agents use; an AgentRuntime
selects a group via `spec.serviceGroup` (defaulting to `default`). A group also
carries **agent-runtime defaults** that its agents inherit.

#### `services[].autoscaling`

Default autoscaling policy for every AgentRuntime in this service group. An
agent that omits `spec.runtime.autoscaling` inherits this policy whole; one
that sets its own block fully owns autoscaling and this default is ignored.

The value is an [`AutoscalingConfig`](/reference/core/agentruntime/#runtimeautoscaling),
the same shape as `AgentRuntime.spec.runtime.autoscaling`.

```yaml
spec:
  services:
    - name: default
      autoscaling:
        enabled: true
        type: hpa
        minReplicas: 1
        maxReplicas: 10
        targetMemoryUtilizationPercentage: 70
```

:::caution[KEDA must be installed for `type: keda`]
KEDA is not installed by the chart by default. When a resolved default requests
`type: keda` but the KEDA CRDs are absent, the agent surfaces an
`AutoscalingReady=False` condition with reason `KEDANotInstalled` and stays at
static replicas — the reconcile does not fail. Use `type: hpa` (the default) on
clusters without KEDA.
:::

#### `services[].memory`

Configures the managed memory-api for this service group. A group with
`mode: managed` (the default) must configure at least one of `memory` or
`session`; it provisions only the services it configures.

| Field | Type | Required | Description |
|---|---|---:|---|
| `database.secretRef.name` | string | Yes | Name of the Secret containing `POSTGRES_CONN`. The schema rejects an absent or empty name |
| `providerRef.name` | string | No | Provider with role `embedding`. Memory requires it: without it the group gets `EmbeddingProviderReady=False` (reason `ProviderNotConfigured`) and no memory-api |
| `nextProviderRef.name` | string | No | Provider of the next embedding model, for an embedding-model change. See below |
| `policyRef.name` | string | No | MemoryPolicy applied to this group |
| `maintenance.schedule` | string | No | Cron schedule of this group's maintenance worker (trash purge, expiry, change-feed and gate-queue pruning): five fields, or a descriptor such as `@hourly` or `@every 15m`. Default: the operator's `--memory-maintenance-schedule` (the chart's `workspaceServices.memoryApi.maintenance.schedule`, `@hourly`). See below |
| `maintenance.trashRetention` | string | No | The trash window: how long a forgotten shared memory, and a run's deletions, can be restored or reverted before maintenance purges them. Hours, minutes and seconds, such as `72h` or `45s`; must be positive. Default: seven days |
| `redis` | object | No | Group-specific Redis override |
| `podOverrides` | object | No | Pod identity, scheduling, and secret-store overrides. See [`podOverrides`](/reference/core/agentruntime/#podoverrides) |
| `resources` | object | No | Container requests and limits. Default: requests of `50m` CPU and `128Mi` memory, no limits. See [Service resources](#service-resources) |

`providerRef` and `nextProviderRef` refer to existing
Provider resources in the workspace namespace.

memory-api checks `maintenance.schedule` when it starts, not the API server:
a schedule that does not parse, or ticks more often than once a minute, stops
memory-api with an error naming `--maintenance-schedule`, and the pod
crash-loops until you fix it. The API server does reject a `trashRetention`
that is not a positive duration.

`nextProviderRef` names the embedding Provider to change to. memory-api embeds
every memory with it beside the current vector and swaps recall over in one
step when you start the change (`POST /admin/brain/model-change`). After the
swap, set `providerRef` to that Provider and remove `nextProviderRef`. Until
you do, memory-api serves with the next Provider, because the database now
names its model.

```yaml
spec:
  services:
    - name: default
      mode: managed
      memory:
        database:
          secretRef:
            name: memory-postgres
        providerRef:
          name: memory-embedding
        policyRef:
          name: memory-policy
```

See the [Memory API reference](/reference/platform/memory-api/) and
[MemoryPolicy reference](/reference/policies/memorypolicy/) for service
behavior and policy fields.

#### `services[].session`

Configures the managed session-api for this service group. The operator names
its Deployment and Service `session-<workspace>-<group>`.

| Field | Type | Required | Description |
|---|---|---:|---|
| `database.secretRef.name` | string | Yes | Name of the Secret containing `POSTGRES_CONN`. The schema rejects an absent or empty name |
| `redis` | object | No | Group-specific Redis override for the session hot cache |
| `podOverrides` | object | No | Pod identity, scheduling, and secret-store overrides. See [`podOverrides`](/reference/core/agentruntime/#podoverrides) |
| `resources` | object | No | Container requests and limits. Default: requests of `50m` CPU and `64Mi` memory, no limits. See [Service resources](#service-resources) |
| `otlp.enabled` | boolean | No | Turn on OTLP trace ingestion. Default `false` |

With `otlp.enabled: true` the operator starts session-api with
`--otlp-enabled`, declares container ports `otlp-grpc` (4317) and `otlp-http`
(4318), and adds both ports to the group's Service. When internal service
authentication is on, it also admits the two ports through the workspace's
service-auth NetworkPolicy, from the workspace namespace only. Port 8080 keeps
its own peers. Setting it back to `false` removes them.

Without a `redis` on the group or its session/memory block, session-api and
memory-api use the operator-wide Redis from the chart. When that comes from an
`existingSecret`, the Secret is in the install namespace, so the operator copies
its URL into `omnia-session-api-redis` and `omnia-memory-api-redis` (key `url`)
in the workspace namespace. Both are owned by the Workspace and rewritten when
the source changes, which rolls the services. They are deleted when no group
needs them or the source is deleted. A `redis.existingSecret` set on the
Workspace names a Secret in the workspace namespace and is read directly.

```yaml
spec:
  services:
    - name: default
      session:
        database:
          secretRef:
            name: session-postgres
        otlp:
          enabled: true
```

See [Ingest OpenTelemetry traces into sessions](/how-to/observability/configure-otlp-ingestion/).

#### `services[].redis`

One Redis target for the whole group. It takes exactly one of `existingSecret`
(`name`, `key`: a Secret in the workspace namespace holding the full
`redis(s)://` URL), `url`, `host` (with optional `port`, `db`, `user`;
unauthenticated) or `serviceRef` (`name`, optional `namespace` and `port`;
`redis://<name>.<namespace>:<port>`, unauthenticated). Each consumer uses the
first that is set of its own override, this group-level block, and the
operator-wide default:

| Consumer | Precedence |
|---|---|
| session-api, the group's eval-worker | `session.redis` > `redis` > operator session Redis |
| memory-api | `memory.redis` > `redis` > operator memory Redis |
| Arena run work queue (Enterprise) | `redis` > `enterprise.arena.queue.redis` |

The Arena queue has no per-component block. When no group in the workspace
sets a `redis`, or no Workspace owns the namespace, runs use the operator-wide
queue. Otherwise a run uses the group named by its job's
[`serviceGroup`](/reference/evaluation/arenajob/#servicegroup), or the
workspace's only group when the job names none. The controller does not guess:
a workspace with several groups and no name fails the run with reason
`ServiceGroupAmbiguous`, and a name the workspace does not declare fails it with
`ServiceGroupNotFound`. A named group without a `redis` uses the operator-wide
queue. If the Workspace cannot be read, the controller retries and does not fall
back.

The run records the Redis it resolved on its `status.queue` before its worker
Job exists, and keeps it. The controller, the workers, cost and budget
accounting, results, progress, live stats and cancellation all use that Redis.
Changing or adding a group's `redis` therefore moves only runs that start
afterwards. A run in flight finishes on the queue it started on and reports the
change on its `QueueRedisDrifted` condition. Runs already started before Omnia
had this field stay on the operator-wide queue.

`status.queue` holds no connection string. With `existingSecret`, the run names
that Secret. With `url`, `host` or `serviceRef`, the arena-controller writes the
resolved URL into a Secret owned by the run, `<run>-queue-redis` (key `url`),
in the workspace namespace. The workers read `REDIS_URL` from that Secret, and
the controller reads the same Secret to connect. If the Secret disappears while
the run is in flight, the controller retries and does not settle the run until
the Secret is back. management-api has no read on Secrets, so it asks the
arena-controller for the live stats of a run on a group Redis.

```yaml
spec:
  services:
    - name: default
      redis:
        serviceRef: { name: redis, namespace: tenant-a-data }
      session:
        database: { secretRef: { name: session-postgres } }
```

#### `services[].evalWorker`

Configures the group's eval-worker, `arena-eval-worker-<group>`. The operator
creates it when the group has an eval-enabled AgentRuntime that is not
PromptKit.

| Field | Type | Required | Description |
|---|---|---:|---|
| `enabled` | boolean | No | Create the worker even when every eval-enabled agent in the group is PromptKit, so their `llm_judge` evals run out of band. Default `false` |
| `podOverrides` | object | No | Pod overrides for the worker. Takes precedence over the agent's `spec.evals.podOverrides`. See [`podOverrides`](/reference/core/agentruntime/#podoverrides) |
| `resources` | object | No | Container requests and limits. Default: requests of `50m` CPU and `128Mi` memory, no limits. See [Service resources](#service-resources) |

#### Service resources

Every per-workspace service container carries resource requests, so its pod
is not `BestEffort` and a namespace `ResourceQuota` that counts requests
admits it. Set `resources` on `session`, `memory`, `evalWorker` or
[`privacy`](#privacy) to replace the default. It takes the Kubernetes
`ResourceRequirements` shape and replaces the default as a whole: a block that
sets only `limits` leaves the container with no default requests, and
Kubernetes then copies the limits into the requests.

| Service | Default CPU request | Default memory request |
|---|---|---|
| session-api | `50m` | `64Mi` |
| memory-api | `50m` | `128Mi` |
| privacy-api | `50m` | `64Mi` |
| eval-worker | `50m` | `128Mi` |

The defaults set no limits. A namespace `ResourceQuota` that counts
`limits.cpu` or `limits.memory` needs a `LimitRange` or a `resources` block
with limits. session-api measured about 16Mi idle and 34Mi under 1,000
concurrent sessions.

```yaml
spec:
  services:
    - name: default
      session:
        database:
          secretRef:
            name: session-postgres
        resources:
          requests:
            cpu: 200m
            memory: 128Mi
          limits:
            memory: 512Mi
```

On a small test cluster that keeps many idle workspaces, scale every default
CPU request at once with the operator flag
`--workspace-service-cpu-request-percent` (default `100`), for example
`operator.extraArgs: ["--workspace-service-cpu-request-percent=10"]` in the
chart values. It scales only the defaults above, never a `resources` block you
set, and never below `1m`.

#### `services[].privacyPolicyRef`

References a `SessionPrivacyPolicy` in this workspace's namespace that applies to
**all agents in this service group**. An individual AgentRuntime can override it
with its own `spec.privacyPolicyRef`.

| Field | Type | Required |
|-------|------|----------|
| `services[].privacyPolicyRef.name` | string | No |

```yaml
spec:
  services:
    - name: default
      privacyPolicyRef:
        name: gdpr-compliant
```

See the [SessionPrivacyPolicy CRD](/reference/policies/sessionprivacypolicy/) reference and
[Configure Privacy Policies](/how-to/privacy/configure-privacy-policies/) for policy
resolution order and per-agent overrides.

### `privacy`

:::note[Enterprise Feature]
The per-workspace privacy-api service is an Enterprise feature. Provisioning it
does not require an active license: setting `spec.privacy`, with the operator
configured with a privacy-api image, is all it takes. An unlicensed Enterprise
deployment nags but keeps privacy-api running — see
[Install an Enterprise License](/how-to/operations/install-license/).
:::

Configures the per-workspace **privacy-api** service, which owns per-user consent
grants and opt-out preferences for the workspace. Setting `spec.privacy` provisions
the privacy-api for this workspace, backed by the consent database referenced here.
Omitting it means no privacy-api is provisioned and the workspace's session/memory
services run without centralized preference enforcement, consent tracking, the
compliance audit hub, or DSAR erasure.

| Field | Type | Required |
|-------|------|----------|
| `privacy.database.secretRef.name` | string | Yes |
| `privacy.resources` | object | No |

`privacy.resources` sets the privacy-api container's requests and limits. The
default is requests of `50m` CPU and `64Mi` memory, with no limits. See
[Service resources](#service-resources).

`privacy.database` points at the PostgreSQL **consent database** for this
workspace (one database per workspace). The schema rejects an absent or empty
`secretRef.name`. The referenced Secret must contain a `POSTGRES_CONN` key
holding a valid PostgreSQL connection string.

```yaml
spec:
  privacy:
    database:
      secretRef:
        name: my-workspace-privacy-db
```

Once provisioned, the privacy-api's resolved URL is published on
[`status.privacyURL`](#privacyurl). To manage consent and opt-out preferences
against it, see [Manage User Consent](/how-to/privacy/manage-user-consent/); to submit
right-to-erasure requests, see [Handle Data Subject Erasure](/how-to/privacy/handle-data-subject-erasure/).

### `builder`

Names what the dashboard's Arena builder ("Generate with AI" and the project
editor's builder) talks to in this workspace. Each field overrides the
install's default, set by the omnia chart's
[`dashboard.builder`](/reference/platform/helm-values/#arena-builder) values;
a field left unset takes that default. No name is built into the dashboard, so
a workspace whose install and spec both leave one empty shows that no builder
is configured.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `builder.agent` | string | No | The builder's AgentRuntime in the workspace namespace |
| `builder.sandbox` | string | No | The sandbox, in that agent's ToolRegistry `spec.sandboxes`, a builder session declares its project mount on. The session uses its default instance, `<sandbox>/default` |
| `builder.projectPath` | string | No | Where that sandbox mounts the project, as the builder agent is told it: its class's `filesystem.workspace.mountPath`. Starts with `/` |
| `builder.mountRole` | string | No | The [sandbox mount role](#sandboxmountroles) a builder session declares the project on: one the operator configures, or one in this Workspace's `sandboxMountRoles`. A DNS label |

```yaml
spec:
  builder:
    agent: authoring-agent
    sandbox: authoring
```

### `sandboxMountRoles`

The mount roles a session in this workspace may declare on its sandbox. A
role names a kind of directory on the workspace content volume that a
sandbox's `/workspace` binds in place, and who may declare it. `scratch` and
`session` are built in and not configurable. Every other role comes from the
operator's `--sandbox-mount-roles` (the chart's
[`operator.sandboxAPI.mountRoles`](/reference/platform/helm-values/#sandbox-api),
whose default is one role, `project`). An entry here replaces the operator's
role of the same name for this workspace, or adds a role of its own. At most
16 entries, keyed by `name`.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | The role a declaration and a SandboxClass's `mounts` name. A DNS label, not `scratch` or `session` |
| `path` | string | Yes | The directory a mount of this role binds, relative to the workspace's content. Exactly one whole segment is `{id}`, the id the declaration names, and at most one is `{user}`, the verified declaring user's pseudonym, e.g. `arena/workspaces/{id}/{user}`. It may not be absolute, contain `..`, or reach or contain `sandboxes/`, `sessions/`, `arena/sources/`, `arena/repos/`, `arena/snapshots/`, `arena/deployed/`, `arena/template-sources/`, `arena/template-indexes/`, `skills/` or `manifests/`, and may not overlap another role's path (sit inside, around or on it) |
| `mustExist` | bool | No | Default `true`: a declaration whose directory is not there is refused (`MountNotFound`). `false`: the operator creates the directory as the claim binds |
| `minRole` | `viewer` \| `editor` \| `owner` | No | Default `editor`. The least role in this workspace a verified user needs to declare the mount |

A declaration of a role whose `path` here is invalid is refused with
`InvalidMountRole` (409). The declaring user must still be verified, with a
token minted in this Workspace, and the workspace must have content storage.
See [SandboxClaim mount roles](/reference/core/sandboxclaim/#mount-roles).

```yaml
spec:
  sandboxMountRoles:
    # Only owners may open a builder sandbox on a project.
    - name: project
      path: arena/workspaces/{id}/{user}
      minRole: owner
    # A notes directory per id, created on first use.
    - name: notes
      path: notes/{id}
      mustExist: false
```

### `quotas`

Caps on the workspace namespace's compute requests, object counts and
AgentRuntimes. The controller writes compute and object caps into a Kubernetes
[`ResourceQuota`](https://kubernetes.io/docs/concepts/policy/resource-quotas/)
named `workspace-<workspace-name>-quota` in the workspace namespace. The
ResourceQuota carries the labels `omnia.altairalabs.ai/workspace=<name>` and
`omnia.altairalabs.ai/managed=true`, and the Workspace as its controller owner.
The controller reverts edits to it. Removing `spec.quotas`, or every limit in
it, deletes it. A ResourceQuota of that name without those labels is left in
place.

| Field | Type | ResourceQuota key | Required |
|-------|------|-------------------|----------|
| `quotas.compute.requests.cpu` | quantity | `requests.cpu` | No |
| `quotas.compute.requests.memory` | quantity | `requests.memory` | No |
| `quotas.objects.pods` | integer (min 0) | `pods` | No |
| `quotas.objects.services` | integer (min 0) | `services` | No |
| `quotas.objects.configMaps` | integer (min 0) | `configmaps` | No |
| `quotas.objects.secrets` | integer (min 0) | `secrets` | No |
| `quotas.objects.persistentVolumeClaims` | integer (min 0) | `persistentvolumeclaims` | No |

An unset field adds no limit. The object counts include the objects the
operator creates in the namespace: agent pods, the per-workspace service
Deployments' pods, Services, Secrets and ConfigMaps.

The Kubernetes API server rejects a pod in the namespace when any of its
containers, init containers included, has no request for a resource that
`compute.requests` caps. The facade, runtime, policy-broker, session-api,
memory-api, privacy-api and eval-worker containers carry default requests. A
sandbox container takes its requests from its SandboxClass `resources`; with
neither requests nor limits there, the API server rejects the sandbox pod.

`quotas` has no limits field. The operator's agent and service containers set
requests and no limits, and a `limits.cpu` or `limits.memory` quota rejects
every container without a limit. To cap limits, add your own ResourceQuota and
a [`LimitRange`](https://kubernetes.io/docs/concepts/policy/limit-range/) that
supplies default limits.

The `ResourceQuotaReady` condition reports the outcome, and
[`status.quotas.resourceQuota`](#quotas-1) the limits and current usage.

#### `quotas.agents`

Caps on the workspace's AgentRuntimes. A ResourceQuota cannot express these,
so the controllers enforce them: the Workspace controller decides which
AgentRuntimes the count limit admits, and the AgentRuntime controller obeys
that and caps replicas. The AgentRuntime admission webhook, when
`webhook.enabled` is set, refuses some requests early.

| Field | Type | Required | Effect |
|-------|------|----------|--------|
| `quotas.agents.maxAgentRuntimes` | integer (min 0) | No | How many AgentRuntimes in the namespace are admitted. The Workspace controller admits them oldest first (creation time, then name) and records the set in `status.quotas.agents.admitted`. An AgentRuntime not admitted is not started: no Deployment and no eval worker. An admitted one stays admitted when the limit is lowered, so lowering it stops nothing. An AgentRuntime that already has a Deployment is always admitted. The webhook refuses a create once the namespace holds this many |
| `quotas.agents.maxReplicasPerAgent` | integer (min 1) | No | Cap on the replicas one AgentRuntime runs. The controller clamps to it: the Deployment's replicas, the HPA or KEDA `minReplicas` and `maxReplicas`, and a replica-weighted rollout's total. The agent is otherwise reconciled as usual. Lowering the cap scales agents down to it. The webhook refuses only an explicit `spec.runtime.replicas` above it, on create or when an update raises it |

An autoscaler's `maxReplicas` is never refused, only capped. The CRD defaults
it to 100, and a service group's `autoscaling` applies to every agent in the
group that sets none of its own, so refusing either would refuse most
autoscaled agents. A Workspace may therefore set `services[].autoscaling`
above `maxReplicasPerAgent`: the cap wins.

The cap applies to each Deployment, so during a rollout that routes traffic
through a mesh rather than by replica weight, the stable and candidate
Deployments can each run the cap.

The admitted set is decided by one writer, the Workspace controller, which
never reconciles a Workspace twice at once, so two AgentRuntimes created
together cannot both take the last slot. An AgentRuntime waiting for admission
starts within moments of being admitted: the Workspace status change reaches
it, and it rechecks every ten minutes besides. An admitted AgentRuntime whose
Deployment is deleted is recreated.

Each AgentRuntime's `WithinWorkspaceQuota` condition reports its own state
(see the [AgentRuntime conditions](/reference/core/agentruntime/#conditions)).
The Workspace's `AgentQuotaWithinLimits` condition and `status.quotas.agents`
report the whole.

```yaml
spec:
  quotas:
    compute:
      requests:
        cpu: "16"
        memory: 32Gi
    objects:
      pods: 100
      persistentVolumeClaims: 10
    agents:
      maxAgentRuntimes: 20
      maxReplicasPerAgent: 5
    arena:
      maxConcurrentJobs: 3
      maxJobsPerDay: 50
      maxWorkersPerJob: 4
```

#### `quotas.arena`

Enterprise. Caps on the workspace's Arena runs, enforced by the arena
controller; a Core install ignores the block. Every limit counts **ArenaRuns**
(one execution each), not ArenaJobs: a manual ArenaJob creates one run, a
scheduled one a run per firing. The arena controller's workspace quota
reconciler decides which runs are admitted and records the set in
`status.quotas.arena.admitted`; the ArenaRun controller starts only an admitted
run. A capacity limit, but no limit fails or stops a run: a run beyond one
waits.

| Field | Type | Required | Effect |
|-------|------|----------|--------|
| `quotas.arena.maxConcurrentJobs` | integer (min 0) | No | How many runs in the namespace may be admitted and unfinished at once. A run is admitted before its worker Job is created and holds its slot until it is `Succeeded`, `Failed` or `Cancelled`. An admitted run that has not started its workers 15 minutes after admission (its ArenaProject is gone, its worker Job cannot be created, its queue cannot be pinned) has its admission released: it is listed in `status.quotas.arena.stalled` and waits again, behind every run that has not stalled, and is re-admitted only into a slot no other waiting run wants. Runs beyond the limit wait `Pending` and are admitted oldest first (creation time, then name) as slots free. A run already started when the limit is set or lowered keeps running and counts |
| `quotas.arena.maxJobsPerDay` | integer (0 to 1000) | No | How many runs may be admitted in any rolling 24 hours (not per calendar day). A run is charged once, at its admission, whether it then succeeds, fails, is cancelled or runs for days; a released admission is not refunded, and re-admitting the run does not charge it again. Admissions are recorded in `status.quotas.arena.starts`, which outlives the runs, so pruning finished runs or restarting the controller does not reset the count; runs that started before the ledger began (`ledgerStart`) count by their `status.startTime`. The maximum, 1000, is the ledger's size. Removing `maxJobsPerDay` discards the ledger; setting it again starts a new one. A manual run beyond it waits `Pending` until the oldest admission is 24 hours old. A scheduled ArenaJob instead skips the firing, with a `RunSkipped` event, when the runs started in the window plus every waiting run (including one waiting only on `maxConcurrentJobs`, which will be charged when admitted) already use the allowance |
| `quotas.arena.maxWorkersPerJob` | integer (min 1) | No | Cap on one run's worker replicas. A run asking for more (`spec.workers.replicas`) runs at the cap; its `status.workers` records the count it started with and the licence's worker-replica limit is checked against it. Applied when the worker Job is created, so lowering it never shrinks a running run. With the arena controller's licence webhooks enabled, an ArenaJob whose explicit `spec.workers.replicas` is above it is refused on create, or when an update raises it; an update that does not raise it is admitted with a warning |

Runs are identified by UID, so a run deleted and recreated under the same name
is a new run. A Workspace that does not own its namespace (another Workspace
claims it, as `omnia.altairalabs.ai/workspace` resolution decides) admits
nothing: that namespace's runs follow its owner's quota.

Admission is decided by one writer per Workspace, which never reconciles a
Workspace twice at once, so two runs created together cannot both take the
last slot. A waiting run starts within moments of being admitted: the
Workspace status change reaches it, and it rechecks every ten minutes besides.
Lowering a limit stops nothing that is already admitted; removing the block
admits every waiting run.

Each run's `WithinWorkspaceQuota` condition reports its own state (see the
[ArenaJob reference](/reference/evaluation/arenajob/)). The Workspace's
`ArenaQuotaWithinLimits` condition and `status.quotas.arena` report the whole.

### `networkPolicy`

Network isolation settings for the workspace. When `isolate` is true, the controller generates a Kubernetes NetworkPolicy to restrict traffic; setting it back to false (or removing the block) deletes that NetworkPolicy.

| Field | Type | Default | Required |
|-------|------|---------|----------|
| `networkPolicy.isolate` | boolean | false | No |
| `networkPolicy.allowExternalAPIs` | boolean | true | No |
| `networkPolicy.allowPrivateNetworks` | boolean | false | No |
| `networkPolicy.allowFrom` | []NetworkPolicyRule | [] | No |
| `networkPolicy.allowTo` | []NetworkPolicyRule | [] | No |

#### Default behavior (when `isolate: true`)

The policy selects every pod in the namespace except codegen sandbox pods and sandbox egress proxy pods, which keep only the per-claim policy written for them.

- **DNS**: Always allows egress to `kube-system` on port 53 (UDP/TCP)
- **Operator namespace**: Allows ingress from and egress to the namespace the operator runs in (matched by `kubernetes.io/metadata.name`), so the dashboard, operator and Prometheus reach workspace pods without any manual labelling
- **Same namespace**: Allows all ingress/egress within the workspace namespace
- **External APIs**: Allows egress to `0.0.0.0/0` excluding [RFC 1918](https://datatracker.ietf.org/doc/html/rfc1918) private IP ranges:
  - `10.0.0.0/8` - Class A private network
  - `172.16.0.0/12` - Class B private networks
  - `192.168.0.0/16` - Class C private networks
  - `169.254.0.0/16` - link-local, home of the cloud metadata service; excluded even when `allowPrivateNetworks` is true

This allows agents to reach external LLM APIs while blocking access to other tenants' pods and internal cluster services.

#### Basic isolation

```yaml
spec:
  networkPolicy:
    isolate: true
```

This creates a NetworkPolicy named `workspace-{name}-isolation` with default rules.

#### Restrict external access

Disable external API access (removes the `0.0.0.0/0` egress rule; DNS, operator-namespace and same-namespace rules remain):

```yaml
spec:
  networkPolicy:
    isolate: true
    allowExternalAPIs: false
```

#### Allow private networks (local development)

For local development or when agents need to access services on private networks (e.g., local LLM servers), enable `allowPrivateNetworks` to remove the RFC 1918 exclusions:

```yaml
spec:
  networkPolicy:
    isolate: true
    allowPrivateNetworks: true  # Allows 10.x, 172.16.x, 192.168.x
```

:::caution
Enabling `allowPrivateNetworks` reduces tenant isolation. Only use this in development environments or when you explicitly need agents to access private network services.
:::

#### Custom ingress rules

Allow traffic from specific namespaces (e.g., ingress controller):

```yaml
spec:
  networkPolicy:
    isolate: true
    allowFrom:
      - peers:
          - namespaceSelector:
              matchLabels:
                kubernetes.io/metadata.name: ingress-nginx
```

#### Custom egress rules

Allow egress to internal databases:

```yaml
spec:
  networkPolicy:
    isolate: true
    allowTo:
      - peers:
          - ipBlock:
              cidr: 10.0.0.0/8  # Internal network
        ports:
          - protocol: TCP
            port: 5432  # PostgreSQL
          - protocol: TCP
            port: 6379  # Redis
```

#### NetworkPolicyRule structure

| Field | Type | Description |
|-------|------|-------------|
| `peers` | []NetworkPolicyPeer | Sources (ingress) or destinations (egress) |
| `ports` | []NetworkPolicyPort | Ports to allow (optional, all ports if omitted) |

#### NetworkPolicyPeer structure

| Field | Type | Description |
|-------|------|-------------|
| `namespaceSelector.matchLabels` | map[string]string | Select namespaces by label |
| `podSelector.matchLabels` | map[string]string | Select pods by label |
| `ipBlock.cidr` | string | IP block in CIDR notation |
| `ipBlock.except` | []string | CIDRs to exclude from the block |

#### NetworkPolicyPort structure

| Field | Type | Description |
|-------|------|-------------|
| `protocol` | string | TCP, UDP, or SCTP (default: TCP) |
| `port` | integer | Port number |

### `costControls`

Budgets for the workspace, enforced by the operator.

| Field | Type | Default | Required |
|-------|------|---------|----------|
| `costControls.dailyBudget` | string | - | No |
| `costControls.monthlyBudget` | string | - | No |
| `costControls.budgetExceededAction` | `warn` \| `pauseJobs` \| `block` | `warn` | No |
| `costControls.alertThresholds[].percent` | integer, 1-100 | - | Yes, per entry |
| `costControls.alertThresholds[].notify` | list of strings | - | No |

Budget values are in USD (e.g., "100.00", "2000.00"). Days and months are UTC
calendar periods: `dailyBudget` is measured against the spend since 00:00 UTC
today, and `monthlyBudget` against the spend since 00:00 UTC on the first of
the month. Spend is every provider call the workspace's session-apis recorded
in the period, dev (try-it) sessions included, plus their session-less provider
usage (embeddings, judges), summed across the service groups. Groups that share
one session-api URL, or managed groups whose session-api uses the same
database Secret, are counted once. Groups on different Secrets that point at
the same database are counted once each, so the spend reads too high.

The operator reads spend about every five minutes, never later than the next
UTC midnight, and at once when the spec changes. The result is in
[`status.costUsage`](#costusage) and the `WithinBudget`,
`BelowCostAlertThresholds` and `CostUsageAvailable` [conditions](#conditions).
A workspace with no session-api has no recorded spend, so its budget is not
enforced (`CostUsageAvailable=False`, reason `NoSessionAPI`).

While the day's or the month's spend is at or over its budget,
`WithinBudget` is `False` and `budgetExceededAction` decides what happens:

| Action | Effect |
|--------|--------|
| `warn` | The condition and a Warning event only |
| `pauseJobs` | Also holds every ArenaRun in the workspace that has not started yet: it stays `Pending` with `Ready=False`, reason `WorkspaceBudgetExceeded`, and starts once the workspace is within budget. Runs already running finish. A run's own `budgetLimit` still caps that run. A scheduled ArenaJob skips its ticks with a `RunSkippedWorkspaceBudget` Warning event rather than creating runs that would wait. With `quotas.arena` set, a held run takes no slot and no `maxJobsPerDay` charge: the quota admits nothing new while the hold lasts, runs it already admitted keep their slots, and waiting runs are admitted oldest first when it lifts |
| `block` | Also refuses new agent work. The WebSocket facade answers a new conversation with error code `BUDGET_EXCEEDED`, A2A fails the message, a function call (`POST /functions/{name}`, streamed or `?mode=async`) gets `429` with error `budget_exceeded`, and an MCP tool call gets the tool error `budget_exceeded: …`. Open conversations, resumes of conversations the runtime still holds, and async calls already accepted carry on |

The hold lifts when the period rolls over or the budget is raised. If the
spend read fails, the last figures read for the current period stay in force,
and a figure from an earlier period counts as nothing; a hold recorded for an
earlier period is ignored, so it lifts at rollover even while the operator is
down. The facade reads its Workspace at most every 30 seconds, and admits all
new work when it cannot read it (no read permission, or the API server
unreachable).

Each `alertThresholds` entry emits one Warning event, reason
`CostAlertThreshold`, the first time the daily or the monthly spend reaches
that percent of its budget in a period. Alerts are at most once: one lost
with a failed status write is not repeated. `notify` is stored but no email is
sent.

The dashboard's workspace cost view (`GET /api/workspaces/{name}/costs`) also
reports spend against the budgets: `dailyBudget` against the last 24 hours, and
`monthlyBudget` against the projected monthly cost.

```yaml
spec:
  costControls:
    dailyBudget: "100.00"
    monthlyBudget: "2000.00"
    budgetExceededAction: pauseJobs
    alertThresholds:
      - percent: 80
      - percent: 95
```

## Status fields

### `phase`

Current lifecycle phase of the Workspace.

| Value | Description |
|-------|-------------|
| `Pending` | The workspace is provisioning, or its storage PVC is not bound |
| `Ready` | Workspace is ready for use |
| `Degraded` | The workspace serves, but a service group is not ready (`ServicesReady=False`), so the capability that group provides is absent for its agents, or `spec.quotas` is not in force (`ResourceQuotaReady=False`) |
| `Error` | Reconciling the namespace, ServiceAccounts, RoleBindings, NetworkPolicy, storage or services failed |

### `observedGeneration`

Most recent generation observed by the controller.

### `namespace`

Namespace status information.

| Field | Description |
|-------|-------------|
| `status.namespace.name` | Name of the created namespace |
| `status.namespace.created` | Whether namespace was created by controller |

### `serviceAccounts`

ServiceAccounts created for this workspace, named `workspace-<name>-<role>-sa`.

| Field | Description |
|-------|-------------|
| `status.serviceAccounts.owner` | Name of the owner ServiceAccount |
| `status.serviceAccounts.editor` | Name of the editor ServiceAccount |
| `status.serviceAccounts.viewer` | Name of the viewer ServiceAccount |

### `members`

Grant-entry count by role. Each group and ServiceAccount listed in
`roleBindings`, and each `directGrants` entry, counts once; group membership is
not expanded.

| Field | Description |
|-------|-------------|
| `status.members.owners` | Count of owner entries |
| `status.members.editors` | Count of editor entries |
| `status.members.viewers` | Count of viewer entries |

### `networkPolicy`

NetworkPolicy status information. Cleared when `networkPolicy.isolate` is false.

| Field | Description |
|-------|-------------|
| `status.networkPolicy.name` | Name of the generated NetworkPolicy |
| `status.networkPolicy.enabled` | Whether network isolation is active |
| `status.networkPolicy.rulesCount` | Total number of ingress and egress rules |

### `services`

One entry per `spec.services[]` group.

| Field | Description |
|-------|-------------|
| `status.services[].name` | Service group name |
| `status.services[].sessionURL` | Resolved session-api URL for the group |
| `status.services[].memoryURL` | Resolved memory-api URL for the group |
| `status.services[].ready` | Whether every component of the group is operational |
| `status.services[].conditions` | Per-group conditions. `EmbeddingProviderReady` appears whenever the group configures memory, and `GateProviderReady` when it references a gate Provider (reasons `ProviderValid`, `ProviderNotConfigured` for a missing `memory.providerRef`, `ProviderNotFound`, `ProviderLookupFailed`, `ProviderRoleMismatch`, `ProviderNotReady`) |
| `status.services[].memoryStore` | The store the group's memory-api serves, always `brain`, recorded once memory-api is provisioned. Kept while the group's memory is held back or removed |

### `quotas`

The quotas in force and the usage against them. Absent when `spec.quotas` sets
nothing in force.

| Field | Description |
|-------|-------------|
| `status.quotas.resourceQuota.name` | Name of the operator-managed ResourceQuota |
| `status.quotas.resourceQuota.hard` | Its limits, from `spec.quotas.compute` and `spec.quotas.objects` |
| `status.quotas.resourceQuota.used` | Its `status.used`, as the Kubernetes quota controller last counted it. Empty until the first count |
| `status.quotas.agents.agentRuntimes` | AgentRuntimes in the namespace, not counting those being deleted |
| `status.quotas.agents.admitted` | AgentRuntimes `maxAgentRuntimes` admits, oldest first. Can be longer than a lowered limit. Absent when `maxAgentRuntimes` is unset |
| `status.quotas.agents.waiting` | AgentRuntimes waiting for admission |
| `status.quotas.agents.clamped` | AgentRuntimes asking for more replicas than `maxReplicasPerAgent`, which run at the cap |

| `status.quotas.arena.running` | ArenaRuns admitted and unfinished: what `maxConcurrentJobs` limits |
| `status.quotas.arena.startedLast24h` | ArenaRuns admitted (or, before the quota, started) in the last 24 hours: what `maxJobsPerDay` limits |
| `status.quotas.arena.admitted` | The unfinished runs the quota has admitted (`name`, `uid`, `time` of admission), oldest first. Can be longer than a lowered limit. Absent when neither `maxConcurrentJobs` nor `maxJobsPerDay` is set |
| `status.quotas.arena.waiting` | Runs waiting for admission, in the order they will be admitted |
| `status.quotas.arena.stalled` | Runs whose admission was released because they did not start their workers within 15 minutes (`name`, `uid`, `time` released). Each is also in `waiting`, at the back |
| `status.quotas.arena.clamped` | Unfinished runs with fewer workers than they ask for: a started run by the count its worker Job was created with (`status.workers`), one not started yet by the current `maxWorkersPerJob` |
| `status.quotas.arena.starts` | The admissions of the last 24 hours (`name`, `uid`, `time`) that `maxJobsPerDay` counts. Present only while `maxJobsPerDay` is set; at most 1000 entries, the newest kept |
| `status.quotas.arena.ledgerStart` | When the `starts` ledger began. Runs started before it count by their `status.startTime`; every run started after it was charged at admission |
| `status.quotas.arena.nextAdmissionTime` | When the daily limit next has room, while a run waits on it |

When the AgentRuntimes cannot be listed, `status.quotas.agents` keeps its last
value and `AgentQuotaWithinLimits` is `Unknown`. `status.quotas.arena` is
written by the arena controller, not the Workspace controller.

### `costUsage`

The workspace's spend against its budgets. Absent when `spec.costControls` sets
no budget.

| Field | Description |
|-------|-------------|
| `status.costUsage.dailySpend` | USD spent since 00:00 UTC on `day`, to four decimal places |
| `status.costUsage.day` | The UTC date (`YYYY-MM-DD`) `dailySpend` covers |
| `status.costUsage.dailyBudget` | The daily budget it was measured against |
| `status.costUsage.dailyPercent` | `dailySpend` as a whole percent of the budget, rounded down. Absent without a daily budget |
| `status.costUsage.dailyAlertPercent` | The highest alert threshold already announced for `day` |
| `status.costUsage.monthlySpend` | USD spent since the start of `month` (UTC) |
| `status.costUsage.month` | The UTC month (`YYYY-MM`) `monthlySpend` covers |
| `status.costUsage.monthlyBudget` | The monthly budget it was measured against |
| `status.costUsage.monthlyPercent` | `monthlySpend` as a whole percent of the budget, rounded down |
| `status.costUsage.monthlyAlertPercent` | The highest alert threshold already announced for `month` |
| `status.costUsage.lastUpdated` | When the figures were last written: when they change, and at least hourly after a successful read |

### `privacyURL`

Resolved URL of the per-workspace privacy-api. Populated only when
[`spec.privacy`](#privacy) is set; empty otherwise.

| Field | Description |
|-------|-------------|
| `status.privacyURL` | In-cluster base URL of the provisioned privacy-api service |

### `conditions`

| Type | Description |
|------|-------------|
| `Ready` | Overall workspace readiness. `False` with reason `StorageProvisioning` while the storage PVC binds, `ServicesNotReady` when a service group is not ready, or `ResourceQuotaNotApplied` when `spec.quotas` is not in force |
| `NamespaceReady` | Namespace is created and configured (reason `NamespaceConflict` when another Workspace owns it) |
| `ServiceAccountsReady` | ServiceAccounts are created |
| `RoleBindingsReady` | RBAC resources are configured |
| `NetworkPolicyReady` | NetworkPolicy reconciled; `True` also when isolation is disabled |
| `ResourceQuotaReady` | `spec.quotas` is in force (reason `ResourceQuotaApplied`), or sets no limit (`ResourceQuotaNotRequired`). `False` with reason `ResourceQuotaFailed` when the ResourceQuota cannot be written or deleted; the message carries the API server's error. `False` with reason `ResourceQuotaConflict` when a ResourceQuota named `workspace-<name>-quota` already exists without the operator's labels: the operator leaves it alone and never deletes it, and `spec.quotas` is not in force until it is deleted or renamed |
| `AgentQuotaWithinLimits` | Present only when `spec.quotas.agents` is set. `True` (reason `WithinAgentQuota`) when every AgentRuntime is admitted and the admitted set fits `maxAgentRuntimes`. Clamped agents leave it `True`: running at the cap is the quota working, and the message names them. `False` (reason `AgentQuotaExceeded`) when some wait for admission or a lowered limit leaves more admitted than it allows; the message names up to five. `Unknown` (reason `AgentQuotaUnknown`) when the AgentRuntimes cannot be listed. A report only: it does not change the phase |
| `ArenaQuotaWithinLimits` | Enterprise; present only when `spec.quotas.arena` is set. `True` (reason `WithinArenaQuota`) when no run waits for admission; clamped runs leave it `True`. `False` (reason `ArenaQuotaExceeded`) when runs wait, or (reason `ArenaRunsStalled`) when an admitted run's admission was released because it did not start. The message names the state only, never counts or runs, so the condition changes only on a transition; `status.quotas.arena` has the detail. Written by the arena controller. A report only: it does not change the phase |
| `StorageReady` | Workspace storage PVC is bound, or `StorageNotRequired` when storage is not enabled |
| `ServicesReady` | Every service group is ready; when not, the message names the group and its first failing condition. Absent when `spec.services` is empty |
| `PrivacyPolicyResolved` | Every `services[].privacyPolicyRef` resolves to a SessionPrivacyPolicy in the workspace namespace (reasons `PolicyResolved`, `DefaultPolicy`, `PolicyNotFound`). Informational: a missing policy does not block reconciliation |
| `OptOutEnforced` | Whether user opt-out is actually being enforced in this workspace |
| `WithinBudget` | Present while `spec.costControls` sets a budget. `False` (reasons `DailyBudgetExceeded`, `MonthlyBudgetExceeded`) while spend is at or over a budget; `budgetExceededAction` acts on it. `True` with reason `WithinBudget`, or `InvalidBudget` when a budget is not a positive number and so is not enforced |
| `BelowCostAlertThresholds` | Present when `alertThresholds` is set. `False` (reasons `DailyAlertThresholdReached`, `MonthlyAlertThresholdReached`) while spend has reached a threshold in the current period |
| `CostUsageAvailable` | Present while a budget is set. `False` with reason `SessionAPIUnavailable` when the last spend read failed, or `NoSessionAPI` when no service group has a session-api |

#### `OptOutEnforced`

Answers "is opt-out actually enforced here?" from the object itself, rather than
only from pod logs.

| Status | Reason | Meaning |
|--------|--------|---------|
| `True` | `PrivacyEnforced` | privacy-api is serving; opt-out is enforced |
| `False` | `PrivacyNotConfigured` | `spec.privacy` is unset. Opt-out is not enforced and all writes are recorded — a valid configuration, not a fault |
| `False` | `PrivacyImageUnset` | `spec.privacy` is set but the operator has no privacy-api image, so none was deployed. An operator misconfiguration |
| `False` | `PrivacyUnavailable` | privacy-api is wanted but not serving. **User-scoped writes are being dropped** until it becomes ready |

The two `False` reasons are not interchangeable. `PrivacyNotConfigured` is a
deliberate posture; `PrivacyUnavailable` means data is being refused right now
and someone should act.

```bash
kubectl get workspace my-ws -o jsonpath='{.status.conditions[?(@.type=="OptOutEnforced")]}'
```

## Complete example

```yaml
apiVersion: omnia.altairalabs.ai/v1alpha1
kind: Workspace
metadata:
  name: customer-support
spec:
  displayName: "Customer Support Team"
  description: "Team responsible for customer support AI agents"
  environment: production

  defaultTags:
    team: "customer-support"
    cost-center: "CC-1234"

  namespace:
    name: omnia-customer-support
    create: true
    labels:
      environment: production

  # Workspace-wide cloud workload identity (keyless providers).
  # The SA + cloud-side federated credential must be provisioned by IaC.
  runtime:
    serviceAccountName: ws-runtime-sa
    podLabels:
      azure.workload.identity/use: "true"

  roleBindings:
    # Owners: Full workspace control
    - groups:
        - "omnia-cs-admins@acme.com"
      role: owner

    # Editors: Create/modify resources
    - groups:
        - "omnia-cs-engineers@acme.com"
      role: editor

    # Viewers: Read-only
    - groups:
        - "omnia-cs-contractors@acme.com"
      role: viewer

    # CI/CD ServiceAccounts
    - serviceAccounts:
        - name: argocd-application-controller
          namespace: argocd
      role: editor

  # Network isolation
  networkPolicy:
    isolate: true
    allowFrom:
      - peers:
          - namespaceSelector:
              matchLabels:
                kubernetes.io/metadata.name: ingress-nginx
    allowTo:
      - peers:
          - ipBlock:
              cidr: 10.0.0.0/8
        ports:
          - protocol: TCP
            port: 5432

  # Cost controls
  costControls:
    monthlyBudget: "5000.00"
```

## Dashboard integration

The dashboard's workspace switcher shows each user only the workspaces they have access to, based on IdP group membership.

### Workspace API endpoints

| Endpoint | Description |
|----------|-------------|
| `GET /api/workspaces` | List accessible workspaces: those on which the caller resolves to a role. Without a `minRole` filter, a platform admin also gets every workspace on which they hold no role, with `role: null` and manage-only permissions. A workspace-scoped API key gets only its allowlisted workspaces. Any other caller with no role gets `{"workspaces":[],"count":0}` |
| `GET /api/workspaces/{name}` | Get workspace details |
| `GET /api/workspaces/{name}/agents` | List agents in workspace |
| `GET /api/workspaces/{name}/promptpacks` | List prompt packs in workspace |
| `GET /api/workspaces/{name}/agents/{agentName}/logs` | Get agent logs |
| `GET /api/workspaces/{name}/agents/{agentName}/events` | Get agent events |

### ServiceAccount token management

management-api manages ServiceAccount tokens for workspace-scoped Kubernetes API access. Each workspace has three ServiceAccounts (owner, editor, viewer) with matching RBAC permissions; management-api fetches short-lived tokens at the permission level the user's role grants.

## Authorization flow

1. User authenticates via OIDC (Okta, Azure AD, Google).
2. The JWT carries claims: `{ email, groups: ["group1", "group2", ...] }`.
3. management-api checks which groups match workspace `roleBindings`, and whether the user has an unexpired `directGrants` entry.
4. The user gets the highest privilege role found, never lower than the `anonymousAccess` role when enabled.
5. management-api makes Kubernetes API calls using the workspace ServiceAccount token for that role.