← Corpus / augment-it / spec
Workspaces as Tenant Primitive — toggling, tenant-aware microservices, per-tenant env-var pickup, and the seam that lets MCPs and connectors vary per client
Augment-It (and, by inheritance, its sibling pillar apps memopop-ai and dididecks-ai) needs a tenant primitive named workspace — the boundary that says 'humain-vc' vs 'reach-edu' vs whoever comes next. The terminal state is rich: per-workspace branded theme, team membership, roles + permissions, registration flow, auth, per-tenant choice of LLM provider / CRM / MCP server / search connector / storage destination. We are filesystem-backed and local right now, so this spec lays out the vision once and then scopes baby step 1 down to its bones: a workspace toggle, a workspace-aware envelope on every microservice request, I/O routing to `clients/<slug>/`, and a per-workspace `.env` pickup that resolves through a connector-config seam designed to absorb Decile-shaped (and future MCP-shaped, future LLM-shaped) per-tenant integrations without re-design. Inspired by [[Cloud-Variant-of-Dididecks-AI-Workspace]] but diverges on storage (filesystem-now vs cloud) and on cross-cutting reach (this is the contract sibling pillar apps inherit, not a dididecks-only artifact).
- Path
- specs/Workspaces-as-Tenant-Primitive.md
- Authors
- Michael Staton
- Augmented with
- Claude Code on Claude Opus 4.7 (1M context)
- Tags
- Spec · Augment-It · Workspaces · Tenant-Boundary · Multi-Tenant · Connector-Config · Env-Var-Adapter · Filesystem-Backed · Cross-Cutting-Pillar-Contract · Humain-VC · Reach-Edu
Workspaces as Tenant Primitive
What this spec is
This spec defines the workspace as the tenant boundary inside augment-it,
pins the long-arc vision so future feature work has a target to compose against,
and scopes baby step 1 down to a filesystem-only walking version that is
genuinely small but does not paint future steps into a corner. Sibling pillar
apps in ai-labs/ — memopop-ai and dididecks-ai — adopt the same shape
later via the same contract; this spec is the cross-cutting one even though
the implementation lands first in augment-it.
It is inspired by [[Cloud-Variant-of-Dididecks-AI-Workspace]] and reuses that exploration’s privacy-properties table as a forward-looking constraint, but it diverges in two ways the reader should know up front:
- Storage: the cloud-variant exploration is reasoning about S3 + KMS +
Postgres RLS. This spec is filesystem-backed (
clients/<slug>/) because that is the actual current ground truth. The seam is designed so the cloud-variant’s adapters can be slotted in later without re-cutting the workspace contract. - Scope: the cloud-variant exploration is dididecks-only. This spec is the cross-cutting contract — workspace is the tenant primitive across all three pillar apps. The shape has to be sharable.
Name collision (lived with, not renamed)
There is a prior plan, [[Augment-It-Workspace-Walking-Skeleton]], that uses the word workspace in a different sense — the singular architectural substrate (Svelte 5 singleton + Node/Fastify Workspace Service + NATS bus) that holds row state for the running app. That substrate shipped.
This spec uses workspace to mean tenant boundary — one of many per running app, the thing the operator toggles between to switch from humain-vc to reach-edu.
Both meanings live. Resolution is in prose where ambiguity matters:
- The prior plan’s Workspace Service (the container) keeps its name; it is the runtime substrate that now also has to become tenant-aware.
- This spec’s workspace (lowercase, the tenant) is the new concept; every occurrence of “workspace” below means tenant boundary unless explicitly qualified.
- When we need to disambiguate in code, we use
tenant_slug/workspace_slugas the field name on the wire (substrate-irrelevant) andWorkspaceService(PascalCase, container name) for the substrate component.
This is deliberate — see [[feedback_context_v_evolves_in_prose]]. The context-v record carries the evolution; we do not rewrite history to dodge a name collision.
Terminal vision (what workspaces eventually do)
Locked here so baby step 1 does not foreclose any of it. None of the following ships in baby step 1; it ships in named later steps.
- Tenant identity. A workspace is the unit of “whose data am I working in right now.” Every record set, every corpus directory, every fire, every response, every audit-log entry, every env-var resolution is workspace- scoped.
- Team membership, roles, permissions. A workspace has members; members
have roles; roles confer permissions. The admin surface for managing this
lives inside the workspace, governed by an
adminrole. Default roles:admin,editor,viewer. Permissions are capability-gated at the workspace boundary, not at the record-set level. - Registration and auth flow. A user signs up, joins or creates a workspace, and from then on every session is workspace-bound. Switching workspaces is a first-class action — not a re-login but a context switch. Inherits from [[Install-Auth-Across-Applied-AI-Labs-Apps]] (the auth blueprint) once that lands.
- Per-workspace branded theme. Each workspace controls design tokens
(colors, typography, spacing) via a
theme.cssoverride layered on top of the shipping three-mode theme system. Inherits from thetheme-systemskill’s two-tier-token discipline. - Per-workspace connector configuration. The headline mechanic. A
workspace declares which LLM provider it uses, which CRM (Decile,
HubSpot, Salesforce, custom), which MCP servers (Gmail, Drive, GitHub,
custom), which search providers (SearXNG, Tavily, SerpApi), which storage
destination (filesystem-now, S3, Powabase later). Each connector reads
from the workspace’s
.envnamespace and is resolved at request time. - Cross-cutting across pillar apps. memopop-ai and dididecks-ai adopt
the same workspace contract. A user with workspaces
humain-vcandreach-edusees the same toggle in every pillar app; the active workspace is shared across apps in the same session. - Tamper-evident audit log. Every workspace-scoped operation writes
to an append-only log inside the workspace (
clients/<slug>/audit/) keyed by user + capability + payload-hash + timestamp. Filesystem-first; moves to a separate trust boundary when the cloud variant arrives. - Termination and export. Deleting a workspace deletes its
clients/<slug>/tree (modulo retention). Exporting a workspace is a zip of the same tree. Filesystem-now makes both trivial; the seam stays the same when storage moves.
Items 2–4 and 7–8 are explicitly out of scope for baby step 1. They are named here so the seam designed in step 1 absorbs them later without re-cut.
Why filesystem-first
Stated plainly so future agents do not skip to the cloud shape prematurely:
- Local-fs makes the privacy properties from the cloud-variant exploration’s table mostly free; we should consume that gift while it is on offer.
- A DB schema for workspaces invites premature commitment to RBAC tables, membership tables, audit tables before the connector-config seam is exercised against real per-tenant integrations.
- The operator (the human) is currently the only user; multi-user / auth arrives later and the shape it takes is informed by what the connector seam produces, not the other way around.
clients/<slug>/.envis already the working convention (humain-vc has one; reach-edu has one). Designing toward what already works beats inventing a workspace-config DB.
Baby step 1 — scope
The smallest end-to-end version that exercises the workspace primitive through every layer of the existing stack. Five mechanics, all filesystem- backed, none of them admin-grade.
1. Workspace registry (filesystem-discovered)
clients/ is the registry. Each immediate child directory is a candidate
workspace. A workspace exists when its directory exists and contains at
minimum:
clients/<slug>/
├── .env # per-workspace environment, gitignored
├── .gitignore # at minimum guards .env
└── (corpus/, inputs/, outputs/ created lazily)
No workspace.json manifest, no registry DB. The directory IS the workspace.
A future step adds a clients/<slug>/workspace.yaml for display name, theme
overrides, member list — but baby step 1 derives display name from the slug
(title-case-with-dashes-broken).
2. Active workspace, persisted
The shell holds active_workspace_slug in localStorage. On boot, the shell:
- Reads the persisted slug.
- If it points to a directory that no longer exists, falls back to the
first slug in alphabetical order (
humain-vctoday; resilient to additions). - Publishes
workspace.active.changedon the NATS bus so every service that holds workspace-scoped state can react.
Following the pattern from [[dffaac6 — Sort & Filter Lens auto-falls-back]] — when localStorage points at a now-gone target, fall back gracefully.
Workspace vs. Flow — orthogonal axes. active_workspace_slug answers
which client. It is deliberately independent of which use-case (augment
records vs. curate a domain/thesis vs. reconcile to canonical DB) — see
[[../explorations/Augment-It-Has-Outgrown-One-Flow-The-Choose-A-Flow-Front-Door]],
which proposes a header-level “jumbo popdown” navigate-action for the
domain/thesis-curation use-case (no persisted state of its own, unlike this
spec’s workspace slug — keep the two conceptually separate regardless).
Note also: this spec’s “Flow 1” language
elsewhere in the docs (the humain-vc user-journey plan) and the shell’s
ROTATION numbered nav are two more senses of the word “flow” — that
exploration’s §“three senses of Flow” disambiguates all three.
3. Toggle UI
A workspace switcher lives in the shell chrome — not inside any single remote. Minimum shape:
- A button or chip showing the active workspace’s display name.
- Click → dropdown of all discovered workspaces.
- Selecting one fires
workspace.activateon the workspace-service. - The shell broadcasts the change; remotes that care re-render against the new context.
No design tokens shift in baby step 1 — the theme stays the shipping three-mode default. Per-workspace theme is item 4 of the terminal vision and is explicitly later.
4. Tenant-aware envelope on every microservice request
Every NATS message envelope gains a required field:
{
"kind": "invoke",
"id": "inv_abc123",
"workspace_slug": "humain-vc",
"capability": "row.list",
"args": { ... }
}
WorkspaceService (the container) injects workspace_slug from the
session’s active workspace on every outbound NATS request. Domain services
treat unscoped requests as a programming error and refuse them with a clear
log line — not a runtime crash, but a refusal that makes the missing-slug
case loud.
Implication for existing handlers:
services/content-ingest/—corpus.list_for_record,corpus.add,corpus.list_for_record,promote-snapshot— all gain aworkspace_slugparameter and resolve paths againstclients/<slug>/corpus/instead of the hardcoded one.services/row-store/— record sets are workspace-scoped; the JSON store becomes per-workspace (clients/<slug>/rows.jsonordata/<slug>/rows.json, TBD in implementation phase).services/social-search/,services/prompt-runner/,services/response-store/,services/ingest/,services/xlsx-ingest/— each receivesworkspace_slugand uses it to scope state and to resolve connector configuration (see § 5).
5. Per-workspace .env pickup via a connector-config seam
The load-bearing design moment of baby step 1. Done well, the rest of the vision composes onto it; done thoughtlessly, every future integration re-cuts the wire.
The seam
A workspace’s .env is loaded into a scoped config object keyed by
workspace slug, NOT merged into process.env. Merging into process.env
would create cross-workspace bleed the moment we have two workspaces active
in the same Node process (which we do, because WorkspaceService serves
all sessions).
// packages/workspace/src/config.ts
export interface WorkspaceConfig {
slug: string;
env: Readonly<Record<string, string>>; // raw .env contents, frozen
connectors: ConnectorRegistry; // resolved typed connectors
}
export interface ConnectorRegistry {
llm?: LLMConnectorConfig; // anthropic | openai | local | ...
search?: SearchConnectorConfig; // searxng | tavily | serpapi | ...
crm?: CRMConnectorConfig; // decile | hubspot | salesforce | ...
mcp?: MCPConnectorConfig[]; // arbitrary set of MCP servers
storage?: StorageConnectorConfig; // filesystem | s3 | powabase | ...
}
Services do not read process.env.ANTHROPIC_API_KEY directly. They request
the workspace’s resolved connectors.llm from WorkspaceService (in-process
when running together, via NATS request/reply when separated).
The Decile worked example
clients/humain-vc/.env contains:
DECILE_API_URL=https://humain.decilehub.com
DECILE_HUB_API_KEY=...
(These are the real Decile Hub var names, confirmed against the API spec — the
tenant is encoded in the subdomain, so there is no separate DECILE_TENANT_ID,
and the token is sent raw in the Authorization header. See the
[[decile-hub-connector]] skill.)
The connector resolver maps the DECILE_* prefix to a crm connector of
kind decile. A future crm.list_deals capability dispatches against
workspace.connectors.crm — when the active workspace is humain-vc it
resolves to the Decile config; when the active workspace is reach-edu
(which does not have Decile configured) it returns null and the capability
publishes a crm.not_configured event instead of erroring.
The pattern generalizes: every connector kind has a prefix or set of
prefixes in the env namespace, a resolver that maps from prefix to
typed config, and a fallback of either another configured provider or
not_configured. Adding a new CRM (HubSpot) is: register the HUBSPOT_*
prefix, write the resolver, ship.
What the seam buys us
- Decile lands without touching any other client’s behavior.
- A new MCP server (say, Gmail for one client, Drive for another) is a registry entry, not a rewrite.
- The cloud-variant’s “per-workspace KMS keys” property has a place to live later — the resolver gains a “fetch from KMS” branch alongside the “read from .env” branch.
- The
connectorsobject is the only surface domain services read workspace-scoped configuration from. That makes the audit story tractable: every connector access can be logged at the resolver, not at every caller.
Migration: reach-edu → humain-vc as primary
The submodule pointer on rebuild/turbo-rsbuild is currently
clients/reach-edu. Baby step 1 makes humain-vc the default active
workspace (alphabetically first; matches the user’s stated intent of
building humain-vc functionality first and back-porting to reach-edu).
Concretely:
clients/humain-vc/is committed to the repo (currently untracked with.env+.gitignore). Submodule or in-tree TBD by the pseudomonorepos relocation discipline —.envis sensitive, must be backed up before any move.clients/reach-edu/(submodule) stays where it is; loses primacy but not presence.- The toggle defaults to humain-vc on a fresh localStorage; users who had reach-edu active retain it (the persisted slug survives the change).
What is explicitly NOT in baby step 1
- Auth, registration, sessions tied to humans. The “active workspace” is per-browser, not per-user.
- RBAC, roles, permissions. The operator is the only role.
- Per-workspace theme. The three-mode theme is unchanged.
- Audit log. The seam exists at the connector resolver; the writer doesn’t.
- Cross-app workspace sync (sibling pillar apps inheriting the same active-workspace context). The contract is shared; the inheritance ships per-app, on each pillar’s own schedule.
- A workspace-creation UI. Workspaces are created by
mkdir clients/<slug>/and dropping a.envin. The toggle picks them up on next boot. - Connector kinds beyond the registry shape. The
LLMConnectorConfig,SearchConnectorConfig, etc. types are declared and at least one is exercised end-to-end (likelysearchvia the existing SearXNG + Tavily seam, since that code already exists). Decile is the design target for the next step’s worked example, not the one baby step 1 has to ship working.
Forward inheritance — sibling pillar apps
memopop-ai and dididecks-ai are explicit consumers of this contract. The
shared parts live in packages/workspace/ inside augment-it; they
graduate to ai-labs/packages/workspace/ (a shared package) when the
second pillar app picks them up. Until then, the contract is exercised
in augment-it, with copies-for-now allowed in the other pillars if their
own walking-skeletons land before extraction.
What inherits:
- The
WorkspaceConfigandConnectorRegistryinterfaces. - The
workspace_slug-on-every-envelope discipline. - The
clients/<slug>/.envconvention (or each app’s equivalent root — memopop-ai may useusers/<slug>/, dididecks-ai may useclient-sites/<slug>/; the seam is shared, the root directory name is per-app). - The
workspace.active.changedevent semantics.
What does not need to be shared:
- The toggle UI (each app has its own shell chrome).
- The fallback rules (each app may have a different “first” workspace).
Open questions
- Same-session multi-workspace. Can a single browser tab hold two
workspaces open side-by-side (e.g., comparing humain-vc and reach-edu
in a split view)? Baby step 1 assumes no — one active workspace per
session. If yes becomes a requirement, the WS-envelope
workspace_slugscales fine, but the shell’s “active” notion has to fork into “active per pane.” - Per-workspace data files inside
clients/<slug>/. Today the corpus lives there but the row-store JSON lives inservices/row-store/data/. Does the row-store JSON move intoclients/<slug>/rows.jsonso the workspace is truly self-contained on disk? Lean yes; makes export and termination trivial. Decide in implementation. .envreloading without restart. When the operator editsclients/humain-vc/.envto add a Decile key, do we expect them to restart the stack or do we file-watch and re-resolve? Lean toward file-watch for the dev experience; trivial withchokidar. Worth pinning in the implementation plan.- What happens when a workspace’s
.envreferences a secret store (1Password CLI,op://...URLs) instead of plain values? The resolver has a clean place to grow that branch. Out of scope to build now; in scope to not foreclose. - Where does the toggle live in dididecks-ai and memopop-ai? The shell chrome differs across pillars. Likely each pillar reuses a shared component but mounts it in its own chrome. Confirm when the second pillar adopts.
- Theme override loading. When per-workspace theme arrives, where
do the override tokens live —
clients/<slug>/theme.css(filesystem) or atheme:block inclients/<slug>/workspace.yaml? Themaintain-design-mdskill prefers prose-with-frontmatter; aligns toward the YAML option.
See also
- [[Augment-It-Workspace-Walking-Skeleton]] — the prior plan whose “workspace” names the architectural substrate (lived-with collision).
- [[Cloud-Variant-of-Dididecks-AI-Workspace]] — the exploration this spec is inspired by; the privacy-properties table is the forward constraint the connector seam has to absorb when storage moves off filesystem.
- [[Funder-Content-Corpus-Workflow]] — the corpus convention that
established
clients/<slug>/corpus/as the per-tenant content root; this spec generalizes that pattern from corpus-only to all per-tenant state. - [[Install-Auth-Across-Applied-AI-Labs-Apps]] — the auth blueprint the workspace’s eventual identity/RBAC layer inherits from.
- [[Per-App-Workspace-Conventions]] — the parent blueprint the prior walking-skeleton plan instantiated; this spec extends with the tenant-boundary dimension.
- [[feedback_context_v_evolves_in_prose]] — why the name collision with the prior plan is left in place.