Private deployment + hosted-runtime repo for the Unity AI assistant platform (formerly Unity). This repo owns the hosted communication stack, the assistant desktop/VM infrastructure, the full local self-host stack, the client/assistant deployment overlay, and the CI/CD that produces production images.
This README is the central source of truth for the fully-deployed system — every "moving part" (GCP projects, GKE, Cloud Run, VM pool, tunnel/SFTP, Pub/Sub, DNS, secrets, CI/CD) and the current state of the Unity → Unity rename. If you are hunting an environment variable, a GCP resource name, a secret, a Cloud Build trigger, or "why is X still called
unity", it should be answered here. Deep-dive operational guides are linked inline; this document is the index and the authority over them.
- The repo set
- Unity → Unity rename: read this first
- GCP project map
- Live resource inventory (canonical names)
- Hosted runtime architecture
- Assistant desktops: VM pool, tunnel/SFTP, archives, TLS
- Secrets management
- CI/CD: Cloud Build, GitHub Actions, branches
- Known rename loose ends & gotchas
- The deployment overlay package
- Local development & self-host
- Troubleshooting
- Deep-dive guides
| Repo | Visibility | Role | Branches |
|---|---|---|---|
unifyai/unity |
public | The assistant runtime ("the brain"). Runs as the GKE container and as the desktop runtime. | main (prod), staging |
unifyai/unity-deploy |
private | This repo. Hosted comms app + adapters, assistant VM/tunnel infra, self-host stack, client deployment overlay, prod image CI/CD. | main, staging, stability |
unifyai/orchestra |
private/hosted | Backend API + Postgres (users, assistants, projects, contexts, logs, billing). | main, staging |
unifyai/console |
private/hosted | Next.js web UI / observability dashboard. | main, staging |
unifyai/unify |
public | Python SDK wrapping Orchestra's REST API. | main, staging |
unifyai/unillm |
public | LLM abstraction layer with caching. | main, staging |
unifyai/magnitude |
private | Computer-use/browser automation dependency. Consumed at branch main. |
main |
Open-source unity runs against hosted Orchestra (ORCHESTRA_URL, default https://api.unify.ai/v0) with no Console. The full "all-repo local" stack lives in selfhost/ here.
Branch model (all repos): default main = production, staging = development/testing. Promotion is staging → main (never feature → main directly). A sync-staging.yml workflow fast-forwards staging after main moves. The big exception today: see loose ends for fixes stranded on staging.
The platform was renamed Unity → Unity on 2026-06-18 (one Rename … to Unity commit per repo, all merged to both main and staging). The coordinator persona was separately renamed Unity → Twin (Orchestra 8e62c4af) / Marty (Console db7253df).
The rename was executed as an additive alias strategy, not an in-place rename:
- Code/identifiers were mechanically renamed
unity*→unity*across all six repos. - New
unity-*GCP/K8s resources were created alongside the existingunity-*ones (buckets, Artifact Registry repos, service accounts, K8s aliases, some Pub/Sub, some pool resources). - GitHub repos were renamed via API (
unity→unity,unity-deploy→unity-deploy). - Several classes of resource were deliberately or accidentally left as
unity-*and the code still points at them.
This is why things broke silently for days: the code says unity-* but the live resource is still unity-* (or vice-versa, or the unity-* copy was created empty and never cut over). A symbol mismatch produces a 404 / 401 / empty-config at runtime, not a build error.
- GCP project IDs are immutable.
gcp-project-vmsandgcp-project-runtime(display name still "Unity LiveKit") will keep theunityname forever unless we migrate to brand-new projects. Code intentionally keepsvm_project_id = "gcp-project-vms". - Service-account emails embed the (immutable) project id, so
service-account@example.iam.gserviceaccount.comstays. - GKE cluster names can't be renamed (recreate-only); the cluster is
unity. Code is decoupled viaUNIFY_GKE_CLUSTER_NAME(defaultunity) / Cloud Build_CLUSTER: 'unity'.
| Layer | State |
|---|---|
| Code/identifiers (all 6 repos) | ✅ renamed, on main + staging |
| GKE workloads (deployments, cronjobs) | ✅ unity-* |
| Cloud Run comms/adapters | ✅ unity-comms-app, unity-adapters (+ -staging) |
| Ubuntu desktop pool (images, VMs, IPs, DNS) | ✅ migrated to unity-pool-ubuntu-* |
| Per-assistant archive bucket | ✅ unity-assistant-archives (migrated 2026-06-24) |
| GKE cluster name | ❌ still unity (immutable-ish) |
| Tunnel servers + IPs | ❌ unity-tunnel-server(-staging), unity-tunnel-server-ip* |
| Tunnel config bucket | ❌ unity-tunnel-config(-staging) (no unity equivalent) |
| Windows desktop pool (images, VMs, IPs, DNS) | ❌ entirely unity-pool-windows-* |
| Pub/Sub fabric | unity-* (1,334 topics / 5,198 subs vs 252 / 1,021 unity) |
| Data buckets (recordings, logs, artifacts) | unity-* created empty; code partly still reads unity-* |
GitHub org secrets UNITY_ADAPTERS_URL / UNITY_COMMS_URL |
UNIFY_* added alongside; both pairs live, both visibility=private (see §7.2) |
Secrets UNITY_COORDINATOR_*, UNITY_{LIVEKIT,OPENAI,…} |
UNITY_COORDINATOR_* added; comms/LLM still unity) |
| GCP project IDs / SA emails / cluster name | ❌ immutable — unity forever |
The full prioritized list of mismatches that can still bite is in §9 Known rename loose ends.
| Commit | Repo | Subject |
|---|---|---|
224cf2ae |
orchestra | Rename Unity platform references to Unity. |
ed4af7c |
unify | Rename Unity references to Unity. |
ca68806 |
unillm | Rename Unity references to Unity. |
be68c00c2 |
unity | Rename Unity runtime to Unity. (whole unity/ pkg → unity/, Dockerfiles, k8s, CI) |
6bf17b3e |
unity-deploy | Rename hosted runtime references to Unity. |
8037a422 |
unity-deploy | Rename deploy repo to Unity deploy. |
34daa854 |
console | Rename Unity references to Unity. |
8b5097a9 |
unity-deploy | Fix: point tunnel control plane at existing unity-tunnel-config bucket — STAGING ONLY |
244ad103 |
unity-deploy | fix(infra): restore SFTP tunnel band reachability — STAGING ONLY |
Four projects, split to isolate workloads and GCE API rate limits.
| Project ID | Display | Role | Region(s) |
|---|---|---|---|
gcp-project-runtime |
Unity LiveKit | Main hosted runtime: GKE cluster unity, Cloud Run (comms/adapters), Pub/Sub fleet, most buckets, tunnel servers, Artifact Registry |
us-central1 |
gcp-project-vms |
Unity Assistant VMs | Assistant desktop VM pool: pool images/families, pool VMs, static IPs, per-assistant archives | us-central1 (-a, -f) |
gcp-project-saas |
SaaS | Orchestra + Console + landing page (Cloud Run) and Cloud SQL (Postgres) | us-central1 |
gcp-project-dns |
DNS-Server | Public Cloud DNS zone unifyai → unify.ai (incl. vm.unify.ai, tunnel.unify.ai) |
global |
Why VMs are a separate project: GKE Autopilot Node Auto-Provisioning consumes the per-project compute.instances.insert rate limit. Co-locating assistant-VM creation with a GKE NAP burst historically exhausted the limit and 500'd the hiring flow. Separating the pool into gcp-project-vms gives each its own budget.
Almost everything here is regional or zonal, and several gcloud surfaces default to the wrong location and return stale or empty results without erroring. Mis-reading that output ("the build never ran", "that service doesn't exist", "wrong project") is the single most common recurring mistake. Always pass the location flag from the table below.
The #1 trap — Cloud Build is regional and the global default lies. Every trigger and build for both gcp-project-runtime and gcp-project-saas lives in us-central1. With no --region, gcloud builds … queries global, where:
gcp-project-runtime→ empty / months-stale (you'll see only old builds and wrongly conclude the push didn't trigger anything).gcp-project-saas→ shows onlylanding-page, silently hiding everyorchestraandconsolebuild.
✅ Always: gcloud builds list --project=<P> --region=us-central1 … — and the same --region=us-central1 on gcloud builds describe, gcloud builds log, gcloud builds triggers list, and gcloud builds triggers run.
| Resource | gcloud surface | Required location flag |
|---|---|---|
| Cloud Build — all triggers + builds, both projects | gcloud builds … |
--region=us-central1 (never global) |
GKE cluster unity (gcp-project-runtime) |
gcloud container clusters … |
--region=us-central1 |
Cloud Run comms/adapters/link-tracker (gcp-project-runtime) |
gcloud run … |
--region=us-central1 |
Cloud Run orchestra(+-staging), landing-page(+-staging), saas-web-app (Console prod) (gcp-project-saas) |
gcloud run … |
--region=us-central1 |
Cloud Run saas-web-app-redesign-staging (Console staging) (gcp-project-saas) |
gcloud run … |
--region=us-central1 |
Cloud SQL prod-ssd-usc1 / staging-ssd-usc1 (gcp-project-saas) |
gcloud sql … / proxy |
us-central1 |
Compute tunnel VMs (gcp-project-runtime) |
gcloud compute … |
zone us-central1-a |
Pool VMs (gcp-project-vms) |
gcloud compute … |
zones us-central1-f (Ubuntu) / -a |
Cloud DNS zone unifyai (gcp-project-dns) |
gcloud dns … |
global (no flag) |
| Secret Manager (all projects) | gcloud secrets … |
global — --project only, no region |
The org GitHub variable GCP_LOCATION=us-central1 is the saas Cloud Run default. As of the July 2026 consolidation the entire estate — runtime, saas Cloud Run, Cloud SQL, Cloud Build — is in us-central1; only Secret Manager and Cloud DNS remain global. When a resource "doesn't exist" or a build "is missing", suspect a wrong/global location before suspecting a wrong project.
Authoritative as of 2026-06-24. ⚠️ marks a unity/unity mismatch that the code/runtime depends on.
GKE cluster unity (regional us-central1, Autopilot). Namespaces: production, staging (+ cert-manager, external-secrets, custom-metrics).
Production deployments (image):
assistant-session-controller,assistant-session-pool-controller→unity-comms-app-repo/assistant-session-controller:<sha>unity-ingest-worker,unity-parse-worker→unity/unity:latestjob-watcher→unity/job-watcher:<sha>grafana(+ cloud-sql-proxy)
CronJobs (prod + staging): unity-failed-pod-gc (/10), unity-pipeline-dlq-reconciler (/10), unity-pipeline-stale-reconciler (*/15), unity-workers-weekly-rollout (Sun 03:00). Assistant Job pods are spawned on demand (label app=unity), not a Deployment.
Cloud Run (us-central1): unity-comms-app(+-staging), unity-adapters(+-staging), link-tracker, smartlead-reply(+-sandbox). Prod tag 6376458 = unity-deploy main; staging tag c402d42.
Compute: unity-tunnel-server, unity-tunnel-server-staging
Static IPs: unity-gke-egress-ip = 203.0.113.12 (Cloud NAT egress; firewall source for SFTP), unity-tunnel-server-ip = 203.0.113.13 (= tunnel.unify.ai), unity-tunnel-server-ip-staging = 136.112.29.250
Firewall: allow-7000 (tcp:7000, tag unity-tunnel-server — rathole control), allow-tunnel-sftp (tcp:61000-61999 from 203.0.113.12/32, tag allow-tunnel — SFTP band), gke-unity-8c75014d-* (cluster rules).
GCS buckets: unity-tunnel-config(+-staging) unity-youtube-extraction {unity,unity}-call-recordings, {unity,unity}-image-hash, {unity,unity}-pipeline-artifacts(-staging), {unity,unity}-pod-logs
Artifact Registry: unity, unity-comms-app-repo, unity-adapters-repo, link-tracker-repo + legacy unity, unity-adapters-repo, unity-comms-app-repo, unity-livekit-app-repo.
Service accounts: comm-sa@ (all assistant pods + cluster services run as this), unity-pipeline-worker@ + legacy unity-pipeline-worker@, link-tracker-sa@, external-secrets-reader@. comm-sa IAM is hand-managed; required roles include roles/monitoring.metricWriter, roles/logging.logWriter, roles/pubsub.editor, and roles/compute.admin + roles/secretmanager.admin in gcp-project-vms, plus roles/storage.objectAdmin on gs://bucket (cross-project; unhire archive delete).
comm-sa Workspace domain-wide delegation (Google Admin → Security → API controls → Domain-wide delegation; client ID 115203909972265828958): authorize exactly these OAuth scopes (comma-separated when editing the client):
https://www.googleapis.com/auth/admin.directory.userhttps://www.googleapis.com/auth/gmail.sendhttps://www.googleapis.com/auth/gmail.readonlyhttps://www.googleapis.com/auth/gmail.modifyhttps://www.googleapis.com/auth/gmail.settings.basic— Gmail send-as display names for platform twin mailboxes (deploy/scripts/set_workspace_twin_gmail_identities.py)
After adding a scope, wait a few minutes for propagation, then run python3 deploy/scripts/set_workspace_twin_gmail_identities.py to sync T-W1N names on twin@ / staging-twin@ / local-twin@.
Pub/Sub: infra topics unity-ingest, unity-parse, unity-dead-letter (+-staging); per-assistant unity-<id>[-staging] (new) and unity-<id>[-staging] (legacy) unity-*.
Secret Manager (names): UNIFY_ADAPTERS_URL{,_PREVIEW,_STAGING}, UNIFY_COMMS_URL{,_PREVIEW,_STAGING} UNITY_ADAPTERS_URL_{PRODUCTION,STAGING}, plus provider/integration secrets (ANTHROPIC_API_KEY, LIVEKIT_*, TWILIO_*, ORCHESTRA_ADMIN_KEY, UNIFY_KEY, VM_WILDCARD_FULLCHAIN/PRIVKEY, gcp-sa-key, github-pat, DEVBOT_GITHUB_TOKEN gcp-project-saas, see §7.5, …). Cluster runtime secret = unity-secrets (see §7).
- Pool VMs: Ubuntu =
unity-pool-ubuntu-<N>[-staging]✅ (us-central1-f / -a); Windows =unity-pool-windows-<N>[-staging]⚠️ . - Images/families:
unity-pool-ubuntu-vm,unity-pool-windows-vm(created 2026-06-24) ✅; legacyunity-pool-ubuntu-vm,unity-pool-windows-vm,unity-ubuntu-vm,unity-windows-vm(rollback source). Code:communication/infra/vm_config.py→POOL_UBUNTU_VM_IMAGE_FAMILY = "unity-pool-ubuntu-vm",*_IMAGE_PROJECT = "gcp-project-vms". - Static IPs:
unity-pool-ubuntu-ip-<N>[-staging]✅; all Windows + preview/staging Ubuntu IPs stillunity-pool-*-ip-*⚠️ . - Buckets:
unity-assistant-archives✅ (per-assistant{id}.tar.gzLocal workspace +{id}-desktop-profile.tar.gzbrowser/GUI session state) + legacy rollback bucket. Code:POOL_ASSISTANT_ARCHIVE_BUCKET = "unity-assistant-archives". - SA:
pool-vm-sa@gcp-project-vms(objectAdmin — pool VMs write/read archives on release/assign) andcomm-sa@gcp-project-runtime(objectAdmin — Cloud Run comms app deletes archives on unhire viaDELETE /infra/vm/pool/archive/{id}; required for both staging and prod). Pool/desktop OS user isunityuser(HOME/Unity)⚠️ . - Secrets:
VM_WILDCARD_FULLCHAIN,VM_WILDCARD_PRIVKEY,DEVBOT_GITHUB_TOKEN.
- Cloud SQL (Postgres, us-central1):
prod-ssd-usc1(203.0.113.14),staging-ssd-usc1(203.0.113.15). The oldeurope-west3prod-ssd/staging-ssdare deleted; final SQL dumps live ings://bucket/andgs://bucket/(decommission-final-*.sql.gz), plus a Cloud SQL final backup ofprod-ssd. - Cloud Run (us-central1):
orchestra(+-staging),saas-web-app(Console prod),saas-web-app-redesign-staging(Console staging),landing-page(+-staging). - Secrets delta:
UNITY_COORDINATOR_*andUNITY_COORDINATOR_*both exist (DISCORD_ID/TOKEN, EMAIL_ADDRESS, PHONE_UK/US, WHATSAPP_NUMBER × PRODUCTION/STAGING)⚠️ ;UNITY_{LIVEKIT,OPENAI,DEEPGRAM,CARTESIA}_*andUNITY_{ADAPTERS,COMMS}_URL*still unity-only⚠️ .
- Zone
unifyai→unify.ai.(93 records).api/console/unify.ai→203.0.113.16(saaspublic-lb-ip).tunnel.unify.ai+*.tunnel.unify.ai→203.0.113.13;staging.tunnel…→136.112.29.250. *.vm.unify.ai: Ubuntu prod hostsunity-pool-ubuntu-N.vm.unify.ai✅; Windows pool + most staging/preview hosts stillunity-*.vm.unify.ai⚠️ . Liveview URLs:https://unity-pool-<...>.vm.unify.ai/desktop/custom.html.
The hosted system spans three code areas: Orchestra (API + DB), unity-deploy (this repo: comms app, adapters, infra controllers), and unity (the per-job container on GKE).
| Service | Purpose |
|---|---|
| Twilio | Phone calls, SMS, WhatsApp |
| Gmail API / Microsoft Graph | Email (Gmail / Outlook) + Teams |
| LiveKit | Real-time audio/video (Unify Meet) |
| Google Cloud Pub/Sub | Message routing adapters ↔ containers |
| GKE (Autopilot) | Container orchestration for Unity jobs |
- Adapters (
adapters/, Cloud Rununity-adapters): unauthenticated webhook handlers for inbound Twilio/Gmail/Microsoft, plus/scheduled/*cron endpoints and/assistant/wakeup. - Comms app (
communication/, Cloud Rununity-comms-app): admin-key-protected JSON API for outbound phone/SMS/email and the infra control plane (/infra/pubsub/topic,/infra/gke/job,/infra/tunnel/register, VM/session management). The same image also runs the GKEassistant-session-controller/assistant-session-pool-controller. - Unity container (GKE job, image
unity/unity): the assistant runtime.CommsManagersubscribes to Pub/Sub;ConversationManagerorchestrates;EventBrokeris the in-memory bus. Pods write fleet audit rows (AssistantJobs/startup_events, includingliveview_url) via ownership-scoped/infra/assistant-jobs/*using their ownUNIFY_KEY— never a shared user key orORCHESTRA_ADMIN_KEYon the pod.
AssistantJobs is an Orchestra system project (Project.is_system, no user_id). Writes and Console hosted liveview reads use ORCHESTRA_ADMIN_KEY as the __system__ principal on the data plane. Live job control remains K8s labels / AssistantSession / /infra/*.
Never back fleet auth with a Workspace/Console User row or user API key (shared@…, personal keys, etc.). Account-purge scripts and staging cleanups must not invent “service users” in the user table for infra — if a key lives on a User, deleting that user CASCADE-breaks the fleet.
- Per-assistant topic
unity-{assistant_id}[-staging](+-sub). Startup topicunity-startup[-staging]engages idle containers. - Message shape:
{"thread": "<type>", "event": {…}}with threadsstartup,msg,email,call,unify_message,unify_meet,unify_message_outbound,assistant_update. - Idle → Live: idle containers (
agent_id is None) subscribe to the startup topic and self-ping every 30s. On inbound, the adapter checksAssistantJobs, publishes astartupmessage (if not live) + the inbound to the assistant topic. The container that wins the startup subscribes to its assistant topic and marks itself live. Inactivity timeout = 7 min; the job is retained (not deleted) for logs.
Target idle count: max(UNIFY_MIN_IDLE_JOBS, live_count // UNIFY_IDLE_JOB_DEMAND_FACTOR) (defaults 3 and 5). Three mechanisms share it:
- Reactive fill — on every inbound that consumes an idle job (
/scheduled/jobs/create). - Deploy refresh — every Cloud Build (
?refresh=true) rotates the pool to the new image. - Hourly cron — self-heal + rotation; cleanup 10 min later trims to target.
A single-replica kopf operator (base/scripts/job-watcher/, image unity/job-watcher) watches pods app=unity. On Succeeded/Failed it sets running=False in AssistantJobs and releases the assigned pool VM — externally, so cleanup happens even if the container crashes (OOM/node failure). The in-container mark_job_done() (graceful exit) and the adapters' expire_all_stale_jobs() (periodic sweep) call the same idempotent operations.
For the per-pod 10Gi ephemeral-storage Autopilot cap and the /tmp emptyDir strategy (HuggingFace/Docling caches, HF_HOME, XDG_CACHE_HOME), see deploy/guides/GKE_EPHEMERAL_STORAGE.md.
Each live assistant can get a dedicated desktop VM (Ubuntu or Windows) from a warm pool in gcp-project-vms. This is the subsystem most damaged by the rename — it caused the 2026-06-24 production outage.
- Controllers (
assistant-session-controller) provision pool VMs from image familyunity-pool-ubuntu-vm/unity-pool-windows-vmin projectgcp-project-vms, attachingunity-pool-ubuntu-ip-<N>and creatingunity-pool-ubuntu-<N>.vm.unify.aiDNS. - Images are built by
scripts/vm-build/build-ubuntu.sh/build-windows.sh(+ packer undercommunication/infra/scripts/*-vm-custom-image/), which publish to theunity-pool-*families. If the family doesn't exist in GCP, every provision 404s (the outage — see §9).
- The runtime syncs the assistant's workspace (
~/Unity/Local, Attachments/Outputs/functions) to the desktop VM over rclone SFTP (unity/file_manager/sync/, userunityuser, port 2222). - For users tunnelling a local machine, the tunnel control plane (
/infra/tunnel/registerin the comms app) manages a rathole relay onunity-tunnel-server, storing state ings://bucket. SFTP uses the raw-TCP band61000-61999(firewallallow-tunnel-sftpfromunity-gke-egress-ip). Tunnel constants live incommon/settings.py(tunnel_vm_name,tunnel_gcs_bucket) andcommunication/infra/tunnel_config.py.
Two GCS blobs per assistant (bucket POOL_ASSISTANT_ARCHIVE_BUCKET in
vm_config.py, passed to each VM as the archive-bucket metadata key):
| Blob | Contents | Restored when |
|---|---|---|
{assistant_id}.tar.gz |
/Unity/Local (workspace files synced with the Unify pod via rclone) |
Next assign if the PD is empty |
{assistant_id}-desktop-profile.tar.gz |
Browser + agent GUI session state in home: Magnitude browser_states, selective Chromium/Chrome profile files, xfce4 prefs — not under Local/, so not rcloned into the pod |
Next assign whenever the blob exists |
On release the watcher archives Local then the desktop-profile, unmounts the PD, and scrubs home profile paths off the shared pool VM. On permanent unhire both blobs are deleted via DELETE /infra/vm/pool/archive/{assistant_id}. Disk GC treats {id}.tar.gz as the durable-workspace signal; a missing profile only means a cold browser login. If the bucket doesn't exist, files don't persist between sessions (a rename gap fixed 2026-06-24).
Exception — a release that interrupted a bootstrap uploads nothing. The guest marks /var/lib/unity-pool-watcher/restore-in-flight while it unpacks these two blobs; a release landing inside that window would otherwise tar up a half-extracted copy of the archives and overwrite them with it. The existing blobs are already a superset of what such an assignment produced, so they are left alone.
Used by unity-pool-watcher.sh / .ps1.
The watcher long-polls instance metadata: unify-key going non-empty means assign, going empty (with a new binding-id:release-generation token) means release.
Assign runs as a background job in its own process group; release runs inline and preempts it. A cold pool VM — every VM, whenever the pool holds no warm one for that type — bootstraps for tens of minutes: two repository clones, three dependency installs, a browser download. When that ran inline it also blocked the metadata poll, so a release was not read until the bootstrap returned; a staging release on 2026-08-15 was requested at 20:26:34 and only acknowledged at 21:58:33, failing two Integration smoke gate runs. Release now cancels the in-flight bootstrap with a process-group SIGTERM (SIGKILL after JOB_TERM_GRACE_SECONDS), which is what reaches the git/npm/gsutil children, then waits for the group to empty before touching the filesystem.
Two consequences worth knowing:
release-completeis no longer behind a code refresh.do_updateused to run at the end ofdo_release, in front of the callback the pool blocks on. It now runs afterwards as a cancellable idle warm-up job, superseded by the next assign.- An interrupted
do_updatere-runs. The agent-service commit hash is recorded only afternpm installfinishes, so a cancelled update cannot be mistaken for current and left with half anode_modules.
The Windows guest (unity-pool-watcher.ps1) still runs both phases inline and carries the same queueing defect; it ships from a separate image pipeline and PowerShell has no equivalent one-liner for killing a process tree.
Every VM runs Caddy terminating TLS for /api/* (agent-service) and /desktop/* (noVNC). A single *.vm.unify.ai wildcard cert (VM_WILDCARD_FULLCHAIN / VM_WILDCARD_PRIVKEY in gcp-project-vms Secret Manager) is pushed to each VM via instance metadata (avoids Let's Encrypt's 50 certs/domain/week limit). Renewed monthly by the cert-renewal Cloud Scheduler job → POST /scheduled/cert-renewal (DNS-01 in the unifyai zone).
There are three independent secret planes — rotating one does not rotate the others.
Source of truth for runtime API keys is GCP Secret Manager in gcp-project-runtime. The External Secrets Operator (namespace external-secrets) polls SM latest into the unity-secrets K8s Secret per namespace (refreshInterval: 1h) via ClusterSecretStore unity-gcp-secret-manager. Unity Job pods read keys with secretKeyRef.
Rotate by adding a new SM version, then force sync + restart pods:
kubectl annotate externalsecret unity-secrets -n staging force-sync=$(date +%s) --overwrite
# then trigger a pool refresh so new pods pick it upBootstrap, break-glass (setup_k8s_config.py), and ESO details: deploy/guides/UNIFY_CLUSTER_SECRETS.md. Never kubectl apply hand-built secret YAML; never commit key material.
No org secret is inherited by every repo. Since the SOC 2 org-secret tightening, not one of them carries visibility all — the provider keys that used to be org-wide (ANTHROPIC_API_KEY, OPENAI_API_KEY, GCP_SERVICE_ACCOUNT_JSON, ORCHESTRA_ADMIN_KEY, UNIFY_KEY, CLONE_TOKEN) no longer exist at org level at all. They moved to repo and environment scope. Resolve a missing secret against the tables below, not against a mental model of inheritance.
Org secrets — the complete list (verified 2026-08-16):
| Secret | Visibility | Who can actually read it |
|---|---|---|
CI_CLONE_APP_PRIVATE_KEY |
selected |
unify, unify-deploy, unillm, unisdk, console, brain, landing-page |
CI_DISPATCH_APP_PRIVATE_KEY |
selected |
unify only |
TOGETHER_API_KEY |
private |
private + internal repos only |
UNIFY_ADAPTERS_URL, UNIFY_COMMS_URL |
private |
private + internal repos only |
UNITY_ADAPTERS_URL, UNITY_COMMS_URL |
private |
private + internal repos only — legacy names, still live |
Both the UNIFY_* and UNITY_* URL pairs now exist, so the old "workflows read one name, only the other exists" mismatch is gone. The legacy UNITY_* pair is still populated and still read by some workflows; treat it as live, not as a leftover to delete.
private visibility trap — this is the one that burns debugging time. GitHub's private visibility means private and internal repositories. It excludes public ones, and three first-party repos are public:
| Repo | Visibility | Inherits org private secrets? |
|---|---|---|
unify, unillm, unisdk |
public | ❌ no — only the selected grants above reach them |
unify-deploy |
internal | ✅ yes |
orchestra, console, brain, landing-page |
private | ✅ yes |
So a workflow in unify, unillm, or unisdk referencing secrets.TOGETHER_API_KEY or secrets.UNIFY_COMMS_URL resolves to the empty string unless that repo holds its own copy. Nothing errors — the step runs with a blank value. unify and unillm each keep a repo-level TOGETHER_API_KEY for exactly this reason; the org copy is unreachable from them.
This is observed, not inferred (verified 2026-08-17). The runner's own env: group dump in unify Tests run 32024407234 (job 95370713080) prints UNIFY_COMMS_URL: blank while every other secret on the same job — OPENAI_API_KEY, OPENROUTER_API_KEY, TOGETHER_API_KEY, ANTHROPIC_API_KEY, TAVILY_API_KEY, UNIFY_KEY — renders ***. Masking is the discriminator: GitHub replaces a non-empty secret with ***, so a blank value is a genuinely empty one. TOGETHER_API_KEY renders *** there because of unify's repo-level copy, not because the org secret reached it — it is not a counterexample.
The authoritative check is the "org secrets shared with this repo" endpoint, which resolves all three layers for you:
gh api repos/unifyai/<repo>/actions/organization-secrets --paginate --jq '.secrets[].name'Across the repo set it splits exactly on visibility, with no exceptions:
| Repo | Visibility | Org secrets actually reachable |
|---|---|---|
unify |
public | CI_CLONE_APP_PRIVATE_KEY, CI_DISPATCH_APP_PRIVATE_KEY |
unillm, unisdk |
public | CI_CLONE_APP_PRIVATE_KEY |
unify-deploy |
internal | the above + all five private secrets |
orchestra, console |
private | all five private secrets (+ CI_CLONE_* where granted) |
The three public repos reach only their selected grants. None of the five private org secrets appears for any of them.
unify's workflows no longer reference secrets.UNIFY_COMMS_URL at all — the four dead references were deleted once this was confirmed, and re-adding one is the wrong fix (§9 row 4). The run cited above predates that removal; it remains the evidence for how a private org secret resolves in a public repo.
Where the keys actually live now (repo-level R, environment-scoped [env]):
| Repo | Secrets |
|---|---|
unify |
R: ANTHROPIC_CI_API_KEY, OPENAI_CI_API_KEY, OPENROUTER_CI_API_KEY, GCP_SERVICE_ACCOUNT_JSON, GCP_PROJECT_ID, TAVILY_API_KEY, TOGETHER_API_KEY, UNIFY_KEY · [unity-testing]: ANTHROPIC_API_KEY, ANTHROPIC_CI_API_KEY, DEEPSEEK_CI_API_KEY, OPENAI_API_KEY, OPENAI_CI_API_KEY, GCP_SERVICE_ACCOUNT_JSON, UNIFY_KEY · [unity-llm-cache-refresh]: ANTHROPIC_CI_API_KEY, OPENAI_CI_API_KEY · [droid-usage-audit]: OPENAI_ADMIN_KEY |
unillm |
R: ANTHROPIC_API_KEY, OPENROUTER_API_KEY, TOGETHER_API_KEY · [unify-testing]: GCP_SERVICE_ACCOUNT_JSON, UNIFY_KEY · [unillm-llm-cache-refresh]: ANTHROPIC_API_KEY, OPENROUTER_API_KEY, UNIFY_KEY |
unisdk |
[unify-testing]: ANTHROPIC_API_KEY, OPENAI_API_KEY, GCP_SERVICE_ACCOUNT_JSON, UNIFY_KEY — no repo-level secrets at all |
unify-deploy |
R: ORCHESTRA_ADMIN_KEY, UNIFY_KEY, TOGETHER_API_KEY, BRANDING_DEPLOY_KEY, ISO_ANIMATION_DEPLOY_KEY · [droid-usage-audit]: OPENAI_ADMIN_KEY |
orchestra |
R: GCP_SERVICE_ACCOUNT_JSON, ORCHESTRA_ADMIN_KEY, OPENROUTER_API_KEY_CI, ORCHESTRA_OPENAI_API_KEY, ORCHESTRA_TOGETHER_AI_API_KEY, TOGETHER_API_KEY |
console |
R: DEVBOT_GITHUB_TOKEN, ORCHESTRA_PAT, ELEVENLABS_API_KEY, CODESANDBOX_TEMPLATE_ID |
Environment-scoped secrets need the job to declare that environment. A job without environment: unity-testing cannot see unity-testing's secrets, even in the owning repo. In unify, flow-smoke.yml, flow-smoke-release-gate.yml and tests.yml declare unity-testing; llm-cache-refresh.yml declares unity-llm-cache-refresh. unify-deploy declares no environment in any workflow, so its gates read repo-level and org secrets only.
Org variables are the exception — they are inherited everywhere (all visibility=all, so public repos get them too):
| Variable | Value |
|---|---|
GCP_PROJECT_ID |
gcp-project-saas |
GCP_LOCATION |
us-central1 |
GCP_BUCKET_ASSISTANT_IMAGES |
hired_assistants_images |
GCP_BUCKET_LOGS |
log-images-bucket |
GCP_BUCKET_RECORDINGS |
assistant-call-recordings |
GCP_CREDENTIALS_FILENAME |
gcp-service-account.json |
Note unify holds GCP_PROJECT_ID as a repo secret as well as inheriting the org variable — secrets.GCP_PROJECT_ID and vars.GCP_PROJECT_ID are different lookups, and a workflow reading the wrong namespace gets an empty string rather than an error.
Debugging a gate that behaves as if a secret is missing — re-derive the state rather than trusting this table, and remember an empty value never raises:
gh api orgs/unifyai/actions/secrets --jq '.secrets[] | "\(.name)\t\(.visibility)"'
gh api orgs/unifyai/actions/variables --jq '.variables[] | "\(.name)\t\(.visibility)\t\(.value)"'
gh api orgs/unifyai/actions/secrets/<NAME>/repositories --jq '[.repositories[].name]|join(", ")' # visibility=selected only
gh api repos/unifyai/<repo>/actions/secrets --jq '.secrets[].name'
gh api repos/unifyai/<repo>/environments/<env>/secrets --jq '.secrets[].name'
# Start here: which ORG secrets this repo can actually read, visibility already applied.
gh api repos/unifyai/<repo>/actions/organization-secrets --paginate --jq '.secrets[].name'A secret must be absent from all three layers to be genuinely unreachable — a repo- or environment-level copy silently shadows the org one, which is why TOGETHER_API_KEY works in unify and UNIFY_COMMS_URL does not. Confirm against a run rather than a table where you can: the env: group at the top of any job log renders reachable secrets as *** and unreachable ones as blank.
Per-repo CI wiring is described in §8.
VM_WILDCARD_FULLCHAIN / VM_WILDCARD_PRIVKEY in gcp-project-vms Secret Manager (see §6).
OpenRouter is the only route to OpenAI-family models (see AGENTS.md) and the largest single spend surface in the estate. These keys are not a fourth secret plane — they are credentials distributed across §7.1 and §7.2 — but they rotate on their own cadence and are listed here because nothing else maps key → secret slot → consumer.
The production OpenRouter account is shared@unify.ai ("Shared Account"), not dan@unify.ai (a separate, empty personal account — the two live in different Chrome profiles). All mint / rotate / cap / rename operations run programmatically against https://openrouter.ai/api/v1/keys with the management key in OPENROUTER_MANAGEMENT_API_KEY (gcp-project-runtime); no browser needed. PATCH /api/v1/keys/{hash} is a true partial update — omitted fields keep their values.
| Key name | Hash | Cap | Held in | Consumer |
|---|---|---|---|---|
unify-prod-gateway-2026-08-11 |
e09094a1 |
$10,000/mo | OPENROUTER_API_KEY (gcp-project-runtime) + ORCHESTRA_OPENROUTER_API_KEY (saas) |
Production — assistant pods + Orchestra |
unify-staging-runtime |
ea72ed20 |
$4,000/mo | OPENROUTER_API_KEY_STAGING (gcp-project-runtime) |
Staging assistant pods |
unify-staging-orchestra |
fb0ea371 |
$4,000/mo | ORCHESTRA_OPENROUTER_API_KEY_STAGING (saas) |
Staging Orchestra + trigger worker |
unify-ci-orchestra-repo |
4f5e1fc5 |
$500/mo | GH secret OPENROUTER_API_KEY_CI |
unifyai/orchestra CI |
unify-ci-unify-repo |
39e621a9 |
$1,000/mo | GH secret OPENROUTER_CI_API_KEY |
unifyai/unify CI |
unify-ci-unillm-repo |
b2d9f264 |
$500/mo | GH secret OPENROUTER_API_KEY |
unifyai/unillm CI |
unify-canary-DO-NOT-DEPLOY |
1ee9a059 |
$100/mo | OPENROUTER_CANARY_KEY (gcp-project-runtime) |
Nothing — tripwire. See below |
unify-production-RETIRED-2026-08-11 |
cfcba7cb |
disabled | — | Was production 2026-08-10 → 08-11 |
Default |
d6370f6a |
disabled | — | Compromised; disabled 2026-08-10 at $32,381.27 |
CI secret names are deliberately inconsistent across repos (OPENROUTER_API_KEY_CI / OPENROUTER_CI_API_KEY / OPENROUTER_API_KEY) — that is the live state, not a typo. Match by repo, not by name.
The canary is a detection control, not a spare. unify-canary-DO-NOT-DEPLOY exists only as a Secret Manager entry and is deployed to no pod or service. If it ever shows usage, someone is reading Secret Manager directly; if it stays at $0 while deployed keys burn, the exposure is pod-environment access. Never deploy it.
To identify which key a secret currently holds (the fastest way to answer "what is production actually using?"), hash the secret and match it against the hash field from GET /api/v1/keys:
gcloud secrets versions access latest --secret=OPENROUTER_API_KEY --project=gcp-project-runtime | tr -d '\n' | shasum -a 256Every key this estate deploys uses limit_reset: monthly, so a cap can throttle spend but can never cause a permanent silent outage. Keep it that way when minting: a one-time ceiling on a production key is an outage with a countdown on it.
MCP: OpenRouter MCP: Claude Code (openrouter)(e104bdd3, $5 cap, $0 used) was auto-minted by an MCP integration on 2026-08-10 and is unattributed. Harmless, but it belongs to someone — attribute it or delete it.- Per-key caps across the eight active keys sum to $20,105/month of theoretical headroom. There is no account-level cap, so that sum is the real ceiling — and the retired
cfcba7cbstill carries a $40,000 cap that would count toward it if anyone re-enabled the key.
unifyai has two machine accounts with deliberately different jobs (see AGENTS.md): approver-bot approves PRs and holds no stored credential in this estate — it is a gh CLI login only — while ci-bot does CI automation and owns the one credential below. It is a classic PAT, repo scope, no expiry, stored under the same name in three projects, one of which is dead.
| Secret slot | Project | State | Consumer |
|---|---|---|---|
DEVBOT_GITHUB_TOKEN |
gcp-project-saas |
✅ live — authoritative | Console Cloud Build (unify-console, unify-console-redesign, us-central1) mounts versions/latest; Console runtime reads it in /api/assistant/local/download |
DEVBOT_GITHUB_TOKEN |
gcp-project-vms |
✅ live — byte-identical token | Pool VM provisioning (§4.2) |
DEVBOT_GITHUB_TOKEN |
gcp-project-runtime |
❌ dead — all versions disabled | Nothing. Appears in §4.1 by name only |
DEVBOT_GITHUB_TOKEN |
GH repo secret, unifyai/console |
✅ live | ghcr-selfhost.yml clones private repos as x-access-token |
The dead copy is the trap. versions access against gcp-project-runtime fails outright, and an unchecked fetch carries an empty string into git clone, which GitHub answers with Repository not found — the same 404-instead-of-403 that hid an expired CLONE_TOKEN for ten days. An empty secret and a missing repo look identical from the error alone, so confirm the project before concluding the credential is broken:
gcloud secrets versions list DEVBOT_GITHUB_TOKEN --project=gcp-project-saasTo confirm which account and scopes a copy carries, without printing it:
TOK=$(gcloud secrets versions access latest --secret=DEVBOT_GITHUB_TOKEN --project=gcp-project-saas)
curl -s -o /dev/null -D - -H "Authorization: Bearer $TOK" https://api.github.com/user | grep -iE 'x-oauth-scopes|token-expiration'An absent github-authentication-token-expiration header means the PAT does not expire.
Direction of travel: these PATs are being retired. GitHub has no API for creating a personal access token, so rotating one means signing into the web UI as its owning account — which for a bot-owned token means holding the bot's password and 2FA, and for a person-owned token means only that person can do it. Sharing a login is not the answer; a GitHub App is. An App has no login, password or 2FA at all: it signs a JWT with a private key and exchanges it for an installation token that expires in an hour, scoped to chosen repos and permissions. Rotation becomes a key swap, and a leaked token dies within the hour.
Three org-owned GitHub Apps have now replaced the CI PATs:
| App | App ID | Installed on | Key held in |
|---|---|---|---|
unifyai-ci-runner |
4597891 |
unify-deploy (Administration: write, Metadata: read) |
Secret Manager CI_RUNNER_GITHUB_APP_PRIVATE_KEY — see deploy/k8s/ci-runner/ |
unifyai-ci-clone |
4606137 |
the 7 repos listed in §7.2 | org secret CI_CLONE_APP_PRIVATE_KEY |
unifyai-ci-dispatch |
4606148 |
unify |
org secret CI_DISPATCH_APP_PRIVATE_KEY |
CLONE_TOKEN is retired — the org secret was deleted on 2026-08-16 and every consumer now mints a short-lived installation token instead, scoped per call (repositories: brain,branding in the clone workflows). The PAT itself still exists on ci-bot; revoking it is the remaining cleanup. The DEVBOT_GITHUB_TOKEN slots above are what is left: document them, do not add to them.
gcp-project-saasholds the same token as two enabled versions (1and3).latestis authoritative; version1is redundant and should be disabled.UNIFY_DEPLOY_CI_RUNNER_GITHUB_TOKEN(saas, fine-grained, expires 2027-08-13) is owned by the human accountYushaArif99, not by a machine account — so it expires on a personal cadence and nobody else can rotate it. Despite the name it is not what the CI runner uses; the runner reads the GitHub App key fromgcp-project-runtime. Confirm what still consumes it before renewing, and prefer moving it onto an App.CI_RUNNER_GITHUB_TOKEN(gcp-project-runtime, classicrepo,YushaArif99-owned, would have expired 2026-09-23) is superseded and disabled — the runner cold-starts without it. Retained only as a rollback path; delete once a release PR has gone green on the App.
Triggers fire on branch pushes; the GitHub connection is github-unifyai (gcp-project-runtime/us-central1). All triggers and builds live in us-central1 — gcloud builds {list,describe,log,triggers list} must pass --region=us-central1, or the global default returns stale/empty results (see the region cheat-sheet in §3).
| Branch | Trigger | Image | Env |
|---|---|---|---|
staging |
unity-deploy-staging |
unity-staging |
Staging |
main |
unity-deploy-production |
unity |
Production |
Each build clones unity (matching branch), overlays this package, pushes to Artifact Registry, updates the GCS image hash, applies ExternalSecret manifests, and refreshes the GKE idle pool. Cloud Build config retains _UNITY_REF/_UNITY_SHA fallback substitutions and _CLUSTER: 'unity'. repositories/unity / unity-deploy and fail at source-fetch — see §9.
- All repos:
tests.yml,sync-staging.yml(fast-forwardstagingaftermain). - orchestra:
ghcr-selfhost.yml(→ghcr.io/unifyai/orchestra), billing/cleanup cron jobs hittinghttps://api.unify.ai/v0/admin/*(prod) andhttps://internal.example.com/v0/*(staging). - unity:
tests.yml(envunity-testing; clones orchestra/unify/unillm +magnitude@main),llm-cache-refresh.yml(envunity-llm-cache-refresh),pages.yml. - unity-deploy:
ghcr-selfhost.yml(→ghcr.io/unifyai/unity-selfhost,unity-desktop-selfhost;workflow_dispatchonly),hosted-tests.yml(GCP_PROJECT_ID=gcp-project-runtime). - console:
ghcr-selfhost.yml(→ghcr.io/unifyai/console-selfhost),code-quality.yml,security.yml. - unillm:
pypi.yml(publish on tag).
This is the single most surprising thing about this pipeline, and it is easy to lose hours to. A push to unity does not put that code on assistant pods. Pods resolve their image from a GCS pointer, and reaching that pointer takes two builds:
| Stage | Trigger | Fires | Writes |
|---|---|---|---|
| 1. Base | unity-base-staging-private / unity-base-production-private |
Manual only (gcloud builds triggers run … --branch=staging|main) |
unity-base* image, and the baked commit to gs://bucket/unity_base_sha[_staging].txt |
| 2. Overlay | unity-deploy-staging / unity-deploy |
On push to this repo | Overlay image, gs://bucket/image_hash[_staging].txt, comms deploy, idle-pool refresh |
Stage 1 clones unity at the branch (_UNITY_REF, default main) and is what actually picks up new brain code. It has no push trigger — nothing runs it automatically, so unity commits sit unshipped until someone runs it. unity/deploy/REBUILD_MARKER.md exists only to nudge this along; it changes unity's SHA and nothing else.
Symptom of forgetting: a unity build reports SUCCESS, no image appears under that commit, and pods keep running the old code. Read the two pointers before believing anything else:
gsutil cat gs://bucket/unity_base_sha_staging.txt # what the base image baked
gsutil cat gs://bucket/image_hash_staging.txt # what pods actually runLand changes on staging, let staging deploy/validate, then promote staging → main. Never merge a feature branch directly into main/master.
When a change spans unity and this repo, the order is not interchangeable. The pod manifest lives here; the code its containers run lives in unity. Promote this repo first and production spawns pods whose manifest references code the image does not contain — and because such changes usually also remove the thing the old shape relied on (a provider key, an env var), there is no fallback: every new pod fails, and restartPolicy: Never means each one stays failed.
1. unity → main
2. run unity-base-production-private --branch=main
3. verify the image really contains the change (below)
4. unity-deploy → main
Step 3 is not ceremony. Verifying against a local checkout is not verifying what ships — run the new base image and import the thing you added:
kubectl run img-check -n production --rm -i --restart=Never \
--image=us-central1-docker.pkg.dev/gcp-project-runtime/unity/unity-base:<sha> \
-- python -c "import unify.llm_broker; print('present')"This ordering was learned the hard way on 2026-08-11: this repo reached main ahead of unity while landing the broker sidecar, leaving production one build away from every new pod crashlooping with no provider keys. It was caught by chance mid-deploy, not by any check — nothing in CI enforces the ordering, which is why it is written down here.
Finalization decision (2026-06-24): stabilize, do not keep migrating. The platform-wide forward migration to
unity-*is halted. The goal is now code ⇆ live infrastructure agreement at the current mixed state, not "everything must beunity". GitHub repo names stayunity/unity-deploy; the immutable trio (project IDs + cluster, row 16) staysunity; the cleanly-migrated pieces (Ubuntu pool, archives, Cloud Run, Artifact Registry, per-assistant Pub/Sub, code identifiers) stayunity; and the parts still live onunity(tunnel, Windows pool) are now pointed atunityin code on purpose rather than chased tounity. Code aligned for this:tunnel_config.py(tag/IP →unity-tunnel-server*),vm_config.py+vm_helpers.py(per-OS pool prefix: Ubuntuunity-pool-*, Windowsunity-pool-*; Windows image family/tags →unity-*), andunityCI (tests.yml,llm-cache-refresh.yml) reference theUNIFY_COMMS_URLsecret by its current name — though as a public repo it cannot actually read it, and resolves it empty (row 4). The 2026-06-22 productionassistant-session-controllercrash-loop was a controller defect (the timer reconcile ran per-session GCP VM/disk ownership scans over ~110 terminal sessions every interval, starving its liveness probe), not a name mismatch; fixed by skipping the timer reconcile for terminally-Releasedsessions. No mass create/ delete/DNS/Pub-Sub churn is required.
Prioritized. P0 = can break production, P1 = breaks CI / partial degradation, P2 = cleanup/clarity.
| # | Pri | Item | Detail / fix |
|---|---|---|---|
| 1 | P0 | Tunnel fixes stranded on staging |
8b5097a9 (tunnel bucket/VM → unity-*) and 244ad103 (SFTP firewall band) are on origin/staging not origin/main. Prod common/settings.py may still point tunnel at non-existent unity-tunnel-*. Merging staging→main is required for these (but does not fix #2/#3 which are identical on both branches). |
| 2 | P0 | Pool image families (RESOLVED 2026-06-24) | Code wanted unity-pool-ubuntu-vm; only unity-pool-ubuntu-vm existed → 404 on every desktop provision → assistant-session-controller CrashLoop. Fixed by creating unity-pool-* families. Keep the families fresh: build-ubuntu.sh/build-windows.sh publish to them. |
| 3 | P0 | Archive bucket (RESOLVED 2026-06-24) | Code wanted unity-assistant-archives; only unity-assistant-archives existed → no cross-session file persistence. Fixed by bucket create + rsync (183 objects). |
| 4 | P2 | unify CI cannot read the comms URL secret (CONFIRMED 2026-08-17) |
Name mismatch RESOLVED — both UNIFY_ADAPTERS_URL/UNIFY_COMMS_URL and legacy UNITY_* exist as org secrets, and unify's + unify-deploy's workflows read secrets.UNIFY_*. Separately, all four are visibility=private, which excludes public repos, so unify's secrets.UNIFY_COMMS_URL resolves empty — silently, no error, in all four workflows that reference it (flow-smoke.yml:213, flow-smoke-release-gate.yml:92, tests.yml:780, llm-cache-refresh.yml:237). unify-deploy (internal) is unaffected. This did not replace the name mismatch and is not new: the predecessor secrets.UNITY_COMMS_URL was equally empty on runs 31721270820 (2026-08-13) and 31829096018 (2026-08-14), before the 2026-08-16 UNIFY_* rename (unify@87801169d) — both names carry the same private visibility, so the rename neither caused nor fixed it. Impact today is nil, which is why it went unnoticed: tests/flows/conftest.py:55 hard-sets UNIFY_COMMS_URL="" for flow tests regardless of the secret, conversation-manager tests stub every outbound comms call autouse, and _post_to_comms degrades to a LOGGER.debug returning False. RESOLVED 2026-08-17 by deleting all four references (unify@1a4d2dfb2) — the lines were a trap, not a missing credential. Populating the secret would have been the harmful fix: TaskSettings.LOCAL_SCHEDULER_ENABLED is derived from UNIFY_COMMS_URL at import (unify/task_scheduler/settings.py), so a real URL switches CI off the in-process LocalActivationScheduler onto Communication's Cloud Tasks queues and points _post_to_comms/drain_gate at the hosted service with UNIFY_KEY. Removal is behaviourally identical to the status quo, since every consumer reads the variable with an empty default and unset == set-to-empty. Do not add a repo-level copy in unify or widen the org secret to selected — CI has no comms service, and wants none. See §7.2. |
| 5 | P1 | Environment naming split + orphans | Live envs: unify uses legacy unity-testing / unity-llm-cache-refresh (both populated, both load-bearing — its workflows name them explicitly), while unillm and unisdk use unify-testing. unify's droid-testing and github-pages hold no secrets — orphans, safe to delete. Renaming the unity-* envs means editing the environment: key in unify's tests.yml, flow-smoke.yml, flow-smoke-release-gate.yml, llm-cache-refresh.yml in the same change, or the jobs lose their secrets silently. |
| 6 | P1 | Stale Cloud Build triggers | Triggers bound to repositories/unity / unity-deploy (e.g. adapters-unity-deploy, unity-comms-app-*) fail at source-fetch (~3-6s, no steps) even though GitHub redirects the repo. Recreate as unity-* triggers against repositories/unity / unity-deploy. Compat files cloudbuild/unity-comms-app*.yaml exist for old trigger names. |
| 7 | P1 | Referenced-but-unconfigured secrets | NEXT_SERVER_ACTIONS_ENCRYPTION_KEY (console build-arg), TWILIO_*/LIVEKIT_*/GCP_SA_KEY (unity-deploy hosted-tests.yml) are not configured → empty/skip. |
| 8 | P2 | magnitude@main |
RESOLVED: magnitude default/consumption branch is now main; install/CI/pool scripts updated. |
| 9 | P2 | OS user unityuser (HOME /Unity) |
Across unity desktop scripts + unity-deploy packer/pool scripts + file_manager/sync/config.py. Renaming requires rebuilding pool images. |
| 10 | P2 | Windows pool entirely unity-* |
RESOLVED (code aligned): Windows pool stays unity-* by design. vm_config.py/vm_helpers.py now use a per-OS prefix (pool_vm_name_prefix): Ubuntu → unity-pool-*, Windows → unity-pool-*; Windows image family → unity-pool-windows-vm, tag → unity-windows-vm. New Windows VMs/IPs/DNS now match the live pool, so replenish no longer breaks. |
| 11 | P2 | Pub/Sub ~84% unity-* |
1,334 topics / 5,198 subs legacy vs 252 / 1,021 unity; per-assistant duplication. Cut over + delete legacy. |
| 12 | P2 | Empty unity-* data buckets |
unity-call-recordings, unity-pod-logs, unity-pipeline-artifacts, unity-image-hash created empty; code partly still reads/writes unity-* (which hold the data). unity-tunnel-config/unity-youtube-extraction have no unity copy. |
| 13 | P2 | Duplicate service accounts | unity-pipeline-worker + unity-pipeline-worker both exist. |
| 14 | P2 | App-level unity string constants |
UnityTests (orchestra context-cleanup + default test project across repos), UnitySystemEvent gateway envelope (unity ↔ console wire contract), WaitingForUnity session-state labels (unity-deploy controller), unity-user-filesync SSH key comment (orchestra). These are deliberate cross-references, not typos — rename only with coordinated multi-repo changes. |
| 15 | P2 | Rollback resources to clean up | Legacy unity-pool-* images and unity-assistant-archives were kept as rollback after the 2026-06-24 fix. Delete once a real session confirms restore against unity-assistant-archives. |
| 16 | — | Immutable unity names |
Project IDs gcp-project-vms / gcp-project-runtime (display "Unity LiveKit"), SA emails, GKE cluster unity. Accept as canonical; do not attempt to "fix" in code (it already targets these intentionally). |
unify_deploy/ is the enterprise overlay loaded by unity at runtime via a Python entry point. When _UNITY_STARTUP_HOOK_GROUP is set (K8s Secret in hosted deploys), unity calls importlib.metadata.entry_points() and runs this package's startup_hook(). Absent that env var (open-source), the mechanism is inert.
The startup hook:
- Resolves the assistant deployment — deployment-matched spec with org/team/user/assistant seed layers merged in scope order, plus
.secrets.json. - Syncs seed data — hash-based idempotent sync of contacts, guidance, knowledge, secrets, blacklist to the Unify backend.
- Syncs custom functions — upserts client memoized functions + venvs via
FunctionManager.sync_custom().
unify_deploy/
├── hook.py # entry point: startup_hook()
└── assistant_deployments/
├── clients/ # client_alpha/, clientzeta/, client_beta/, … (self-register)
├── configs/types/ # ActorConfig
├── environments/ # serialized environment reconstruction
├── seed_sync.py # generic hash-based seed sync
└── secrets_file.py # .secrets.json parser
Adding a client: create clients/<name>/, define deployments/<name>/ (each exposes a DeploymentSpec via get_deployment()), build an EnvironmentConfig/DeploymentMapping and call register_client() in __init__.py, import the client at the bottom of clients/__init__.py, and add any runtime secrets to .secrets.json (gitignored).
base/migration note:base/is the private mirror of hosted base-image assets that still live inunity/deploy/today. Treatbase/as the canonical private copy while live triggers run fromunity; cut over only after the private path is verified end-to-end.
Setup:
git clone git@github.com:unifyai/unify.git
git clone git@github.com:unifyai/unillm.git
git clone git@github.com:unifyai/unity-deploy.git
cd unity-deploy && uv sync --all-groups && pre-commit installFirst-party deps resolve from sibling editable checkouts. Run pre-commit install in every fresh checkout/worktree.
The full all-repo local stack (local Orchestra + Console + Coordinator + gateway) is in selfhost/ — start with bash selfhost/stack.sh up --durable, inspect with selfhost/stack.sh status, reset with selfhost/stack.sh reset. The inner-loop runbook is docs/local-full-stack-inner-loop.md. LiveKit is an external Cloud BYOK dependency for both source and compose stacks.
| Symptom | Likely cause | Debug |
|---|---|---|
| Assistant not responding | No idle container | GKE for idle jobs; /scheduled/jobs/create logs |
| Delayed response (minutes) | Startup queued, no idle container | Check idle jobs; trigger job creation |
| Files vanish between sessions / "workspace was wiped" | Desktop VM not provisioned (image family 404) or archive bucket missing | kubectl logs deploy/assistant-session-controller -n production for image … was not found; confirm unity-pool-ubuntu-vm family + unity-assistant-archives bucket exist |
assistant-session-controller CrashLoop (Exit 137) |
Provision loop stalls health probe (usually image-family 404) | Same as above; verify pool VMs are RUNNING in gcp-project-vms |
/infra/tunnel/register 500 |
Tunnel bucket name mismatch (unity-tunnel-config doesn't exist) |
Ensure code points at unity-tunnel-config (loose end #1) |
/infra/tunnel/register 401 |
Orchestra /user/basic-info rejecting the key |
Auth issue in tunnel control plane (separate from #1) |
| Email/SMS not received | Watch expired / contact not validated | /scheduled/email-watches logs; verify saved contact |
TLSV1_ALERT_INTERNAL_ERROR on desktop |
VM missing wildcard cert | VM_WILDCARD_FULLCHAIN in gcp-project-vms; crt.sh/?q=%.vm.unify.ai |
| CI deploy reads empty COMMS/ADAPTERS URL | Secret name split (#4) | Org has UNITY_*; workflow reads UNITY_* |
| Cloud Build fails instantly, no steps | Stale trigger on repositories/unity[-deploy] (#6) |
Recreate trigger against unity/unity-deploy |
| Hiring 500 / "Rate Limit Exceeded" | GCE instances.insert rate limit |
gcloud logging read in the VM project for 403s |
| Bundle publish landed but live brain_operator still on old code | Never-idle session / overlapping offline Jobs | Arm graceful drain: ASSISTANT_DRAIN_RESTART.md; check ConfigMap unity-assistant-drain-intents |
| Guide | Topic |
|---|---|
deploy/guides/ASSISTANT_DRAIN_RESTART.md |
Graceful/force assistant drain, admission close, publish fan-out |
deploy/guides/UNIFY_CLUSTER_SECRETS.md |
ESO bootstrap, unity-secrets rotation, break-glass |
deploy/guides/GKE_EPHEMERAL_STORAGE.md |
10Gi Autopilot cap, emptyDir /tmp, HF/Docling caches |
deploy/guides/TELEMETRY.md |
Prometheus / Cloud Monitoring metrics pipeline |
deploy/guides/CALL_RECORDING.md |
Call recording storage + flow |
deploy/communication/guides/DEPLOY_CHECKLIST.md |
Comms deploy checklist |
docs/coordinator-onboarding-contract.md |
Coordinator onboarding contract |
guides/LOCAL_ASSISTANTS.md |
Local assistant deployments |
Maintenance: when infrastructure changes, update this README first. It supersedes the former
deploy/guides/INFRA.md(folded in 2026-06). Keep the deep-dive guides for operational procedure; keep architecture + canonical names here.