Skip to main content
Everything after the install. This runbook is written for the person who is paged at 02:00 and has never read the Genesis source: each procedure states what you run, what a good result looks like, and what to do when it is not. It is cloud-agnostic — Genesis on Kubernetes is the same StatefulSet, the same chart and the same data volume on GKE, EKS and AKS. Only the substrate underneath differs, and every substrate difference is collected in Per-cloud specifics at the end rather than scattered through the text.
Installing, not operating? Start with the install runbook for your cloud — GKE, EKS, AKS — and come back here once it is up.

Pin the identity before you touch anything

Every procedure below needs these facts, and every one of them is read from the cluster rather than remembered. Write them at the top of your incident notes.
The rest of this runbook writes $RELEASE-0 for brevity, which is right on a default install. Where yours differs, substitute the pod name from step 3.
The release name is not a version. helm upgrade with a new release name creates a new StatefulSet, which creates a new, empty PersistentVolumeClaim — the old data stays on an orphaned claim under the old name. This is the single most common way a Genesis deployment loses its database. Always upgrade the release name helm list reports, in the namespace helm list reports.
Read the workspace namespace back; never assume genesis-cli. It is configurable (cliTerminal.namespace), and on a cluster running more than one Genesis instance the chart’s default is somebody else’s namespace, full of other people’s attached sessions:
Note what that does not tell you. helm get values -a returns computed values, so every release installed from a chart that ships genesis-cli as its default reports genesis-cli whether the feature is on or off. The answer names a namespace; it does not establish that this release owns it. Ownership is the genesiscomputing.ai/instance-namespace label on the live namespace — see Teardown without data loss.

Upgrade

A Genesis upgrade is normally an image upgrade: same chart, same release name, same namespace, same PVC. The pod is replaced; the volume reattaches.

1. Pull the new image into your mirror

If your nodes pull from a mirror rather than from the Genesis registry, the mirror is the first thing to update — and the tag you are about to deploy must exist in it. crane copies registry-to-registry without a local Docker daemon and preserves the source digest and every platform in the manifest:
Mirror the workspace image too, in the same pass. An operator who upgrades the backend and forgets the sandbox runner gets a working console in which every terminal fails to start — see Every workspace is ImagePullBackOff.
On a laptop, pin the platform. docker pull without --platform linux/amd64 on an Apple-silicon machine mirrors an arm64 image that your nodes cannot run, and the failure surfaces later as a crash-looping pod rather than as a bad copy. crane copy has no such trap — it copies the whole manifest list.

2. Run the upgrade

Do not use --reuse-values. It carries the previous release’s values forward and ignores the new chart’s defaults, so a values key introduced after your release was installed is simply missing at upgrade time. The chart has been broken this way for real: secrets.existingSecretMintlifyJwtPrivateKey was added with a correct default, which does nothing on a --reuse-values upgrade, and every lab environment failed to render at once. Always pass your complete values file with -f. (The chart now guards this case — validate-reuse-values-upgrade.sh renders with each optional secret map explicitly absent — but -f is still the supported path.)

3. Verify by content, not by “Ready”

A Ready pod proves a process started. It does not prove it is the build you shipped: main, latest and any other moving tag resolve to whatever the registry holds at pull time, and IfNotPresent will happily keep a cached older layer under the same tag. Verify the digest, and then verify the behaviour you upgraded for.
Gate — do not declare the upgrade done until all four hold: (a) equals (b); (c) is 200; (d) names the same Bound claim as before; and the specific behaviour you upgraded for is exercised by hand. If (a) and (b) disagree, the node is running a cached layer — see The upgrade “worked” but the old behaviour persists.

What “version-gated” means

Some Genesis fixes change behaviour that has no switch: the code either does the old thing or the new one. A fix described as version-gated is delivered only by running an image that contains it, and no amount of configuration reproduces it on an older image. That is why step 3(a) reads the digest rather than the tag. When a support answer says “fixed in current builds”, the question it is answering is which image is running — and the only answer that settles it is the digest, checked against the digest of the build the fix landed in.

Rollback

Roll the release back

helm rollback restores the rendered manifest of that revision — including the image reference it carried. That reference is always repository:tag: the chart takes a tag, not a digest. So if your values file pins a moving tag (main), the rollback restores the tag and not the build, the node may pull the newest image again, and you land back where you started with a green rollback to show for it. The fix is to make the tag itself immutable. Mirror the known-good digest to a tag of your own that nothing will ever move, then deploy that tag:
Then verify by digest exactly as in step 3 of an upgrade — the running imageID must equal the digest you copied. The point is the same in both directions: deploy a build, not a label.
Never leave image.tag unset. With no tag the chart falls back to the chart’s appVersion, which is frequently a tag that does not exist in the registry your nodes pull from — so the rollback “succeeds” and the pod never starts.

What survives a rollback, and what does not

A rollback to a chart revision older than 0.3.70 strips the retention annotation from the live objects while leaving the objects in place. The namespace and the ServiceAccounts stay, but they are no longer protected: the next operation that removes them from a rendered manifest will delete them, and a namespace delete cascades to every PVC inside it. Re-check after any rollback:
Gate — rollback complete when: helm history shows the target revision deployed; the running digest is the one you intended; /api/healthcheck returns 200; the PVC is the same Bound claim; and both namespaces still report resource-policy: keep.

Backup and restore

What is on the volume

One PersistentVolumeClaim per deployment (genesis-data-<statefulset-name>-0 by default), mounted into the backend at four subpaths: Workspace home volumes are separate claims in the workspace namespace, two per user. They hold each person’s saved Claude Code conversations and working tree. They are not on the backend volume and are not covered by a backend snapshot.

Snapshot the volume

The reliable mechanism is a volume snapshot taken by the storage layer, not a kubectl cp. Find the underlying disk, then snapshot it with your cloud’s mechanism (commands per cloud are in Per-cloud specifics):
That last value is the disk your cloud’s snapshot command takes as input. Snapshot that handle — not a claim name you composed, which on a deployment using fullnameOverride names a claim that does not exist, or worse, one that does and belongs to something else.
The SQLite WAL must be captured with the database — checkpoint first. Genesis runs SQLite in write-ahead-logging mode: recent committed transactions live in genesis.db-wal and genesis.db-shm beside genesis.db until a checkpoint folds them in, and a deployment holds several databases across /app/.genesis/db and /app/bot_storage. A raw snapshot taken mid-transaction, or a file copy that takes genesis.db alone, restores the database as of the last checkpoint rather than as of the snapshot — and it restores cleanly, with no error, which is what makes this dangerous.The image ships a pre-backup hook that folds every WAL in, in place, in seconds, without stopping the application. Run it immediately before every snapshot:
It exits non-zero if any database failed to checkpoint, so wire it as a gating step rather than a best-effort one. For Velero, attach it as a pod annotation so every scheduled backup checkpoints first:
The master key is not on the volume. Restoring the data volume without the matching GENESIS_MASTER_KEY gives you a database whose encrypted fields — every stored credential — cannot be read. A volume snapshot is therefore half a backup. The other half is the key, backed up separately, in a place that survives the loss of this cluster. See Secrets.
For a file-level copy — useful as a second, portable line of defence, not as a replacement for volume snapshots — the chart ships one:
It refuses rather than writing an empty archive: it skips with exit 2 if the pod is not Running or /app/.genesis/db is unreadable, and fails with exit 1 on an empty result. Treat any exit other than 0 as “no backup was taken”.

The restore drill

An untested backup is not a backup. Run this on a schedule — quarterly at the least, and once before the deployment carries anything that matters. Restore to a separate release so the drill can never damage the live one.
1

Checkpoint, snapshot, and record the identity

Run the checkpoint hook, then snapshot the live volume. Write down the snapshot id, the claim name, the chart version, the image digest that produced it, and which master key it was encrypted under. A restore into a different build, or under a different key, is a different test.
2

Create a claim from the snapshot

Provision a new PVC from the snapshot using your cloud’s restore mechanism, in a scratch namespace (genesis-drill). Confirm STATUS Bound before going on.
3

Install a second release onto that claim, with the same master key

Name the scratch release, and use that name for the rest of the drill:
Install the same chart version and the same image digest into the scratch namespace, pointed at the restored claim, and supply the same GENESIS_MASTER_KEY the snapshot was taken under. Supplying a different key is how a drill “passes” while proving nothing: the server starts, the threads are there, and every stored credential is unreadable.
The restored volume carries the live deployment’s configuration, including which workspace namespace to use — and cliTerminal.enabled: false pins nothing. Leaving the chart flag off is not enough: the flag only decides whether the chart renders a platform, and the restored APP_CONFIG can still say cli_terminal.enabled: true with k8s_namespace: genesis-cli. The drill’s backend would then create and, worse, reap workspace Pods in the production workspace namespace, where a Pod and both of its volumes are named from a digest of the user id alone — so the drill and the live release collide on real people’s home volumes.Force-lock both Pod-creating features off, by name. These are the double-underscore overrides, which outrank anything stored in the restored database:
Never pass the key itself with --set. It would land in your shell history, in the process arguments of a running command, and in the Helm release record — three places your escrow policy does not cover. Reference a Secret instead. Secrets are namespaced, so copy the live one into the scratch namespace first, stripping the fields that belong to the original object:
Where the deployment resolves its key from a cloud secret manager instead, give the drill the same config.extraEnv.GENESIS_MASTER_KEY_REF and leave secrets.genesisMasterKey empty — the drill’s service account needs read on that secret. Delete the copied Secret with the scratch namespace in Step 5.Then prove the lock took, before anyone touches the drill — a restored database that disagrees is exactly the case this guards against:
To exercise the terminal in the drill instead, give it a workspace namespace of its own (cliTerminal.enabled=true with cliTerminal.namespace=genesis-drill-cli), never the production one — and own that namespace’s whole lifecycle in Step 5, not just the release’s. Two releases must never share one workspace namespace.
4

Prove the data came back, by content

Then sign in and check three things by eye, because a healthy process proves none of them: a named user still exists with their roles; a known chat thread still opens and shows its messages; a configured LLM provider is still configured. Record what you checked and the date.
5

Destroy the drill — all of it

A drill that leaves objects behind has created a second lifecycle beside the real one. Retained objects never go away on their own, so removal is explicit. Follow Teardown without data loss against the drill identity, in that order:
Then confirm nothing of the drill’s remains. Deletion was requested above; these check it happened — and cluster-scoped bindings in particular survive a namespace delete:
A namespace stuck Terminating is not deleted — find what is holding it (kubectl get namespace genesis-drill -o jsonpath='{.status.conditions}') rather than recording the drill as clean.Finally, prove the production workspace namespace came through untouched:
Lines added or removed for Pods are normal — workspaces are created and reaped while you work. Any change to a PVC line is not: a missing or re-UID’d claim is someone’s home volume, and it is an incident, not a drill result.
Gate — the backup is proven only when all five hold:
  1. A claim provisioned from the snapshot reached Bound
  2. The drill release returned 200, with the same master key
  3. A human confirmed a user, a thread and a provider configuration by looking at them
  4. diff /tmp/ws-baseline.txt /tmp/ws-after.txt shows no PVC line changed in the production workspace namespace — same names, same UIDs. The drill never touched live users’ volumes. (Pod lines may differ; workspaces come and go.)
  5. Nothing drill-owned remains, checked rather than assumed: helm list does not show the drill release, both scratch namespaces are NotFound (not Terminating), and no Role/ClusterRoleBinding carries the drill’s instance label
Anything less has tested the storage layer, not the backup — and 4 and 5 are what make it safe to run the drill on a schedule rather than once.

Secrets

The master key

GENESIS_MASTER_KEY encrypts every credential Genesis stores. It is required — the chart refuses to render without it — and it is not recoverable: lose it and every stored connection credential in the database becomes undecryptable ciphertext, which is a rebuild, not a recovery. Three supported ways to supply it, in increasing order of operational maturity: Generate one with python3 -c "import secrets; print(secrets.token_hex(32))".
Name an escrow owner before go-live, in writing. The key must exist in a corporate secret store that survives the departure of whoever installed Genesis, with a named owner and a named deputy. This is an operator responsibility and there is no product feature that substitutes for it: Genesis cannot re-derive the key, and the deployment’s credentials are only as recoverable as the escrow record.
There is no master-key rotation procedure, and swapping the key is not a values change. Genesis documents none, and the reason is structural: every credential in the database is ciphertext under the current key, so changing the key makes all of it unreadable — the server starts, and every connection fails. Treat the key as fixed for the life of the deployment, and protect it by escrow rather than by rotation.If a rotation is forced on you by policy or by a suspected compromise, it is a migration, not an upgrade: plan a maintenance window, take and verify a backup first, expect to re-enter every stored credential under the new key by hand, and confirm each connection afterwards. Do not begin one on the strength of this paragraph — raise it with Genesis first.The corollary for backups: rotate the key and your existing backups stop being restorable. If the key ever changes, take a fresh verified backup the same day.

LLM and database credentials

Service principal or PAT? A personal access token is issued to a person and dies with their account — the classic Genesis outage is an LLM that stops answering because the engineer who installed it left the company. For anything beyond a trial, authenticate as a service principal / machine identity owned by a team, and record its expiry in the same calendar that holds your certificate renewals. Where the platform supports it, prefer no stored credential at all: the chart’s genesisConfig path accepts auth_mode: "iam_role" for Bedrock, which authenticates through the pod’s cloud identity and stores nothing.
Two further secrets the chart accepts, both with a documented rotation:
  • The Mintlify documentation signing key (Ed25519, PKCS#8) is accepted only as an existing Kubernetes Secret — never in genesisConfig.content, config.extraEnv, a ConfigMap, or a plaintext Helm value. Rotating it is update-the-Secret and roll the workload, because the pod’s environment is what carries it. Give it a named security or platform owner, the same way the master key gets one.
  • Custom CA certificates are fetched once at pod startup, so rotating one needs a StatefulSet restart. The same is true of any value resolved from a cloud secret manager: after you rotate it there, restart so the pod re-resolves it.
An existingSecret* reference is the rotation-friendly shape for all of these, because the Secret is updated without a helm upgrade and the credential never appears in the release record. The pod reads these into environment variables at start, so a rotation takes effect on the next pod restart:
Gate — a rotation is complete when the pod has restarted onto the new Secret, /api/healthcheck returns 200, a chat message gets a reply, and the embedding endpoint is verified separately (see Health and verification). Chat and embeddings can use different credentials and different endpoints; proving one says nothing about the other.

Configuration precedence

Genesis reads one setting from up to five places. When a change “does not take effect”, it is almost always because a higher tier is holding the old value. Highest wins, and the Config Sources page names the tier that actually won for any field — which is the fastest way to end an argument about it: Read the two environment-variable spellings carefully, because they look alike and behave oppositely:
  • GENESIS__CLI_TERMINAL__ENABLED — prefix GENESIS__, then the config path uppercased with each . replaced by __. This is the force-lock: it is applied when the configuration snapshot is built, so it outranks everything below it. It is never persisted to the database.
  • GENESIS_CLI_TERMINAL_ENABLED — single underscores. This is only a default. For a field declared env_leaf, the config-to-env bridge overwrites this variable with the admin’s stored value, live and with no restart — so on its own it does not hold against the panel.
A force-lock does more than pin the value — through the panel it refuses the write. An admin editing a GENESIS__-locked field in Advanced Settings gets an error, not a stored-but-overridden value:
A reset on a locked field is refused the same way, and nothing is deleted:
A batch update containing one locked path is refused in its entirety. The lock test is the presence of the variable, whatever its value.Two consequences worth knowing before you debug one: on a chart-managed install with cliTerminal.enabled=true, the three pinned rows below cannot be changed or reset from the panel at all — the failure is a configuration error in the UI, not a 403 at attach time. And the other write paths do not perform this check: a genesisConfig.content apply, the config CLI, and the AI Providers / Embeddings pages — which own llm.active_provider, embedding.active_provider and embedding.active_model — store the row, and the override simply keeps winning. So a stored value can exist under a lock even though the panel would have refused to create it, and for those three fields a save under a pin is accepted and takes effect only once the pin is removed.
Editing genesisConfig.content can wipe Advanced Settings. The file is not a precedence tier; it is a loader that writes into tier 2 at startup, and it behaves in two very different ways depending on whether its content changed:
  • Content unchanged — it fills in only fields that have no stored value, and never overwrites an admin’s panel setting.
  • Content changed (its hash differs, or the file path changed) — it performs a full replacement, overwriting and deleting stored rows.
So editing one line of that file and running helm upgrade discards configuration your admins set in the UI. This is the most destructive precedence interaction in the system. Decide up front whether the values file or the panel is the source of truth for a given field, and if it is the file, tell your admins the panel is advisory for it.

Which fields the chart pins

Setting cliTerminal.enabled=true writes, as container env, both spellings of three fields: cliTerminal.maxWorkspaces is deliberately not pinned: lowering the cap in the panel is safe and a lock would block it to prevent the unsafe direction. See Scaling for the consequence of raising it there. Two asymmetries that surprise people:
  • cliTerminal.enabled=false pins nothing. The chart emits no lock at all when the feature is off, because deployments that provision the workspace namespace and RBAC outside the chart enable the feature through APP_CONFIG and must keep working. So false means “the chart provides no platform”, not “the feature is off everywhere”.
  • Off ECR, the workspace image is yours and is not locked. With ecr.enabled, the chart owns the image and pins it, because the pull secret it places authenticates to that registry and nowhere else. Off ECR, repointing the workspace image stays an admin’s to make.

What applies live and what needs a restart

Reset to defaults

“Set default” on a configuration row deletes the stored value and falls back to the tier below. On a current build, for an unlocked cli_terminal.* or sandbox_runner.* row, that restores the deployment’s own pre-panel value — the value the environment held before an admin ever touched the panel — rather than the schema default. The response says so explicitly when it happens, naming the paths that landed on an environment default rather than a schema one. For a locked row the reset is refused outright and nothing is deleted, as above. On a chart-managed install that covers cli_terminal.enabled, .runtime and .k8s_namespace: they cannot be reset from the panel, by design.
On an older image, “reset to defaults” reads as off while the feature stays live. Before the fix, using Set default on a cli_terminal.* or sandbox_runner.* row deleted the stored value and reported success — the panel then showed the feature off — while the environment variable the runtime actually reads kept the value the panel had bridged into it. The terminal stayed reachable until the pod restarted: a live shell behind an “off” switch.This is version-gated. If your image predates the fix, either upgrade it, or verify the real state after a reset — attempt an attach, or restart the pod — rather than trusting the panel.

Users: onboarding and offboarding

Onboarding

  1. The user signs in through your identity provider. Genesis maps their IdP groups to Genesis roles on each login, from the groupRoleMapping file supplied to the chart, so group membership is the thing to change — not anything inside Genesis.
  2. Terminal access is permission-gated, separately from the feature being enabled: a user needs the use_cli_terminal permission. Without it the WebSocket closes with “Access denied”, which is a different failure from the feature being off.
  3. The admin CLI Workspaces tab at /pulse/cli-workspaces lists running workspace Pods with restart and cancel, and is gated by the view_sandboxes permission.
The first terminal a user opens creates their workspace Pod and pulls the ~700 MB workspace image, which can take up to a minute. Subsequent attaches are immediate.

Offboarding: the step everyone misses

Revoking the IdP group removes access. It does not reclaim disk.
Workspace volumes are never auto-reclaimed, and lifetime users — not concurrent users — is what grows your disk. Each person who has ever attached holds two durable PVCs (10 GiB each by default) for their home and work directories. Reaping an idle workspace returns CPU and memory; it returns no disk, because the volume is what makes a user’s saved conversations survive the reap. A deployment with 8 people using it and 60 people who have tried it is carrying 120 volumes.Nothing reclaims them automatically and that is deliberate: an “oldest unused” policy would delete the work of whoever is on holiday. Retirement is an operator action tied to a person leaving.
Both of a user’s claims carry the ownership metadata needed to find them — genesis.computing/owner-id as a readable annotation, and genesis.computing/workspace-owner as the selectable digest, because a label value cannot hold an email address:
Cancel the user’s workspace from the admin CLI Workspaces tab (or delete the Pod) first: deleting a claim while its Pod still exists leaves the claim Terminating until the Pod goes.
Gate — offboarding is complete when: the user’s IdP group membership is removed; kubectl -n $WS_NS get pod shows no workspace for them; and kubectl -n $WS_NS get pvc returns zero claims annotated with their address. This is destructive and unrecoverable — it is that person’s saved conversations — so confirm the address in the annotation before deleting, not the digest.

Scaling

The capacity formula

The backend is fixed; workspaces scale with people.
Nconcurrent is bounded by cliTerminal.maxWorkspaces (default 25) and Nlifetime by cliTerminal.maxLifetimeUsers (default 50). At those defaults the feature asks for 12.5 CPU and 100 GiB reserved, bursting to 200 GiB, on top of the backend’s own 4 CPU / 16 GiB — and up to 1000 GiB of durable disk at the claim ceiling. A count quota reserves nothing, so the disk ceiling costs only what people actually claim. Three facts that make real deployments size differently than expected:
  • A workspace outlives the connection. Disconnecting does not delete the Pod; the idle reaper does, after cli_terminal.idle_reap_seconds (default 1 hour). Size for the peak number of workspaces that exist, not for the number attached right now. Shortening the reap interval returns capacity sooner and never costs a user work.
  • One user is one Pod, however many agents they run. Up to cli_terminal.max_sessions_per_workspace (default 8) sessions share one container’s CPU and memory. More sessions per user is cheaper than more users — and more likely to hit the memory ceiling, which OOM-kills the container and takes every session that user has open.
  • Reserved is not used. A cluster sitting at 5% observed CPU can correctly refuse to schedule a workspace. Compare requests against allocatable minus committed requests, never against kubectl top.

Raising the cap

Raise it in the values file, never in Advanced Settings. maxWorkspaces renders the namespace’s count/pods ResourceQuota and is written to the backend as GENESIS_CLI_TERMINAL_MAX_WORKSPACES. That env var is only a default, and the field is env_leaf, so an admin raising the cap in the panel outranks it immediately while the quota stays where it was rendered. The application then admits a workspace the API server refuses with a bare 403 — and the quota is the authority. Lowering it in the panel is harmless; raising it is what bites.
Then confirm the cluster can actually supply the new number, which is a different question from whether the quota permits it:

Node auto-upgrade will interrupt sessions

A managed node pool upgrade drains and replaces nodes. Workspace Pods are evicted with them: running agent sessions are interrupted, and every volume survives, so users resume on their next attach on a new Pod. Treat node auto-upgrade as a maintenance window, not as a transparent operation. Set the maintenance window to your users’ off-hours, announce it, and prefer draining workspaces yourself beforehand so the interruption happens when you chose it:
A workspace Pod is pinned to its volume’s zone. The home PVC is ReadWriteOnce and, on zonal storage, bound to one zone — so a regional cluster with plenty of free capacity in other zones leaves a workspace Pending indefinitely with didn't match PersistentVolume's node affinity. Shrinking the workspace size does not fix it; the constraint is the zone. Capacity has to exist where that user’s volume already lives.

Health and verification

Endpoints

First boot legitimately takes up to 10 minutes. The startup probe allows failureThreshold: 60 × periodSeconds: 10, because the first boot builds the database and warms sessions. /api/health/startup tells you whether that time is being used or wasted — check it before restarting a pod that is simply still working.
The token-free endpoints are matched exactly — no prefix matching, and GET/HEAD only — so /api/health/detailed authenticates even though /api/health does not. That matters behind an auth_request-mode ingress: an ingress controller’s auth annotation applies to the whole Ingress, so an external probe gets a login redirect rather than a health answer. The chart’s ingress.authExemptHealth block renders a second Ingress carrying only those exact probe paths, listed with and without a trailing slash, so health checks read server liveness instead of the proxy’s login page.

Chat working does not mean embeddings working

This is the failure that costs the most trust, because nothing looks broken. Chat and embeddings are separate routes, frequently separate endpoints, and sometimes separate credentials. When embeddings fail, Genesis keeps answering: semantic search silently falls back to graph-only lexical matching, so queries still return results — worse ones — and the data-context tool reports index_status: ready_with_warnings rather than an error.
ready_with_warnings is not an embedding verdict. An install whose embeddings are completely dead still answers every query as ready_with_warnings from the lexical fallback. It tells you a query had warnings; it does not tell you semantic search is working. Use the two surfaces below instead.
Both are present-tense: they report whether embedding works now, not whether it ever did. 1. The AI Providers page, at /config/ai-providers/ (embeddings tab ?tab=embeddings), shows an embedding status chip with its reason. It is fed by GET /api/config/ai-providers/embedding-health, deliberately as a separate request from the page overview, so a slow or absent indexing subprocess delays the status alone rather than the whole page. The chip does not poll — it is a one-shot read with a 30-second cache, so reload the page to re-check rather than watching it. 2. GET_INDEX_STATS, which is an action on the lineage tool, not a REST endpoint. Over HTTP:
It returns embedding_provider, embedding_model, embedding_status and embedding_status_reason from one snapshot, so the verdict always describes the identity printed beside it — alongside index counts. It costs nothing: it reads recorded outcomes and makes no provider call. All four embedding fields are present on every successful response, including on an install with no embedding client at all — that case reports unconfigured. Alert on the value, and treat an absent embedding_status as a fault in the check itself rather than as a pass: a response without one came from a Twin too old to carry the field, and says nothing about whether embedding works. The reason is shown for every value, ok included. 3. Force a real embedding call. The two surfaces above read recorded evidence; when the verdict is unverified or you have just rotated a credential, make the deployment actually embed something:
It issues a real embedding request against the configured provider and model and returns the vector dimension it got back. "success": false carries the provider’s own error text, which is usually the whole diagnosis. This is the only surface that makes a provider call on demand, and it needs ACCESS_CONFIG_PANEL. 4. The boot-time smoke test leaves a greppable line in the logs, so an incident can be dated to a restart:
Gate — the deployment is healthy when all five hold. Anything less is a partial answer:
  1. curl -sS -o /dev/null -w '%{http_code}\n' https://<host>/api/healthcheck → 200
  2. kubectl -n $NS get pods and kubectl -n $WS_NS get pods → nothing in ImagePullBackOff, CrashLoopBackOff or Pending
  3. A signed-in user sends a chat message and gets a reply
  4. The embedding connection test returns "success": true with a dimension count — not merely a chip that is not red
  5. A user opens a terminal and gets a shell

The chart’s own validators

The chart ships render-time validators, and they are packaged inside the chart tarball, so an operator who never sees the source can still run them:
Expected final lines, and both exit 0:
These render the chart locally and never contact a cluster — KUBECONFIG is pinned to /dev/null inside them. They answer “does this chart version still produce a correct, data-safe manifest”, which is exactly the question to ask before an upgrade and after a rollback. They cannot tell you anything about your running deployment; the gate above does that. Invoke them with bash — files inside a Helm tarball are not extracted executable.

Teardown without data loss

The order is not a style preference. Each step removes something the next step needs.
cluster/scripts/… is not in the chart tarball. Unlike the validators, these scripts live in the Genesis repository, so a deployment installed only from the OCI chart does not have them — ask Genesis for them before you need them, not during a teardown.Do not improvise a kubectl delete pod equivalent. The ownership checks described below are the entire point of these scripts: on a cluster running more than one Genesis instance, acting on the chart’s default namespace means stopping another instance’s users mid-turn and, with the wrong flag, destroying their saved conversations.
1

Drain the workspaces first — while the release is still up

This deletes the workspace Pods and nothing else: every PVC and the workspace namespace stay, so each user resumes on their next attach.It must run first because afterwards nothing can do it: the reaper goes with the backend, the RoleBinding that let it act goes with the release, and the release-to-namespace mapping goes with helm get values. It is a separate script from teardown.sh precisely so it is runnable on a release that is staying up — which is also what you run before setting cliTerminal.enabled=false.
2

Uninstall the release

This leaves behind, by design: the data PVC, the Genesis namespace, the workspace namespace and every workspace PVC, the ServiceAccounts, and the per-instance sandbox RoleBinding and ClusterRoleBinding. All carry helm.sh/resource-policy: keep.
3

Revoke the retained RBAC

$INSTANCE_NS, not $NS. The argument is the instance namespace — the identity the chart wrote into the label — and on a release that sets values.namespace that is a different value from the Helm release namespace. Passing the Helm namespace there searches for an owner that does not exist: the script reports nothing to delete, the bindings stay, and the final gate below inspects the wrong identity and agrees. By this point helm uninstall has already removed the release metadata, so $INSTANCE_NS must have been captured before the uninstall — which is why it is read in Pin the identity and not here.
Until this runs, those two bindings keep granting sandbox pod management and namespace read to the instance’s ServiceAccount. They live outside the Genesis namespace — the ClusterRoleBinding is cluster-scoped and the RoleBinding is in the sandbox namespace — so deleting the Genesis namespace does not reach them. An operator who tore the instance down has not revoked what they believe they revoked.Its argument is the Genesis instance namespace, and it selects on the genesiscomputing.ai/instance-namespace label rather than on a Helm release name, so it still works after the release record is gone. It deliberately leaves the shared, fixed-name sandbox objects alone: other instances bind to them.
4

Retire the data, deliberately and last

Whether the underlying disk is destroyed depends on the StorageClass reclaim policy (Delete or Retain) — check it before assuming either.
5

Retire the workspace namespace, separately and explicitly

The workspace namespace holds every user’s home volume and therefore their saved conversations, so nothing removes it implicitly — not helm uninstall, and not setting cliTerminal.enabled=false. Removal is its own command, and it asks first unless you pass --force:
To retire one departed user instead of everyone, use the PVC commands in Offboarding.

Ownership is by instance label, never by release name

Both cleanup scripts refuse to act on a workspace namespace they cannot show belongs to this instance, and the reason is worth understanding before you reach for a flag to override it.
The identity is the instance namespace, never the Helm release namespace. On an installation that sets values.namespace, those are two different values, and comparing a workspace namespace’s owner against the release namespace refuses that instance its own workspaces. Both scripts resolve the identity from the release and carry it into every command they print, because after the uninstall nothing can resolve it again.drain-workspaces.sh --instance-ns <ns> supplies it by hand once no release is left to read. While the release is readable, that flag may only confirm what the release says — a different value is refused, because an identity accepted from the command line would let a live teardown drain another instance’s workspaces by naming that instance. teardown.sh takes no such flag at all.
Gate — teardown is complete when all four hold. Note the selector uses $INSTANCE_NS: on a release that sets values.namespace, checking $NS here asks about an identity that never existed and passes while every binding is still standing.
A PVC you meant to destroy that is still listed is a disk still being billed; a binding still listed is access you believe you revoked and did not.

Incident triage

This catalogue is the single home for Genesis failure modes. The install runbooks link here rather than restating it, so there is one description to keep current. Each row is symptom → cause → fix; read the symptom column first and do not skip to the fix, because several of these present identically and are cured by opposite actions.

The cluster never comes up

Cause. The nodes cannot reach the container registry, so they cannot pull Kubernetes’ own system images (pause, netd, pdcsi, kube-proxy). The node then never registers. Nothing in the Genesis chart or your values file is at fault — and the symptom wears different costumes on different node modes, which is why it reads as a storage, chart or quota problem and is none of them.Verify — the first minute after the cluster object exists. This is the highest value minute in the whole install:
Every system pod should reach Running within roughly a minute. A cluster creation that timed out still leaves a usable kubeconfig behind, so this works on that wreck too — you do not have to delete and retry to diagnose it.Fix. Grant node egress to the registry before creating the cluster: a NAT gateway, a private-access path to the registry, or a tag-scoped internet route. The exact mechanism is per-cloud — see Per-cloud specifics and, for GCP, the detailed remedies in the GKE install runbook.And a route is not a permission. A higher-priority egress deny — a VPC firewall rule, or a policy inherited from a folder or organisation — blocks the pull with a perfect route in place, and the symptom is identical. Read the effective rules, not the network’s own.
Cause. A preemptible / Spot node was reclaimed during the install, churning the node the backend was scheduled on and wedging PVC binding. It reads as a storage fault and is a node one.Fix. Bring the cluster up on on-demand nodes and finish the install. Spot is fine for read-only workloads afterwards.

The deployment is up but something is wrong

Cause. Kubernetes accepts a NetworkPolicy object whether or not your CNI implements it. On a non-enforcing cluster the policy is inert, workspace egress stays wide open, and there is no error on either side — the failure looks exactly like working containment.This is measured, not assumed. From the chart’s own values.yaml:
It is only as good as your CNI. Kubernetes accepts the object whether or not the CNI enforces it. Measured on stock EKS 1.34 with the default VPC CNI: applied cleanly, enforced nothing, workspace reached the public internet. GKE blocked it. Verify.
Verify on the cluster, never from the setting — and run the control first. A blocked connection on its own proves nothing: in a locked-down enterprise VPC the cluster may have no egress to that address at all, so a probe that simply fails tells you the destination was unreachable, not that the policy did the blocking. Two Pods, identical except for the one label the policy selects on:
--attach blocks until each container exits and --rm removes the Pod, so there is nothing to clean up and no half-started Pod to read at the wrong moment.
Two readings that look like a pass and are not. CONTROL-BLOCKED with SUBJECT-BLOCKED is the trap: it looks exactly like containment and is the normal state of a cluster with no internet egress, where the policy may be entirely inert. And silence is never a pass — a Pod that was not scheduled or could not pull busybox prints nothing. Insist on one of the four words from each command; if one is missing, check kubectl -n $WS_NS get events --sort-by=.lastTimestamp | tail and run it again.
Fix. Turn on your CNI’s policy engine — the mechanism is per-cloud, and on GKE it is creation-time only for Dataplane V2, with a Calico retrofit available on an existing cluster. See Per-cloud specifics.Before you treat this as an outage, check whether you needed the policy at all: cliTerminal.networkPolicy.enabled is off by default, and that is the intended posture. See What the network posture actually is.
Three different failures share this description. The WebSocket close reason tells you which, so read it before changing anything — they are cured by opposite actions.Where to read it. The server closes the socket before accepting it, with close code 1008 and the reason as text. Take it from the browser’s devtools console or Network → WS on the failed attach, or from the backend log, which records the same string:
The historical trap. Earlier deployments applied the terminal platform with a separate kubectl apply of a manifest. That is no longer the path: the chart renders the namespace, RBAC and quota itself. A hand-applied copy now diverges silently from the chart’s — only one of the two ever gets updated.
Cause. The quota, not RBAC. cliTerminal.maxWorkspaces renders the namespace’s count/pods ResourceQuota and seeds the backend’s own cap — but the backend’s cap is only a default, and an admin who raised it in Advanced Settings outranks it immediately while the quota stays where it was rendered. The application then admits a workspace the API server refuses.
Fix. Raise it in the values file and re-render — not in the panel. See Raising the cap. The quota is the authority.
Cause. One of two, and they are distinguishable:
  1. The tag is not in the mirror. image.tag must be a tag that actually exists in the registry your nodes pull from. The chart’s default falls back to the chart’s appVersion, which is frequently not a tag present in the distribution registry — only a named channel tag is. An upgrade that mirrors the backend image and forgets the workspace image produces exactly this: a working console, no terminals.
  2. The node’s identity cannot pull. A Kubernetes node pulls as its own service account or instance identity, not as the human who ran helm. A grant made to your corporate directory does not cover the node.
Fix. For (1), mirror the tag, or point GENESIS_CLI_TERMINAL_IMAGE at a tag that exists. For (2), grant the node identity read on the registry — the mechanism is per-cloud, see Per-cloud specifics.
Cause. The embedding endpoint is unroutable or not served. Genesis does not fail the query: semantic search falls back to graph-only lexical matching and the index reports ready_with_warnings, so the install keeps answering and the degradation is invisible from chat.Verify the embedding path independently of chat — they are separate routes and frequently separate endpoints and credentials. In order:
  1. POST /api/config/llm-providers/embedding-config/test — forces a real embedding call and returns the dimension, or the provider’s own error text.
  2. Read embedding_status and embedding_status_reason from the AI Providers page or GET_INDEX_STATS for the recorded history behind that one call.
  3. kubectl -n $NS logs $RELEASE-0 -c genesis | grep -i 'failed startup smoke test' to date the failure to a restart.
All three are in Health and verification.Fix, by what the reason says:Recovery is observable: a single success clears the failure record and the status returns to ok without a restart.
This is a Professional Services responsibility at install time, not a customer-infrastructure one. The embedding serving endpoint must exist and be reachable, and it must be verified separately from chat. An install signed off on “the bot replies” ships with semantic search dead and nobody knowing for weeks.
Cause. A version-gated defect. On older images, Set default on a cli_terminal.* or sandbox_runner.* row deleted the stored value and reported success, while the environment variable the runtime actually reads kept the value the panel had bridged into it. The panel showed the feature off; the terminal stayed reachable until the pod restarted.Fix. Upgrade the image — current builds restore the deployment’s own pre-panel value on reset, and leave a GENESIS__… force-lock winning where one is set. Until then, never trust the panel after a reset: attempt an attach, or restart the pod, and read the real state.
Cause. Starved, not broken. Either the cluster cannot supply the requests, or the user’s volume pins them to a zone with no room, or the node pool cannot grow.
Cause. The pod is Ready on a cached layer under the same moving tag, or the rollout never actually replaced the pod.
Fix. Deploy by digest, or mirror the build to an immutable tag of your own and deploy that. A moving tag plus IfNotPresent is the combination that produces this.
Cause. Helm refusing to adopt an object it did not create — typically the workspace namespace, applied by hand on an earlier deployment. Not a chart fault.
Fix. Set cliTerminal.createNamespace=false and leave the namespace where it is, then delete only the Role, RoleBinding, ResourceQuota and NetworkPolicy so the chart can render its own. Do not delete the namespace — that cascades to every user’s home volume.A namespace this chart created re-adopts cleanly, because retention stops the delete without stripping the ownership annotations Helm wrote. Only a hand-created one is rejected.

What the network posture actually is

State this accurately when asked, because the reassuring version is wrong and the difference is a security finding:
  • By default, a workspace Pod has exactly the network access any other Pod in the cluster has — the same access the Genesis backend has, because the Genesis namespace carries no NetworkPolicy either. That is deliberate: these are development workspaces, and installing packages and cloning repositories is the point of them.
  • The workspace egress lockdown is opt-in (cliTerminal.networkPolicy.enabled=true), confining workspaces to DNS plus the Genesis backend — at the cost of git, gh, npm and pip timing out.
  • It is only as good as the CNI, and an unenforcing CNI reports success. Verify it on the cluster before describing it to a security reviewer.
Do not describe agents as locked to DNS and the backend by default. They are not.

Per-cloud specifics

The Genesis release is identical everywhere; the substrate underneath is not. Everything above is cloud-agnostic — these are the rows that differ.
How to read this table. A cell marked ✓ is established by Genesis documentation or measured on a real cluster, and the linked page carries the detail. A cell marked ◦ names the standard mechanism for that platform, which Genesis does not document and you must confirm against your cloud’s own documentation and with your platform team. Do not carry a ◦ cell into a design review as a Genesis requirement.
Whatever the cloud, the VolumeSnapshot API is the portable option where your cluster has a CSI driver and a snapshot controller installed: one procedure everywhere, at the cost of a cluster prerequisite. The cloud-native command always works.
Two facts hold on every cloud, and neither is in the snapshot commands: run the checkpoint hook before the snapshot, and back the master key up separately — it is not on the volume. Use the per-cloud pages for the snapshot mechanics, and the restore drill for the part none of them covers: proving the backup actually restores.

Related: Installing Genesis on GKE · Genesis on AWS · Genesis on Azure · Environment strategy and governance