Sprinter Docs

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).

SlugNameForeground
slateSlateoklch(0.42 0.02 260) — near-neutral blue-gray (default)
navyNavyoklch(0.35 0.05 260) — deep navy (matches --primary)
marigoldMarigoldoklch(0.55 0.14 65) — warm amber
mossMossoklch(0.5 0.12 145) — forest green
emberEmberoklch(0.55 0.18 25) — burnt orange
lagoonLagoonoklch(0.52 0.11 200) — teal
irisIrisoklch(0.52 0.14 295) — violet
roseRoseoklch(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 path

Middleware (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 — calls useScopedHref() 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-href reads URL-pinned headers and returns the canonical scoped path.
  • Workspace root (/t/<t>/w/<w>/): app/page.tsx resolves the workspace's landingRoute and redirects under the workspace prefix. Loop guard treats landingRoute = "/" as /dashboard.
  • An ESLint rule (no-restricted-imports) flags raw next/link imports 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:

  1. Read the x-workspace-slug header (set by middleware).
  2. If present, call getWorkspaceBySlug(slug) — returns null if the user has no visibility.
  3. 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.

<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 position pins or reorders an item.
  • A node entry with hidden: true removes a node (recursively) from the resolved tree.
  • A node entry can override label, icon, or config for an existing id.
  • Setting replace: true at the overlay root discards the merged tenant rail and starts fresh with this overlay's nodes (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.

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

FunctionLocationWhat it does
getActiveWorkspaceSlug()features/workspaces/context.tsRead x-workspace-slug header; returns null outside workspace-scoped pages
getActiveWorkspace()features/workspaces/context.tsResolve full Workspace for the request; falls back to default workspace
resolveActiveWorkspaceForPath()features/workspaces/lib/url.tsClient-shell helper: derive active workspace from browser pathname and visible workspaces
buildWorkspaceScopedHref()features/workspaces/lib/url.tsPreserve the active /w/<workspace> segment for relative app links
workspaceNavOverlayToOverride()features/workspaces/lib/nav-overlay.tsConvert 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.

FunctionWhat 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.

FunctionPermissionWhat it does
createWorkspaceAction(input)workspaces.team.manageCreate a workspace; auto-adds creator as a member
updateWorkspaceAction(id, input)workspaces.team.managePartial update by workspace ID
deleteWorkspaceAction(id)workspaces.team.manageDelete; rejects if is_default = true
addWorkspaceMemberAction({workspaceId, userId})workspaces.team.manageAdd a tenant member to a workspace
removeWorkspaceMemberAction({workspaceId, userId})workspaces.team.manageRemove a member
assignWorkspaceRole({workspaceId, userId, roleId})workspaces.team.manageAssign a role to a workspace member. Anti-escalation guard: caller cannot assign a role above their own level.
setLastVisitedWorkspace({workspaceId})noneUpdate user_tenants.last_visited_workspace_id as a UX hint

Install actions

In features/workspaces/server/install.ts.

FunctionWhat 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 } | null

Accent 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 keys

Template helpers

In features/workspaces/templates.ts.

findWorkspaceTemplate(slug): WorkspaceTemplate | undefined
listFeaturedTemplates(): WorkspaceTemplate[]   // Templates with featured !== false

API routes

MethodRouteWhat it does
GET/api/workspacesList workspaces for the current user
POST/api/workspacesCreate a workspace
PATCH/api/workspaces/[id]Update a workspace
DELETE/api/workspaces/[id]Delete a workspace
GET/api/workspaces/[id]/membersList members
POST/api/workspaces/[id]/membersAdd a member
DELETE/api/workspaces/[id]/members/[userId]Remove a member
POST/api/workspaces/[id]/members/[userId]/roleAssign a role to a member (anti-escalation: caller cannot grant above own)
POST/api/workspaces/last-visitedRecord last-visited workspace (UX hint)

Zod schemas

ExportUse
WorkspaceCreateInputSchemaValidate workspace creation payload
WorkspaceUpdateInputSchemaPartial variant for updates; omitted keys mean "leave unchanged" (no .default() resets)
WorkspaceNavOverlaySchemaAlias 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:

  1. Platform — default empty {}
  2. Tenant — tenant-scoped tenant_settings row with key = 'agent_context' and workspace_id IS NULL
  3. Workspace — workspace-scoped row with workspace_id = activeWorkspaceId
  4. 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:

  1. User opens /t/<tenant>/w/<workspace>/.... Server reads currentWorkspace.navOverlay.pinnedRecord (which survives the row-mapper read because it's parsed with WorkspaceNavOverlaySchema, not the base NavConfigOverrideSchema).
  2. The sidebar header mounts <PinnedRecordPicker> whenever pinnedRecord.entityTypeSlug is non-null. No hardcoded slug check — the picker renders for any pinned record type the workspace declares.
  3. User picks a record → URL updates to ?<urlParam>=<id> via router.replace (preserves back-button history; the param name comes from pinnedRecord.urlParam, not a hardcoded constant).
  4. useFilteredEntities() reads the URL param via usePinnedRecord() and:
    • UUID-validates the param value (defense against crafted strings).
    • Filters id = ?param when rendering the pinned entity type itself.
    • Filters <entityTypeSlug>_id = ?param when rendering related entity types — but only when the rendered type's json_schema.properties actually declares that FK column. Without this guard, pinning a patient in a workspace that also shows protocol or therapy-plan would silently zero out those views.
    • Hyphens in the entity-type slug are normalized to underscores in the FK column name (therapy-plantherapy_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=B invalidates the cache cleanly.
  5. 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.
  6. 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 = ?patient

The 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 by install_bundle(), never edited at runtime. Used by uninstall_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.

  • Multi-Tenant — tenant resolution, URL-as-truth, getTenantContext()
  • Navigation — sidebar config that workspace nav overlay is merged into
  • Auth & Permissionsworkspaces.team.manage permission, requirePermission()
  • Admin — admin pages at /admin/workspaces

On this page