Skip to content

Workspace CRD

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

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

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

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

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

FieldTypeRequired
displayNamestringYes
spec:
displayName: "Customer Support Team"

Optional description of the workspace.

FieldTypeRequired
descriptionstringNo
spec:
description: "Team responsible for customer support AI agents"

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.

FieldTypeDefaultRequired
environmentstringdevelopmentNo

Allowed values: development, staging, production.

spec:
environment: production

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.

FieldTypeRequired
defaultTagsmap[string]stringNo
spec:
defaultTags:
team: "customer-support"
cost-center: "CC-1234"
business-unit: "support-ops"

Kubernetes namespace configuration for the workspace.

FieldTypeRequired
namespace.namestringYes
namespace.createbooleanNo (default: false)
namespace.labelsmap[string]stringNo
namespace.annotationsmap[string]stringNo

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"

Maps IdP groups and ServiceAccounts to workspace roles.

FieldTypeRequired
roleBindings[].groups[]stringNo
roleBindings[].serviceAccounts[]ServiceAccountRefNo
roleBindings[].rolestringYes

ServiceAccountRef:

FieldTypeRequired
namestringYes
namespacestringYes

Available roles:

RoleDescription
ownerFull workspace control including member management
editorCreate/modify resources but cannot manage members
viewerRead-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: editor

Per-user grants for exceptions to roleBindings.

FieldTypeRequired
directGrants[].userstringYes
directGrants[].rolestringYes
directGrants[].expiresstring (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 access

Configures access for unauthenticated users.

FieldTypeRequired
anonymousAccess.enabledbooleanYes
anonymousAccess.rolestringNo (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 users

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.

FieldTypeRequired
runtime.serviceAccountNamestringNo
runtime.podLabelsmap[string]stringNo
runtime.podAnnotationsmap[string]stringNo
runtime.runtimeClassNamestringNo
FieldDescription
serviceAccountNameThe 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.
podLabelsLabels 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.
podAnnotationsAnnotations added to every agent runtime pod.
runtimeClassNameThe 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.

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.

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: 70

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.

FieldTypeRequiredDescription
database.secretRef.namestringYesName of the Secret containing POSTGRES_CONN. The schema rejects an absent or empty name
providerRef.namestringNoProvider with role embedding. Memory requires it: without it the group gets EmbeddingProviderReady=False (reason ProviderNotConfigured) and no memory-api
nextProviderRef.namestringNoProvider of the next embedding model, for an embedding-model change. See below
policyRef.namestringNoMemoryPolicy applied to this group
maintenance.schedulestringNoCron 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.trashRetentionstringNoThe 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
redisobjectNoGroup-specific Redis override
podOverridesobjectNoPod identity, scheduling, and secret-store overrides. See podOverrides
resourcesobjectNoContainer 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-policy

See the Memory API reference and MemoryPolicy reference for service behavior and policy fields.

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

FieldTypeRequiredDescription
database.secretRef.namestringYesName of the Secret containing POSTGRES_CONN. The schema rejects an absent or empty name
redisobjectNoGroup-specific Redis override for the session hot cache
podOverridesobjectNoPod identity, scheduling, and secret-store overrides. See podOverrides
resourcesobjectNoContainer requests and limits. Default: requests of 50m CPU and 64Mi memory, no limits. See Service resources
otlp.enabledbooleanNoTurn 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: true

See Ingest OpenTelemetry traces into sessions.

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:

ConsumerPrecedence
session-api, the group’s eval-workersession.redis > redis > operator session Redis
memory-apimemory.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 } }

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.

FieldTypeRequiredDescription
enabledbooleanNoCreate the worker even when every eval-enabled agent in the group is PromptKit, so their llm_judge evals run out of band. Default false
podOverridesobjectNoPod overrides for the worker. Takes precedence over the agent’s spec.evals.podOverrides. See podOverrides
resourcesobjectNoContainer requests and limits. Default: requests of 50m CPU and 128Mi memory, no limits. See 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.

ServiceDefault CPU requestDefault memory request
session-api50m64Mi
memory-api50m128Mi
privacy-api50m64Mi
eval-worker50m128Mi

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: 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.

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.

FieldTypeRequired
services[].privacyPolicyRef.namestringNo
spec:
services:
- name: default
privacyPolicyRef:
name: gdpr-compliant

See the SessionPrivacyPolicy CRD reference and Configure Privacy Policies for policy resolution order and per-agent overrides.

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.

FieldTypeRequired
privacy.database.secretRef.namestringYes
privacy.resourcesobjectNo

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-db

Once 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.

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.

FieldTypeRequiredDescription
builder.agentstringNoThe builder’s AgentRuntime in the workspace namespace
builder.sandboxstringNoThe 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.projectPathstringNoWhere that sandbox mounts the project, as the builder agent is told it: its class’s filesystem.workspace.mountPath. Starts with /
builder.mountRolestringNoThe 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: authoring

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.

FieldTypeRequiredDescription
namestringYesThe role a declaration and a SandboxClass’s mounts name. A DNS label, not scratch or session
pathstringYesThe 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)
mustExistboolNoDefault true: a declaration whose directory is not there is refused (MountNotFound). false: the operator creates the directory as the claim binds
minRoleviewer | editor | ownerNoDefault 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: false

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.

FieldTypeResourceQuota keyRequired
quotas.compute.requests.cpuquantityrequests.cpuNo
quotas.compute.requests.memoryquantityrequests.memoryNo
quotas.objects.podsinteger (min 0)podsNo
quotas.objects.servicesinteger (min 0)servicesNo
quotas.objects.configMapsinteger (min 0)configmapsNo
quotas.objects.secretsinteger (min 0)secretsNo
quotas.objects.persistentVolumeClaimsinteger (min 0)persistentvolumeclaimsNo

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.

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.

FieldTypeRequiredEffect
quotas.agents.maxAgentRuntimesinteger (min 0)NoHow 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.maxReplicasPerAgentinteger (min 1)NoCap 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: 4

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.

FieldTypeRequiredEffect
quotas.arena.maxConcurrentJobsinteger (min 0)NoHow 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.maxJobsPerDayinteger (0 to 1000)NoHow 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.maxWorkersPerJobinteger (min 1)NoCap 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.

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.

FieldTypeDefaultRequired
networkPolicy.isolatebooleanfalseNo
networkPolicy.allowExternalAPIsbooleantrueNo
networkPolicy.allowPrivateNetworksbooleanfalseNo
networkPolicy.allowFrom[]NetworkPolicyRule[]No
networkPolicy.allowTo[]NetworkPolicyRule[]No

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 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.

spec:
networkPolicy:
isolate: true

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

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: false

Allow 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.x

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

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

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 # Redis
FieldTypeDescription
peers[]NetworkPolicyPeerSources (ingress) or destinations (egress)
ports[]NetworkPolicyPortPorts to allow (optional, all ports if omitted)
FieldTypeDescription
namespaceSelector.matchLabelsmap[string]stringSelect namespaces by label
podSelector.matchLabelsmap[string]stringSelect pods by label
ipBlock.cidrstringIP block in CIDR notation
ipBlock.except[]stringCIDRs to exclude from the block
FieldTypeDescription
protocolstringTCP, UDP, or SCTP (default: TCP)
portintegerPort number

Budgets for the workspace, enforced by the operator.

FieldTypeDefaultRequired
costControls.dailyBudgetstring-No
costControls.monthlyBudgetstring-No
costControls.budgetExceededActionwarn | pauseJobs | blockwarnNo
costControls.alertThresholds[].percentinteger, 1-100-Yes, per entry
costControls.alertThresholds[].notifylist 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:

ActionEffect
warnThe condition and a Warning event only
pauseJobsAlso 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
blockAlso 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: 95

Current lifecycle phase of the Workspace.

ValueDescription
PendingThe workspace is provisioning, or its storage PVC is not bound
ReadyWorkspace is ready for use
DegradedThe 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)
ErrorReconciling the namespace, ServiceAccounts, RoleBindings, NetworkPolicy, storage or services failed

Most recent generation observed by the controller.

Namespace status information.

FieldDescription
status.namespace.nameName of the created namespace
status.namespace.createdWhether namespace was created by controller

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

FieldDescription
status.serviceAccounts.ownerName of the owner ServiceAccount
status.serviceAccounts.editorName of the editor ServiceAccount
status.serviceAccounts.viewerName of the viewer ServiceAccount

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

FieldDescription
status.members.ownersCount of owner entries
status.members.editorsCount of editor entries
status.members.viewersCount of viewer entries

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

FieldDescription
status.networkPolicy.nameName of the generated NetworkPolicy
status.networkPolicy.enabledWhether network isolation is active
status.networkPolicy.rulesCountTotal number of ingress and egress rules

One entry per spec.services[] group.

FieldDescription
status.services[].nameService group name
status.services[].sessionURLResolved session-api URL for the group
status.services[].memoryURLResolved memory-api URL for the group
status.services[].readyWhether every component of the group is operational
status.services[].conditionsPer-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[].memoryStoreThe 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

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

FieldDescription
status.quotas.resourceQuota.nameName of the operator-managed ResourceQuota
status.quotas.resourceQuota.hardIts limits, from spec.quotas.compute and spec.quotas.objects
status.quotas.resourceQuota.usedIts status.used, as the Kubernetes quota controller last counted it. Empty until the first count
status.quotas.agents.agentRuntimesAgentRuntimes in the namespace, not counting those being deleted
status.quotas.agents.admittedAgentRuntimes maxAgentRuntimes admits, oldest first. Can be longer than a lowered limit. Absent when maxAgentRuntimes is unset
status.quotas.agents.waitingAgentRuntimes waiting for admission
status.quotas.agents.clampedAgentRuntimes 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.

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

FieldDescription
status.costUsage.dailySpendUSD spent since 00:00 UTC on day, to four decimal places
status.costUsage.dayThe UTC date (YYYY-MM-DD) dailySpend covers
status.costUsage.dailyBudgetThe daily budget it was measured against
status.costUsage.dailyPercentdailySpend as a whole percent of the budget, rounded down. Absent without a daily budget
status.costUsage.dailyAlertPercentThe highest alert threshold already announced for day
status.costUsage.monthlySpendUSD spent since the start of month (UTC)
status.costUsage.monthThe UTC month (YYYY-MM) monthlySpend covers
status.costUsage.monthlyBudgetThe monthly budget it was measured against
status.costUsage.monthlyPercentmonthlySpend as a whole percent of the budget, rounded down
status.costUsage.monthlyAlertPercentThe highest alert threshold already announced for month
status.costUsage.lastUpdatedWhen the figures were last written: when they change, and at least hourly after a successful read

Resolved URL of the per-workspace privacy-api. Populated only when spec.privacy is set; empty otherwise.

FieldDescription
status.privacyURLIn-cluster base URL of the provisioned privacy-api service
TypeDescription
ReadyOverall 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
NamespaceReadyNamespace is created and configured (reason NamespaceConflict when another Workspace owns it)
ServiceAccountsReadyServiceAccounts are created
RoleBindingsReadyRBAC resources are configured
NetworkPolicyReadyNetworkPolicy reconciled; True also when isolation is disabled
ResourceQuotaReadyspec.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
AgentQuotaWithinLimitsPresent 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
ArenaQuotaWithinLimitsEnterprise; 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
StorageReadyWorkspace storage PVC is bound, or StorageNotRequired when storage is not enabled
ServicesReadyEvery service group is ready; when not, the message names the group and its first failing condition. Absent when spec.services is empty
PrivacyPolicyResolvedEvery services[].privacyPolicyRef resolves to a SessionPrivacyPolicy in the workspace namespace (reasons PolicyResolved, DefaultPolicy, PolicyNotFound). Informational: a missing policy does not block reconciliation
OptOutEnforcedWhether user opt-out is actually being enforced in this workspace
WithinBudgetPresent 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
BelowCostAlertThresholdsPresent when alertThresholds is set. False (reasons DailyAlertThresholdReached, MonthlyAlertThresholdReached) while spend has reached a threshold in the current period
CostUsageAvailablePresent 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

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

StatusReasonMeaning
TruePrivacyEnforcedprivacy-api is serving; opt-out is enforced
FalsePrivacyNotConfiguredspec.privacy is unset. Opt-out is not enforced and all writes are recorded — a valid configuration, not a fault
FalsePrivacyImageUnsetspec.privacy is set but the operator has no privacy-api image, so none was deployed. An operator misconfiguration
FalsePrivacyUnavailableprivacy-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.

Terminal window
kubectl get workspace my-ws -o jsonpath='{.status.conditions[?(@.type=="OptOutEnforced")]}'
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"

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

EndpointDescription
GET /api/workspacesList 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}/agentsList agents in workspace
GET /api/workspaces/{name}/promptpacksList prompt packs in workspace
GET /api/workspaces/{name}/agents/{agentName}/logsGet agent logs
GET /api/workspaces/{name}/agents/{agentName}/eventsGet agent events

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.

  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.