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:
- Substrate + migrations — delegates to
start-compose.sh(compose up +alembic upgrade head). - Build —
uv sync --devfor the backend;pnpm install(+ optionalpnpm buildvia--build-fe) for the SPA. - Seed —
bb workspace seed <manifest>registers the brain, seeds accounts + scout policy. - Start — launches the gateway (
uvicorn --reload, :8088) and the SPA (vite, :5174) as host processes, pointed at the gateway. Skip with--no-app. - Verify —
bb workspace verifychecks brain registration + accounts + store connectivity (the same probe asbb probe), then curls the gateway/healthand 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.ymlis parametrized:name/container_namederive from${POLARIS_STACK:-polaris}, each published port from${*_HOST_PORT:-<default>}, and each data volume from${POLARIS_DATA_DIR:-data}.provision-workspace.shcomputes offset ports fromport_offset, writesenv/workspaces/<brain>.stack.env(compose interpolation), and generatesenv/workspaces/<brain>.app.env— the base env with the offset store URLs +BRAIN_IDappended (dotenv = last value wins), so the gateway/migrations/seed all target the isolated stack.start-compose.sh/stop-compose.shhonorPOLARIS_STACKfor 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 downaway 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:
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.py → bb 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. |