Skip to content

Workspaces — provisioning & standing up a brain instance

DEE-7. How Polaris creates and stands up a new brain (one tenant's whole platform) from a single declarative file, and the topology choice behind it.

A workspace is one brain instance: its tenant identity in the app.* registry, the accounts that operate it, its organization provenance, and its Scout discovery policy — provisioned onto the store substrate (Postgres+pgvector, Redis, MinIO, git, LightRAG). One YAML manifest declares it; one script stands it up end-to-end.

TL;DR

# Full stand-up from a manifest (stores → migrate → build → seed → start → verify)
./scripts/provision-workspace.sh --seed env/workspaces/example.workspace.yml

# Headless provision only (no gateway/FE): stores + migrate + seed + verify
./scripts/provision-workspace.sh --seed env/workspaces/acme.workspace.yml --no-app

# Apply just the seed against an already-running stack, then verify
APP_CONFIG=$(pwd)/env/local/app.env uv run bb workspace seed  env/workspaces/acme.workspace.yml
APP_CONFIG=$(pwd)/env/local/app.env uv run bb workspace verify --brain-id acme

Standing up a workspace on a new machine (from a fresh clone)

Do this once per host. Steps 1–3 are the standard Polaris dev prerequisites; step 4 is the provision.

0. Prerequisites — install these on the host first: - Docker + Docker Compose v2 (docker compose version) - uv (Python 3.13 toolchain + runner) - Node 20+ and pnpm (only if you want the SPA; skip with --no-fe)

# 1. Clone
git clone <repo-url> polaris && cd polaris

# 2. Create the app env from the template, then fill in secrets (API keys, JWT_SECRET, …).
cp env/local/app.env.example env/local/app.env
$EDITOR env/local/app.env            # set ANTHROPIC_API_KEY / TOGETHER_API_KEY / JWT_SECRET, etc.

# 3. Author the tenant manifest (copy the template; real *.workspace.yml is gitignored).
cp env/workspaces/example.workspace.yml env/workspaces/acme.workspace.yml
$EDITOR env/workspaces/acme.workspace.yml   # set brain_id, accounts + STRONG passwords, jurisdictions

# 4. Provision end-to-end (stores → migrate → build → seed → start → verify).
./scripts/provision-workspace.sh --seed env/workspaces/acme.workspace.yml

When it finishes you'll see the gateway (http://localhost:8088) and SPA (http://localhost:5174) URLs and a green verify summary. Log in with an account from your manifest.

Notes for a fresh host - First run pulls container images and installs deps, so it's slower; re-runs are fast and the seed is idempotent. - --no-app provisions the data (stores + migrate + seed + verify) without starting the gateway/SPA — useful for CI or a server that runs the app under a process manager. - --build-fe produces a production SPA build (pnpm build); omit it for the dev server. - If port 8088 or 5174 is taken, pass --gateway-port N / --fe-port N. - App processes run in the background (pids in /tmp/polaris_*.pid, logs in /tmp/polaris_*.log). Stop them with kill $(cat /tmp/polaris_gateway.pid) $(cat /tmp/polaris_fe.pid); stop the stores with ./scripts/stop-compose.sh. - Per-brain stack: if your manifest has a stack: block, the same command instead brings up an isolated compose stack on offset ports — set POLARIS_STACK/POLARIS_DATA_DIR are handled for you. See Per-brain stack below.

Re-provisioning / adding a second tenant

The seed is idempotent, so re-running provision-workspace.sh (or bb workspace seed) is safe — it only creates what's missing and never clobbers edits. To stand up another business on the same host, either add a new manifest and re-run against the shared substrate, or give each a stack: block with a distinct project_name + port_offset to isolate them.

The manifest

One file per tenant. Committed template: env/workspaces/example.workspace.yml (in the repo). Real manifests (env/workspaces/*.workspace.yml) are gitignored because they carry seed passwords.

Block Required Purpose
workspace ✅ (brain_id) Tenant identity → app.brain (display name, tenant_type, domain, jurisdictions, status).
stack Opt into a per-brain stack: project_name + port_offset. Absent ⇒ shared substrate.
provenance The tenant's business record (OrgProvenance); anchors ingestion + the Counselor's org-context.
accounts platform_admin (superuser) and tenant_user (brain + role) accounts. Created only if the email is absent.
scout_policy Outward-discovery policy so the Scout queue isn't empty. Seeded only if none exists.

The seed is idempotent: re-running never clobbers existing accounts, provenance edits, or a curator-tuned scout policy. It reuses the exact primitives the demo seeder uses (register_brain, create_account, scout_service.set_policy).

What the provisioning script does (the 5 requirements)

scripts/provision-workspace.sh implements DEE-7's script requirements in order:

  1. Substrate + migrations — delegates to start-compose.sh (compose up + alembic upgrade head).
  2. Builduv sync --dev for the backend; pnpm install (+ optional pnpm build via --build-fe) for the SPA.
  3. Seedbb workspace seed <manifest> registers the brain, seeds accounts + scout policy.
  4. Start — launches the gateway (uvicorn --reload, :8088) and the SPA (vite, :5174) as host processes, pointed at the gateway. Skip with --no-app.
  5. Verifybb workspace verify checks brain registration + accounts + store connectivity (the same probe as bb probe), then curls the gateway /health and the SPA. Exits non-zero on any failure, so it doubles as a CI gate.

Key flags: --no-app (provision only), --no-fe, --no-build, --build-fe (production SPA build), --no-verify, --gateway-port, --fe-port, -e/--env.

Topology: shared substrate vs. per-brain stack

The research question in DEE-7 was whether each brain should run as its own stack. Both are supported; the manifest's stack: block is the switch.

Shared substrate (default)

One polaris compose stack; brains are isolated logically by brain_id scoping that already runs through every store (app.datastore.scoping: Postgres rows, lightrag namespace, S3 prefix, Redis stream key). This is what the dev loop and the demo use.

  • ➕ One stack to run; cheapest footprint; matches today's dev.sh.
  • ➖ Shared blast radius; noisy-neighbor risk; no per-tenant store version pinning; a single Postgres to scale.

Use for: dev, demos, and low-volume tenants where logical isolation is sufficient.

Per-brain stack (opt-in)

Add a stack: block and each brain gets its own compose stack — isolated containers, data directory, and host ports:

stack:
  project_name: polaris_acme   # compose -p + container-name prefix (POLARIS_STACK)
  port_offset: 100             # added to every published host port

Mechanism (all backward-compatible — defaults reproduce the single polaris stack):

  • docker-compose.yml is parametrized: name/container_name derive from ${POLARIS_STACK:-polaris}, each published port from ${*_HOST_PORT:-<default>}, and each data volume from ${POLARIS_DATA_DIR:-data}.
  • provision-workspace.sh computes offset ports from port_offset, writes env/workspaces/<brain>.stack.env (compose interpolation), and generates env/workspaces/<brain>.app.env — the base env with the offset store URLs + BRAIN_ID appended (dotenv = last value wins), so the gateway/migrations/seed all target the isolated stack.
  • start-compose.sh / stop-compose.sh honor POLARIS_STACK for the project name and health target.

Example: port_offset: 100 ⇒ Postgres 5532, Redis 6479, MinIO 9100/9101, LightRAG 9721.

  • ➕ Hard isolation; independent scaling, backup, upgrade, and blast radius per tenant; a tenant's whole platform is one docker compose down away from a clean teardown.
  • ➖ N× container footprint on one host; you manage a port map; cross-tenant platform-admin views span multiple stacks. Doesn't yet containerize the gateway/SPA (host processes today; that lands with Phase 6 Dockerfiles).

Use for: production tenants and anywhere a business needs its own isolated environment — the direction DEE-7 leans ("run each brain as its own stack at the beginning").

Gotchas

brain_id is the underscore form — everywhere

The manifest's brain_id is the literal identifier used by every scoped store (LightRAG namespace, S3 prefix, Redis stream key), so it's written in the underscore form that those stores accept — themis_lime, not themis-lime. The hyphenated name is only ever a directory or hostname (env/workspaces/themis-lime/, polaris-themis-lime.tail8cee6e.ts.net), and the two are easy to mix up because they sit side by side.

Anything taking a brain_id must use the underscore form exactly:

uv run python -m app.cli apikey mint --brain-id themis_lime    # ✅
uv run python -m app.cli apikey mint --brain-id themis-lime    # ✅ mints — but for a brain that doesn't exist

A wrong brain_id fails late, and misleadingly

Nothing validates that the brain exists at mint time. The key authenticates fine, and the Advisor then reports no published version — this brain has not been published yet — which reads like a publishing problem but is really "that brain has nothing in it." Check bb corpus list for the exact spelling before you debug a publish.

Per-brain stacks: the git corpus mount must mirror the data dir

A per-brain stack sets POLARIS_DATA_DIR=data/<brain> (stack env) and GIT_REPO_PATH=./data/<brain>/git/<brain>.git (app env). That path is relative, and corpus_path() returns it verbatim for the stack's own BRAIN_ID, so it must resolve to the same physical repo whether the app runs on the host (CWD = repo root) or in a container (CWD=/app). The compose mount is therefore written to mirror the host-relative path on both sides:

- ${DEVOPS_BASEDIR}/${POLARIS_DATA_DIR:-data}/git:/app/${POLARIS_DATA_DIR:-data}/git

Silent corpus loss if the container target is hardcoded

With a fixed /app/data/git target, a per-brain stack's GIT_REPO_PATH resolves to /app/data/<brain>/git/…outside the mount. Everything still works: the Librarian commits, certify publishes, the Advisor answers. But the authoritative corpus is being written to the container's writable layer, and the next docker compose up --force-recreate (which start-compose.sh --with-app performs) deletes it. Postgres/MinIO survive, so the symptom is an intact brain_version row pointing at a corpus_sha whose repo no longer exists: NotGitRepository('No git repository was found at data/<brain>/git/<brain>.git').

Verify the mount lands on the host before trusting a per-brain app tier:

docker exec <stack>_advisor touch /app/data/<brain>/git/.mounttest
ls data/<brain>/git/.mounttest && rm data/<brain>/git/.mounttest   # must exist on the host

Recovery, if it happens: the approved change sets in app.change_set still hold the full graph. Replay them through review_service.approve() in decided_at order (merge order feeds dedup/resolution) and re-certify — corpus_sha is the content-addressed git tree sha, so a faithful replay lands on the original sha and publish() returns already_published, reusing the existing version rather than forking the lineage.

Recommendation

Start every business on a per-brain stack for clean separation of concerns and teardown, while keeping the shared substrate as the default for dev/demo. The manifest is the single source of truth for both — flipping topology is adding/removing the stack: block, not rewriting the tenant.

The natural next step (Phase 6) is to containerize the gateway + SPA per stack (Dockerfiles + compose services) so a per-brain stack is the entire platform in one docker compose up, and to lift the host-port map into a small allocator once tenants outgrow a single host.

Files

Path Role
env/workspaces/example.workspace.yml Committed manifest template.
app/service/workspace_seed.py load_manifest / seed_workspace / verify_workspace.
app/cli.pybb workspace {plan,seed,verify} CLI surface over the seeder.
scripts/provision-workspace.sh End-to-end orchestrator (the 5 requirements).
docker/docker-compose.yml Parametrized substrate (shared or per-brain).
scripts/start-compose.sh / stop-compose.sh Stack up/down; POLARIS_STACK-aware.