Workspaces
Scoped sub-environments within a tenant — each with its own accent, sidebar overlay, landing route, and default agent.
Overview
Workspaces are focused environments that live inside a tenant. Where a tenant represents an organization, a workspace represents a function or team within that organization — Marketing, Sales, Executive, Research, Operations, and so on.
Each workspace has:
- An accent color (one of eight oklch presets) that appears as a visual indicator in the sidebar rail and nav items.
- A landing route that controls where the workspace opens by default (
/dashboard,/feed,/command-center, etc.). - A sidebar nav overlay — an optional list of pinned items, groups to show, or items to hide on top of the platform default.
- A featured entity type list — up to N entity type slugs that the workspace treats as primary data types (used by sidebar ordering and future workspace-filtered views).
- A default agent slug pre-selected in the chat dock when entering the workspace.
A user can belong to any number of workspaces within a tenant. Membership controls visibility — which workspaces appear in the rail and switcher — not data access. RLS still enforces tenant-level data isolation.
Key Concepts
Workspace
The core row shape returned to the UI. Maps 1:1 to the workspaces table.
interface Workspace {
id: string;
tenantId: string;
slug: string; // URL segment, lowercase + hyphens
name: string;
description: string | null;
icon: string | null; // Lucide icon name
letter: string | null; // Single-letter logo override
accent: WorkspaceAccentSlug;
landingRoute: string;
defaultAgentSlug: string | null;
featuredEntityTypeSlugs: string[];
navOverlay: WorkspaceNavOverlay;
boundEntityId: string | null;
boundEntityFilter: "parent" | "relation" | null;
isDefault: boolean;
sourceTemplateSlug: string | null;
createdAt: string;
updatedAt: string;
}WorkspaceAccent
Eight oklch accent presets. Each provides a fg (foreground, used for indicators and borders) and bg (tinted background, used for active tiles and the agent dock ring).
| Slug | Name | Foreground |
|---|---|---|
slate | Slate | oklch(0.42 0.02 260) — near-neutral blue-gray (default) |
navy | Navy | oklch(0.35 0.05 260) — deep navy (matches --primary) |
marigold | Marigold | oklch(0.55 0.14 65) — warm amber |
moss | Moss | oklch(0.5 0.12 145) — forest green |
ember | Ember | oklch(0.55 0.18 25) — burnt orange |
lagoon | Lagoon | oklch(0.52 0.11 200) — teal |
iris | Iris | oklch(0.52 0.14 295) — violet |
rose | Rose | oklch(0.55 0.15 10) — dusty rose |
WorkspaceNavOverlay
Optional sidebar customization merged as the final layer in the nav resolution pipeline when the workspace is active. Uses the same NavConfigOverride schema as tenant and user overrides — one merge pipeline, one schema across all five layers.
// WorkspaceNavOverlay is an alias for NavConfigOverride
type WorkspaceNavOverlay = NavConfigOverride;
interface NavConfigOverride {
style?: "sidebar" | "icon-rail" | "floating" | "inset";
nodes?: NavNode[]; // Each node may set hidden: true, position, or override label/icon/config
}To pin an item, add a link node with a position value. To hide a node, reference its id and set hidden: true. The workspace overlay is applied after the user's personal override so it cannot be overridden by per-user customisation.
// Example: hide the Data group and pin a workspace-specific link
const overlay: WorkspaceNavOverlay = {
nodes: [
{ id: "data", type: "group", hidden: true },
{
id: "ws-pipeline",
type: "link",
label: "Pipeline",
icon: "bar-chart",
config: { href: "/pipeline" },
position: 0,
},
],
};AppSidebar resolves this layer client-side so it follows the visible URL on soft navigation between workspace dashboards (both /t/<t>/w/<a>/dashboard and /t/<t>/w/<b>/dashboard rewrite to the same /dashboard route segment, so the layout does not re-render server-side). The shared (app) layout therefore calls getResolvedNavConfig(reqs, applyWorkspaceOverlay = false) and lets the sidebar apply the workspace overlay using resolveWorkspaceNavConfig (a thin wrapper around the unified resolveNavConfig).
WorkspaceTemplate
Code-defined constructors used by the install gallery. Templates are not a separate primitive — they describe the values to use when inserting a Workspace row. Six templates ship out of the box: default, marketing, sales, strategy, research, operations. Each function template (everything except default) sets navOverlay.replace: true so installing it produces a focused mini-app rail rather than a delta on top of the tenant rail.
WorkspaceMembership
Join table between users and workspaces within a tenant. Controls which workspaces a user sees in the rail and can also carry a workspace-local role. RLS and server auth always treat membership as (workspace_id, tenant_id, user_id) so a known workspace UUID cannot be reused across tenant boundaries.
interface WorkspaceMembership {
workspaceId: string;
userId: string;
tenantId: string;
roleId: string | null;
createdAt: string;
}workspaces.team.manage permission
The RBAC gate for workspace administration. Assigned to tenant_admin and system_admin roles by default. Workspace update and member-management actions pass { workspaceId } to requirePermission() so either a tenant-wide admin or a workspace-local admin can manage that existing workspace. Create, delete, and install-from-template stay tenant-level administrative operations because they create/destroy workspace scope rather than operating inside an existing one.
Role assignment and removal have an anti-escalation cap:
- Caller must administer the target workspace through tenant-level or workspace-level
workspaces.team.manage. - Target workspace must belong to the active tenant.
- Caller cannot assign, demote, or remove a peer/higher role unless operating from a higher role level.
- The final write is filtered by
(workspace_id, user_id, tenant_id).
How It Works
URL Routing
Workspace context is encoded as a path segment, not a query parameter. There are two URL shapes:
/t/<tenantSlug>/w/<workspaceSlug>/<rest> — explicit tenant + workspace
/w/<workspaceSlug>/<rest> — default tenant, bare pathMiddleware (proxy.ts) calls parseWorkspacePath(pathname) and, when a workspace segment is found, sets the x-workspace-slug request header and rewrites the URL to strip the /w/<slug>/ segment. The rewritten path (/<rest>) is what the page router and RSCs see — existing pages need no changes to support workspace context.
Runtime read coherence
The URL pins workspace context; the runtime consumes it implicitly. The PostgREST db-pre-request hook (private.set_tenant_context) reads the x-workspace-slug header on every Data API request, validates membership against workspace_memberships (or default-workspace status), and pins the transaction-local app.workspace_id GUC. RLS on the 10 scope-aware tables uses the canonical equality form:
(workspace_id IS NULL OR workspace_id = get_active_workspace_id())This means any reader on the auth client (createClient()) automatically sees the right tier — tenant defaults plus the active workspace's overrides — without an explicit filter. The hook fires only for anon / authenticated roles, so admin / impersonated callers (which use service_role) keep their intent-explicit filters; see documents/work/2026-04-29-workspaces-runtime-coherence/audit-client-classes.md for the per-reader audit.
For callers that prefer single-row resolution at the SQL boundary, get_resolved_setting(p_key text) returns the most-specific tenant_settings.value visible under the active GUCs. It mirrors pickResolvedRow() semantics and is supported by the composite index idx_tenant_settings_tenant_key_workspace. getSettingsForKey() deliberately does NOT use this RPC — it already does 4-tier resolution in TS with per-tier unstable_cache (1h TTL, workspace-tagged), and a DB round trip per request would regress the cache.
URL discipline:
- Client links:
<ScopedLink>from@/features/tenant/components/scoped-link— callsuseScopedHref()to preserve the active/w/<workspaceSlug>/prefix on all<a>clicks. Mandatory for all in-app navigation. - Server
redirect():getScopedHref(path)from@/features/tenant/lib/get-scoped-hrefreads URL-pinned headers and returns the canonical scoped path. - Workspace root (
/t/<t>/w/<w>/):app/page.tsxresolves the workspace'slandingRouteand redirects under the workspace prefix. Loop guard treatslandingRoute = "/"as/dashboard. - An ESLint rule (
no-restricted-imports) flags rawnext/linkimports outside the public surfaces (app/(auth)/,app/embed/,app/share/,app/present/, tests) so future code stays scoped by default.
Active Workspace Resolution
Server data access still resolves workspace context from middleware headers. getActiveWorkspace() in features/workspaces/context.ts resolves the active workspace for the current request using React.cache() for per-request deduplication:
- Read the
x-workspace-slugheader (set by middleware). - If present, call
getWorkspaceBySlug(slug)— returns null if the user has no visibility. - Fall back to
getDefaultWorkspace()— so non-workspace pages still get default workspace context.
App-shell chrome also derives the active workspace from usePathname() via resolveActiveWorkspaceForPath(). This client-side derivation is required because the (app) layout persists across same-route App Router navigation: switching from /t/acme/w/home/dashboard to /t/acme/w/marketing/dashboard does not remount the server layout. The client shell uses the URL-derived workspace to update the rail selection, workspace pill, accent variables, mobile trigger, sidebar nav overlay, and last-visited beacon.
Scoped links use buildWorkspaceScopedHref() through the standard useScopedHref() hook, so clicking a regular app link from a workspace path preserves the active /w/<workspace> segment. Middleware also forwards the browser-visible pathname to tenant scope before rewriting; client hooks use that value for the first hydrated render, then switch to live usePathname() updates after mount.
Accent CSS Variable
<WorkspaceAccentStyle> renders a <style> tag with the active workspace accent. The app shell re-renders it from the client-derived active workspace so soft navigation updates the variables without requiring a full page load:
:root {
--workspace-accent: <fg value>;
--workspace-accent-bg: <bg value>;
}These variables are intentionally chrome-level tokens. The active rail icon also uses the accent directly from the active workspace record for its strip and tile styling.
Sidebar Workspace + Tenant Header
<WorkspaceTenantHeader> is the unified identity block at the top of the sidebar. It replaces the previous two-stack arrangement (tenant logo + tenant name on top, a separate WorkspacePill underneath).
When a workspace is active: shows the workspace name as the primary label and the tenant name as a smaller subtitle. Clicking the header opens the workspace switcher.
When no workspace is active: shows the tenant name and logo only — identical to the previous single-tenant header.
The component is in features/workspaces/components/workspace-tenant-header.tsx. The now-dead WorkspacePill export has been removed from workspace-switcher.tsx.
Workspace Rail
<WorkspaceRail> renders when the user has access to 2 or more workspaces. Each workspace appears as an icon tile in a vertical strip on the left edge of the sidebar. The active workspace tile is highlighted from that workspace's accent token. Clicking a tile navigates to workspaceUrl(tenantSlug, workspaceSlug, activeWorkspace.landingRoute).
Workspace Navigation Overlay
Workspace rows can carry a small navOverlay object using the same NavConfigOverride schema as tenant- and user-level overrides — one merge pipeline, one editor surface, one storage shape across all five layers (platform → role → tenant → user → workspace).
The overlay is applied client-side by <AppSidebar> via resolveWorkspaceNavConfig() (a thin wrapper around the unified resolveNavConfig). It runs against the URL-derived active workspace so soft navigation between /t/<t>/w/<a>/dashboard and /t/<t>/w/<b>/dashboard (both rewrite to the same /dashboard route segment) updates the sidebar without a server round-trip. The shared (app) layout therefore calls getResolvedNavConfig(reqs, applyWorkspaceOverlay = false) to avoid a stale double-apply on top of the client-side layer.
Supported behaviours match NavConfigOverride:
- A node entry with
positionpins or reorders an item. - A node entry with
hidden: trueremoves a node (recursively) from the resolved tree. - A node entry can override
label,icon, orconfigfor an existing id. - Setting
replace: trueat the overlay root discards the merged tenant rail and starts fresh with this overlay'snodes(or empty). Subsequent layers continue to merge by id. Used by the function workspace templates (marketing,sales,strategy,research,operations) to ship a focused mini-app rail rather than a delta on top of the tenant rail. Default (unset / false) preserves the original merge-by-id semantics.
Editing the overlay
/admin/workspaces/[id] mounts <WorkspaceNavOverlayEditor> (features/workspaces/components/admin/workspace-nav-overlay-editor.tsx). Workspace admins can reorder, rename, hide, or add nodes; flip replace: true for a curated mini-app rail; and set the pinnedRecord (entity-type slug + URL parameter) without re-running seed scripts.
The editor reuses <NavTreeEditor> from features/navigation/components/nav-tree-editor.tsx — the same controlled visual + JSON tabbed UI that powers the tenant /admin/navigation page — and persists changes through the existing PATCH /api/workspaces/:id endpoint via updateWorkspaceAction. There is no separate persistence channel; the schema (WorkspaceUpdateInputSchema.navOverlay) is the only validator.
When the caller lacks workspaces.team.manage, the editor renders read-only with a banner; the page-level guard already redirects unauthenticated tenant-admins, so this is defensive only.
Last-Visited Beacon
<SetLastVisitedClient> is mounted from the app shell with the client-derived active workspace id. It fires a POST /api/workspaces/last-visited request whenever that id changes, including soft navigation between workspace URLs. The API route calls setLastVisitedWorkspace(), which does a single UPDATE user_tenants SET last_visited_workspace_id = ? WHERE tenant_id = ? AND last_visited_workspace_id IS DISTINCT FROM ?. The IS DISTINCT FROM guard short-circuits the write when nothing changed.
Query Cache Invalidation
<WorkspaceQueryInvalidator> is mounted from the same client-derived active workspace id. When soft navigation changes that id, it invalidates React Query caches that live above workspace chrome so dashboard/sidebar data cannot linger from the previous workspace.
Tenant-Create Hook
When a new tenant is provisioned, the tenant-create hook calls seedDefaultWorkspaceForTenant() to insert a default is_default = true workspace named after the tenant and auto-enroll all existing user_tenants members. This is idempotent — re-calling it returns the existing workspace if one already exists.
Create Blank Workspace
/admin/workspaces/new is a dedicated page for creating a workspace from scratch (no template). It renders WorkspaceCreateForm — a form with name, slug (auto-derived from name via slugify(), editable), description, icon, letter, accent, and landing route fields. On submit it calls createWorkspaceAction() and redirects to the workspace-scoped admin detail page (/t/<tenant>/w/<workspace>/admin/workspaces/<id>) via useScopedHref.
The slug <input pattern> attribute uses ^[a-z0-9][a-z0-9\-]*$ — the hyphen is escaped (\-) so Chrome's /v regex mode treats it as a literal rather than a range character.
Install Gallery
The install gallery (/admin/workspaces/gallery) lists all WorkspaceTemplate entries returned by listInstallableTemplates(). For each template, it shows whether it is already installed in the tenant (queried via listInstalledTemplateSlugs()). Installing calls installWorkspaceFromTemplate(), which is idempotent — if a workspace with the same source_template_slug already exists, it returns the existing one.
API Reference
Context helpers
| Function | Location | What it does |
|---|---|---|
getActiveWorkspaceSlug() | features/workspaces/context.ts | Read x-workspace-slug header; returns null outside workspace-scoped pages |
getActiveWorkspace() | features/workspaces/context.ts | Resolve full Workspace for the request; falls back to default workspace |
resolveActiveWorkspaceForPath() | features/workspaces/lib/url.ts | Client-shell helper: derive active workspace from browser pathname and visible workspaces |
buildWorkspaceScopedHref() | features/workspaces/lib/url.ts | Preserve the active /w/<workspace> segment for relative app links |
workspaceNavOverlayToOverride() | features/workspaces/lib/nav-overlay.ts | Convert a workspace navOverlay into a navigation override |
Server queries
All in features/workspaces/server/queries.ts. All are "use server" and use React.cache() for per-request deduplication.
| Function | What it returns |
|---|---|
listWorkspacesForCurrentUser() | All workspaces visible to the caller (RLS-filtered) |
getWorkspaceBySlug(slug) | Single workspace by slug; null if not visible |
getDefaultWorkspace() | The is_default = true workspace for the active tenant |
listWorkspacesWithMemberCounts() | Admin view — all workspaces with member counts and isMember flag for the current user |
listWorkspaceMembers(workspaceId) | Members of a specific workspace with profile data |
listInstalledTemplateSlugs() | source_template_slug values of all installed workspaces |
Server actions
All in features/workspaces/server/actions.ts. All require the workspaces.team.manage permission except setLastVisitedWorkspace.
| Function | Permission | What it does |
|---|---|---|
createWorkspaceAction(input) | workspaces.team.manage | Create a workspace; auto-adds creator as a member |
updateWorkspaceAction(id, input) | workspaces.team.manage | Partial update by workspace ID |
deleteWorkspaceAction(id) | workspaces.team.manage | Delete; rejects if is_default = true |
addWorkspaceMemberAction({workspaceId, userId}) | workspaces.team.manage | Add a tenant member to a workspace |
removeWorkspaceMemberAction({workspaceId, userId}) | workspaces.team.manage | Remove a member |
assignWorkspaceRole({workspaceId, userId, roleId}) | workspaces.team.manage | Assign a role to a workspace member. Anti-escalation guard: caller cannot assign a role above their own level. |
setLastVisitedWorkspace({workspaceId}) | none | Update user_tenants.last_visited_workspace_id as a UX hint |
Install actions
In features/workspaces/server/install.ts.
| Function | What it does |
|---|---|
installWorkspaceFromTemplate(input) | Install a code-defined template; idempotent — returns existing workspace if already installed |
seedDefaultWorkspaceForTenant({tenantId, tenantName, createdBy}) | Called by tenant-create hook; idempotent |
listInstallableTemplates() | All WorkspaceTemplate entries |
URL helpers
In features/workspaces/lib/url.ts.
// Build a workspace-scoped URL
workspaceUrl(tenantSlug, workspaceSlug, path): string
// → "/t/acme/w/marketing/dashboard"
// Parse workspace path segments (used by middleware)
parseWorkspacePath(pathname): { workspaceSlug: string; rest: string } | nullAccent helpers
In features/workspaces/accents.ts.
getWorkspaceAccent(slug): WorkspaceAccent // Returns slate if slug is null/unknown
isWorkspaceAccentSlug(slug): boolean
WORKSPACE_ACCENTS // Full palette map
WORKSPACE_ACCENT_SLUGS // Array of slug keysTemplate helpers
In features/workspaces/templates.ts.
findWorkspaceTemplate(slug): WorkspaceTemplate | undefined
listFeaturedTemplates(): WorkspaceTemplate[] // Templates with featured !== falseAPI routes
| Method | Route | What it does |
|---|---|---|
GET | /api/workspaces | List workspaces for the current user |
POST | /api/workspaces | Create a workspace |
PATCH | /api/workspaces/[id] | Update a workspace |
DELETE | /api/workspaces/[id] | Delete a workspace |
GET | /api/workspaces/[id]/members | List members |
POST | /api/workspaces/[id]/members | Add a member |
DELETE | /api/workspaces/[id]/members/[userId] | Remove a member |
POST | /api/workspaces/[id]/members/[userId]/role | Assign a role to a member (anti-escalation: caller cannot grant above own) |
POST | /api/workspaces/last-visited | Record last-visited workspace (UX hint) |
Zod schemas
| Export | Use |
|---|---|
WorkspaceCreateInputSchema | Validate workspace creation payload |
WorkspaceUpdateInputSchema | Partial variant for updates; omitted keys mean "leave unchanged" (no .default() resets) |
WorkspaceNavOverlaySchema | Alias for NavConfigOverrideSchema — validates the nav_overlay JSONB column |
4-Tier Agent Context Resolver
getResolvedAgentContext(tenantId, workspaceId?) from features/context/server/get-resolved-agent-context.ts merges the agent_context JSONB setting across all four tiers:
- Platform — default empty
{} - Tenant — tenant-scoped
tenant_settingsrow withkey = 'agent_context'andworkspace_id IS NULL - Workspace — workspace-scoped row with
workspace_id = activeWorkspaceId - User — user-scoped row (future; reserved)
Merge semantics:
- Object keys — most-specific tier wins per key. A workspace override for
"persona"does not erase the tenant-level"industry"key. - Array values — most-specific tier replaces wholesale (no concat). This keeps agent prompts deterministic and avoids cross-tier prompt bloat.
The resolver is wired into the chat route (/api/chat) and the inbox agent caller so all supervised agent invocations automatically receive workspace-scoped business context.
Workspace Edit Page Shell
/admin/workspaces/[id] is intentionally minimal: identity fields + members drawer + <WorkspaceConfigureCards /> deep-link grid + publish stub + danger zone. It does not inline config editors for scope-aware surfaces (agents, webhooks, views, etc.) — those live at the workspace-scoped admin URLs (/t/<slug>/w/<ws>/admin/<surface>) linked from the cards. This enforces the single-editor rule: config always edits at its target scope.
Delete Confirmation Cascade Counts
The workspace delete confirmation modal queries each scope-aware table for rows that will cascade-delete when the workspace is removed. It also explicitly clarifies which tables use ON DELETE SET NULL (chats, criteria_sets) so users know those items are preserved but detached, not destroyed.
custom_pages Table (Stub)
custom_pages is the tenth scope-aware table. It holds workspace-aware static pages. A tenant-default and a workspace-scoped row can share the same slug — the route stub prefers the workspace-scoped row inside a workspace URL (per ADR-0013 D7). The runtime column is 'static' for all v1 rows; the sandbox renderer is a deferred follow-up.
Schema shape:
interface CustomPage {
id: string;
tenantId: string;
workspaceId: string | null; // null = tenant default
slug: string;
title: string;
source: Record<string, unknown>; // page content — format is runtime-dependent
runtime: "static"; // v1 only; future: "sandbox" | "mdx"
createdBy: string | null;
createdAt: string;
updatedAt: string;
}For Agents
Workspaces are not yet an AI tool surface. No agent tools exist for workspace CRUD in this release.
Agents that need workspace context can call getActiveWorkspace() server-side to read the current workspace's defaultAgentSlug, featuredEntityTypeSlugs, and navOverlay. The navOverlay is now a NavConfigOverride and is actively applied as the final layer in the sidebar resolution pipeline — changes to it render immediately.
getResolvedAgentContext() is called automatically in supervised (chat) and inbox execution flows — agents receive workspace-scoped business context without requiring any extra tool calls.
Curated workspace nav (nav_overlay)
Workspace-scoped nav lives on workspaces.nav_overlay JSONB — not in a separate nav_configs row. The overlay is the final layer in the sidebar resolution pipeline; any view, divider, or pinned-record affordance you put on it appears in the rail without code changes.
The overlay is a NavConfigOverride with an optional pinnedRecord extension:
type NavConfigOverride = {
primary?: NavItem[]; // friendly view list shown first in the rail
pinnedRecord?: {
entityTypeSlug: string; // e.g. "patient"
urlParam: string; // e.g. "patient" → ?patient=<id>
};
};Seed a curated nav by writing the overlay directly:
await supabase
.from("workspaces")
.update({
nav_overlay: {
primary: [
{
kind: "view",
view_slug: "todays-visits",
label: "Today's Visits",
icon: "calendar-days",
},
{
kind: "view",
view_slug: "ops-schedule",
label: "Schedule",
icon: "calendar",
},
// ...
],
pinnedRecord: { entityTypeSlug: "patient", urlParam: "patient" },
},
})
.eq("id", workspaceId);This is the first-class affordance for non-technical users — the rail reads like a clinic dashboard, not a database admin. Entity type slugs never appear in nav directly.
Pinned record context
When a workspace declares a pinnedRecord in its overlay, the workspace shell mounts a <PinnedRecordPicker> and every list block in that workspace filters to the selected record automatically. The picker is generic over entityTypeSlug — workspaces can pin any record type (patient, client, engagement, …) and the labels follow via humanize(entityTypeSlug).
The flow:
- User opens
/t/<tenant>/w/<workspace>/.... Server readscurrentWorkspace.navOverlay.pinnedRecord(which survives the row-mapper read because it's parsed withWorkspaceNavOverlaySchema, not the baseNavConfigOverrideSchema). - The sidebar header mounts
<PinnedRecordPicker>wheneverpinnedRecord.entityTypeSlugis non-null. No hardcoded slug check — the picker renders for any pinned record type the workspace declares. - User picks a record → URL updates to
?<urlParam>=<id>viarouter.replace(preserves back-button history; the param name comes frompinnedRecord.urlParam, not a hardcoded constant). useFilteredEntities()reads the URL param viausePinnedRecord()and:- UUID-validates the param value (defense against crafted strings).
- Filters
id = ?paramwhen rendering the pinned entity type itself. - Filters
<entityTypeSlug>_id = ?paramwhen rendering related entity types — but only when the rendered type'sjson_schema.propertiesactually declares that FK column. Without this guard, pinning a patient in a workspace that also showsprotocolortherapy-planwould silently zero out those views. - Hyphens in the entity-type slug are normalized to underscores in the FK column name (
therapy-plan→therapy_plan_id) so hyphenated slugs can't produce invalid SQL identifiers. - Includes the URL value in the React Query cache key so switching
?patient=A → ?patient=Binvalidates the cache cleanly.
- Stale URL IDs (record was deleted or isn't visible to the user) trigger a one-shot toast and clear the param — the picker never silently shows "Select X" with a dead reference in the URL.
- Pinned-record detail layouts can use
key={pinnedRecordId}so form state remounts on switch — no stale-cache leaks across record context.
Server-to-client plumbing:
// app/(app)/t/[tenant]/w/[workspace]/layout.tsx (or sidebar mount point)
const currentWorkspace = await getActiveWorkspace();
return (
<PinnedRecordProvider
pinnedRecord={currentWorkspace?.navOverlay?.pinnedRecord ?? null}
>
{children}
</PinnedRecordProvider>
);// any block consuming useFilteredEntities() automatically participates
const { data } = useFilteredEntities({ entityTypeSlug: "visit" });
// → filters to visits where patient_id = ?patientThe picker is the only new component on the platform side — list blocks need zero changes.
The hooks (usePinnedRecord, usePinnedRecordId, useSetPinnedRecord) live in features/workspaces/lib/pinned-record-context.ts; the picker UI in features/workspaces/components/pinned-record-picker.tsx.
Design Decisions
URL path segment, not query parameter
Workspace slug is encoded as /w/<slug>/ in the URL path rather than ?workspace=<slug>. This keeps deep-links bookmarkable, prevents the workspace from being silently lost on navigation, and lets middleware resolve the workspace before the React tree renders — the same pattern used for tenant slugs. The rewrite strips /w/<slug>/ so existing page routes require no changes.
Membership = visibility, not data access
Adding a user to a workspace makes that workspace appear in their rail and switcher. It does not narrow what data they can read or write — RLS still enforces tenant-level isolation. This keeps the permission model simple: one workspaces.team.manage gate for workspace administration, and the existing 63-permission RBAC for data operations. Workspace-scoped data filters are a follow-up concern (boundEntityId / boundEntityFilter fields are persisted for future use but not yet applied by queries).
Scope-aware admin overrides (ADR-0013)
All ten scope-aware tables (agents, agent_connections, webhook_endpoints, inbound_webhook_endpoints, external_data_sources, views, criteria_sets, chats, tenant_settings, custom_pages) carry a nullable workspace_id column and resolve via the canonical 4-tier resolver: user > workspace > tenant > platform. Most-specific tier wins for single-row resolution; list pages render every visible tier with an "Inherited from {tier}" chip via <InheritanceBadge />. A workspace-scoped admin URL (/t/<t>/w/<ws>/admin/<surface>) reuses the same admin pages as the tenant URL (/t/<t>/admin/<surface>) — the page reads ?scope= to filter the list and shows an <OverrideInWorkspaceButton /> on tenant rows. Override clones regenerate secrets per-resource (agent_connections.encrypted_credentials is force-NULLed; webhook_endpoints.secret, inbound_webhook_endpoints.token + signature_secret, external_data_sources.token + signature_secret are auto-regenerated) so a workspace admin never inherits a tenant credential. All ten surfaces ship in PR #997 (consolidated): surfaces 1–4 (agents, agent_connections, webhook_endpoints, external_data_sources) in PR 3a; surfaces 5–7 (views, criteria_sets, tenant_settings) in PR 3b; surface 8 (custom_pages stub) in PR 4.
Override handlers for tenant_settings apply a per-key allowlist. In v1, only agent_context is allowlisted — any attempt to override an unknown key is rejected by default. This guards against misconfigured overrides silently clobbering tenant defaults.
The inheritance staleness banner is integrated into /admin/agents/[id]: when a tenant admin is editing a tenant-scoped agent that already has workspace overrides, the banner surfaces the count and links to the audit sub-page at /t/<slug>/admin/agents/workspace-scoped.
Code-defined templates, not DB rows
Workspace templates are TypeScript constants in features/workspaces/templates.ts. The install action is the only write path. This avoids a separate workspace_templates table, a migration every time a template changes, and the complexity of syncing template updates to existing installations. Templates stay in code where they are easy to version, review, and extend. The source_template_slug column on workspaces tracks which template was used, enabling gallery "Installed" status.
WorkspaceNavOverlay is an alias for NavConfigOverride, not its own schema
The original WorkspaceNavOverlay type had a bespoke showGroups / hideItems / pinnedItems shape. That shape was never consumed by getResolvedNavConfig() — the overlay was stored but never rendered. Unifying onto NavConfigOverride (the same shape used by tenant and user overrides in nav_configs) means: one merge pipeline, one Zod schema, one editor surface. The workspaces.nav_overlay JSONB column is now safe-parsed by row-mapper.ts using NavConfigOverrideSchema; invalid or null values reset to {}. The old bespoke shape is fully superseded — no DB migration was required because the column was effectively unused.
Accent CSS variable scoped to subtree
The --ws-accent-fg / --ws-accent-bg CSS variables are injected by a server component rather than a global document.body class or a JS context value. Scoping to the subtree means the variable resolves correctly when switching workspaces in a new tab without requiring a client-side update, and eliminates the flash-of-wrong-color on page load. Only four design tokens consume these variables — the surface area is intentionally small.
seedDefaultWorkspaceForTenant bypasses workspaces.team.manage
The seed function runs during tenant provisioning, before the creating user's role grants exist. It uses the admin client directly and is called only from the tenant-create hook — not exposed as a user action. The idempotency guard (WHERE is_default = true) makes it safe to call multiple times.
custom_pages.runtime is a text column, not an enum
Future runtimes (sandbox, mdx, react) can be added without a migration. The static value is the only one the renderer handles in v1. A DB CHECK constraint is intentionally deferred so development iteration on runtimes does not require migration churn; a constraint can be added once the set stabilizes.
tenant_settings override allowlist defaults to reject
The override handler for tenant_settings requires each overriddable key to be explicitly allowlisted. In v1, only agent_context is allowlisted. Unknown keys return a 400 error rather than silently creating an override that might shadow an unrelated tenant setting. This is conservative by design; new keys are added to the allowlist per-PR as workspace-scoped use cases are validated.
assignWorkspaceRole anti-escalation is caller-bound, not role-bound
The anti-escalation check compares the caller's highest-privilege membership role (across tenant and all workspace memberships) with the target role. A user with editor at the tenant level cannot grant tenant_admin to a workspace member, even if the workspace-scoped page technically allows them to manage that workspace. This prevents privilege escalation via workspace membership promotion.
Object-key merge vs. array-replace for agent_context
getResolvedAgentContext merges object keys (most-specific tier wins per key) but replaces arrays wholesale. The asymmetry is intentional: key-level merging lets workspace teams override individual fields (e.g., persona) without losing tenant-level fields (e.g., industry). Array replacement prevents workspace-level prohibitions or instructions from being silently concatenated with tenant-level ones, which could produce contradictory or oversized prompts.
Two-column ownership on agents and views (ADR-0013 D14)
agents and views carry two distinct FK columns that must not be conflated:
installed_by_workspace_id— bundle provenance. Set only byinstall_bundle(), never edited at runtime. Used byuninstall_bundle()to identify which rows belong to a bundle and should be removed. Immutable after install.workspace_id— runtime config scope. Set by the scoped-tenants model. Edited via "Override in this workspace". Used by the resolver and admin list UI. Nullable = tenant-wide.
A row can have one, both, or neither set independently. The override clone handler sets workspace_id on the clone but explicitly clears all three bundle provenance columns (installed_by_workspace_id, installed_by_bundle, bundle_version). Clearing only installed_by_workspace_id would leave the clone matched by uninstall_bundle()'s installed_by_bundle sweep predicate, silently destroying the workspace-specific override when the source bundle is later uninstalled. Both columns carry COMMENT ON COLUMN annotations in the migration explaining the ownership contract.
Related Modules
- Multi-Tenant — tenant resolution, URL-as-truth,
getTenantContext() - Navigation — sidebar config that workspace nav overlay is merged into
- Auth & Permissions —
workspaces.team.managepermission,requirePermission() - Admin — admin pages at
/admin/workspaces
Navigation
Config-driven sidebar powered by a recursive NavNode tree — supports arbitrary nesting, style presets, and a menu block type for embedding nav trees in views.
Multi-Tenant Workspaces
Create and switch between isolated organizations — each with their own data, members, roles, and agents. The URL is the sole source of truth for the active tenant, giving per-tab and per-device independence for free.