Workspace CRD
The Workspace custom resource defines a multi-tenant workspace with isolated namespace, RBAC, and network isolation in Kubernetes.
API version
Section titled “API version”apiVersion: omnia.altairalabs.ai/v1alpha1kind: WorkspaceShort name: ws (e.g. kubectl get ws).
Resource scope
Section titled “Resource scope”Workspace is a cluster-scoped resource. It creates and manages resources in its associated namespace.
Spec fields
Section titled “Spec fields”displayName
Section titled “displayName”Human-readable name for the workspace shown in the dashboard.
| Field | Type | Required |
|---|---|---|
displayName | string | Yes |
spec: displayName: "Customer Support Team"description
Section titled “description”Optional description of the workspace.
| Field | Type | Required |
|---|---|---|
description | string | No |
spec: description: "Team responsible for customer support AI agents"environment
Section titled “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.
spec: environment: productiondefaultTags
Section titled “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 |
spec: defaultTags: team: "customer-support" cost-center: "CC-1234" business-unit: "support-ops"namespace
Section titled “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).
spec: namespace: name: omnia-customer-support create: true labels: environment: production annotations: cost-center: "cc-12345"roleBindings
Section titled “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.
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: editordirectGrants
Section titled “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.
spec: directGrants: - user: emergency-admin@acme.com role: owner expires: "2026-02-01T00:00:00Z" # Temporary accessanonymousAccess
Section titled “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.
spec: anonymousAccess: enabled: true role: viewer # Read-only for anonymous usersruntime
Section titled “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. |
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.
Workload-identity federation is provisioned out of band (IaC)
Section titled “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.
services
Section titled “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
Section titled “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,
the same shape as AgentRuntime.spec.runtime.autoscaling.
spec: services: - name: default autoscaling: enabled: true type: hpa minReplicas: 1 maxReplicas: 10 targetMemoryUtilizationPercentage: 70services[].memory
Section titled “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 |
resources | object | No | Container requests and limits. Default: requests of 50m CPU and 128Mi memory, no limits. See 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.
spec: services: - name: default mode: managed memory: database: secretRef: name: memory-postgres providerRef: name: memory-embedding policyRef: name: memory-policySee the Memory API reference and MemoryPolicy reference for service behavior and policy fields.
services[].session
Section titled “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 |
resources | object | No | Container requests and limits. Default: requests of 50m CPU and 64Mi memory, no limits. See 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.
spec: services: - name: default session: database: secretRef: name: session-postgres otlp: enabled: trueSee Ingest OpenTelemetry traces into sessions.
services[].redis
Section titled “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, 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.
spec: services: - name: default redis: serviceRef: { name: redis, namespace: tenant-a-data } session: database: { secretRef: { name: session-postgres } }services[].evalWorker
Section titled “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 |
resources | object | No | Container requests and limits. Default: requests of 50m CPU and 128Mi memory, no limits. See Service resources |
Service resources
Section titled “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 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.
spec: services: - name: default session: database: secretRef: name: session-postgres resources: requests: cpu: 200m memory: 128Mi limits: memory: 512MiOn 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
Section titled “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 |
spec: services: - name: default privacyPolicyRef: name: gdpr-compliantSee the SessionPrivacyPolicy CRD reference and Configure Privacy Policies for policy resolution order and per-agent overrides.
privacy
Section titled “privacy”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.
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.
spec: privacy: database: secretRef: name: my-workspace-privacy-dbOnce provisioned, the privacy-api’s resolved URL is published on
status.privacyURL. To manage consent and opt-out preferences
against it, see Manage User Consent; to submit
right-to-erasure requests, see Handle Data Subject Erasure.
builder
Section titled “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 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 a builder session declares the project on: one the operator configures, or one in this Workspace’s sandboxMountRoles. A DNS label |
spec: builder: agent: authoring-agent sandbox: authoringsandboxMountRoles
Section titled “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,
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.
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: falsequotas
Section titled “quotas”Caps on the workspace namespace’s compute requests, object counts and
AgentRuntimes. The controller writes compute and object caps into a Kubernetes
ResourceQuota
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 that
supplies default limits.
The ResourceQuotaReady condition reports the outcome, and
status.quotas.resourceQuota the limits and current usage.
quotas.agents
Section titled “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).
The Workspace’s AgentQuotaWithinLimits condition and status.quotas.agents
report the whole.
spec: quotas: compute: requests: cpu: "16" memory: 32Gi objects: pods: 100 persistentVolumeClaims: 10 agents: maxAgentRuntimes: 20 maxReplicasPerAgent: 5 arena: maxConcurrentJobs: 3 maxJobsPerDay: 50 maxWorkersPerJob: 4quotas.arena
Section titled “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). The Workspace’s
ArenaQuotaWithinLimits condition and status.quotas.arena report the whole.
networkPolicy
Section titled “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)
Section titled “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-systemon 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/0excluding RFC 1918 private IP ranges:10.0.0.0/8- Class A private network172.16.0.0/12- Class B private networks192.168.0.0/16- Class C private networks169.254.0.0/16- link-local, home of the cloud metadata service; excluded even whenallowPrivateNetworksis true
This allows agents to reach external LLM APIs while blocking access to other tenants’ pods and internal cluster services.
Basic isolation
Section titled “Basic isolation”spec: networkPolicy: isolate: trueThis creates a NetworkPolicy named workspace-{name}-isolation with default rules.
Restrict external access
Section titled “Restrict external access”Disable external API access (removes the 0.0.0.0/0 egress rule; DNS, operator-namespace and same-namespace rules remain):
spec: networkPolicy: isolate: true allowExternalAPIs: falseAllow private networks (local development)
Section titled “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:
spec: networkPolicy: isolate: true allowPrivateNetworks: true # Allows 10.x, 172.16.x, 192.168.xCustom ingress rules
Section titled “Custom ingress rules”Allow traffic from specific namespaces (e.g., ingress controller):
spec: networkPolicy: isolate: true allowFrom: - peers: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: ingress-nginxCustom egress rules
Section titled “Custom egress rules”Allow egress to internal databases:
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 # RedisNetworkPolicyRule structure
Section titled “NetworkPolicyRule structure”| Field | Type | Description |
|---|---|---|
peers | []NetworkPolicyPeer | Sources (ingress) or destinations (egress) |
ports | []NetworkPolicyPort | Ports to allow (optional, all ports if omitted) |
NetworkPolicyPeer structure
Section titled “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
Section titled “NetworkPolicyPort structure”| Field | Type | Description |
|---|---|---|
protocol | string | TCP, UDP, or SCTP (default: TCP) |
port | integer | Port number |
costControls
Section titled “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 and the WithinBudget,
BelowCostAlertThresholds and CostUsageAvailable 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.
spec: costControls: dailyBudget: "100.00" monthlyBudget: "2000.00" budgetExceededAction: pauseJobs alertThresholds: - percent: 80 - percent: 95Status fields
Section titled “Status fields”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
Section titled “observedGeneration”Most recent generation observed by the controller.
namespace
Section titled “namespace”Namespace status information.
| Field | Description |
|---|---|
status.namespace.name | Name of the created namespace |
status.namespace.created | Whether namespace was created by controller |
serviceAccounts
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “privacyURL”Resolved URL of the per-workspace privacy-api. Populated only when
spec.privacy is set; empty otherwise.
| Field | Description |
|---|---|
status.privacyURL | In-cluster base URL of the provisioned privacy-api service |
conditions
Section titled “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
Section titled “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.
kubectl get workspace my-ws -o jsonpath='{.status.conditions[?(@.type=="OptOutEnforced")]}'Complete example
Section titled “Complete example”apiVersion: omnia.altairalabs.ai/v1alpha1kind: Workspacemetadata: name: customer-supportspec: 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
Section titled “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
Section titled “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
Section titled “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
Section titled “Authorization flow”- User authenticates via OIDC (Okta, Azure AD, Google).
- The JWT carries claims:
{ email, groups: ["group1", "group2", ...] }. - management-api checks which groups match workspace
roleBindings, and whether the user has an unexpireddirectGrantsentry. - The user gets the highest privilege role found, never lower than the
anonymousAccessrole when enabled. - management-api makes Kubernetes API calls using the workspace ServiceAccount token for that role.