Net-new / profile: observability-saas — designed for the new project. The pattern is extracted from GlassFlow's first product (a Preact/MobX app in a separate repo), not from the single-tenant source app the rest of this pack is drawn from. See Source lineage.
Profile applicability. This doc decides product questions — tenancy boundary, entity hierarchy, roles, URL scheme, onboarding — for the workspace-as-tenant SaaS profile. If your product's tenancy model differs (single-tenant, org-as-tenant, personal accounts), delete this doc at seed time and skip the
tenant-scopeCI gate; the tenancy model should come from your product process (e.g. product-dev-osproduct:model), not from this pack.
The source app this pack is extracted from (the ClickHouse ETL UI) was
single-tenant: one operator, one backend, no concept of "whose data is this."
./16-multitenant-performance.md covers the
performance and isolation consequences of that assumption breaking. This doc
covers the layer underneath it — the tenancy model itself: what the tenant,
the workspace, and the agent are, how a user is bound to them, and how the
active tenant travels through the app. Doc 16 is how a tenant-scoped view stays
fast and can't leak; doc 17 is what "a tenant" is and where its identifier
lives.
The model is not invented here. GlassFlow's first product already shipped a working multi-tenant frontend, and its shape is the lineage: a user belonged to many organizations (the tenant), each organization held spaces (groupings), and each space held pipelines (the resources). That worked, and the parts that worked are kept. But it also made one decision that is the origin of a whole class of bugs, and this doc exists mostly to correct it.
The correction. In the first product the active tenant was an ambient
value: localStorage["activeOrganization"], read inline at every single call
site and passed as an organization_id query parameter. That has four
failure modes, and a multi-tenant AI-observability product hits all of them:
- A forgotten scope is a cross-tenant leak. Nothing structural forces a
query to be scoped — the
organization_idparam wasrequired: falsein the spec. One call site that forgets it returns the wrong tenant's data. In a hosted product that's not a bug, it's an incident (the same escalation doc 16 describes for a missingWHERE tenant_id). - URLs aren't shareable. Two operators can't send each other a link to "the same view of the same tenant," because the tenant isn't in the link — it's in one browser's localStorage.
- No two tenants at once. Two browser tabs share one localStorage, so a user can't watch workspace A in one tab and workspace B in another. The second tab silently reinterprets the first tab's data.
- Switching is a global reset. Because the active tenant lived outside the
view tree, changing it meant a
reactionthat reset every store and refetched — brittle, and easy to leave a stale slice behind.
The fix is to make the tenant identifier a required path segment and a required argument, never an ambient default. The URL becomes the single source of truth for "which tenant am I looking at," which dissolves all four failure modes at once: the scope can't be forgotten (a route can't render without it), links carry it, tabs are independent, and switching is ordinary navigation.
The reading of "tenant." For this product the workspace is the tenant —
the isolation, billing, and invitation boundary. A workspace carries a nullable
organizationId so an enterprise org/billing umbrella can be added later
without a data migration, but nothing reads that field today and no org UI is
built (see the YAGNI rule below). This keeps the term consistent with
./16-multitenant-performance.md: its
tenantId is this doc's workspaceId — the same value, named for what it is
here.
The hierarchy, and the one relationship that is many-to-many:
[Organization] nullable umbrella — a field, not a feature (forward-compat)
└─ Workspace THE TENANT: isolation + billing + invite root
└─ Agent the observed AI agent; belongs to exactly one workspace
User ⇄ Workspace via Membership { userId, workspaceId, role }
role ∈ 'admin' | 'member' | 'viewer'
one user, many workspaces, a possibly-different role in each
How the tenant identifier travels on a request — it enters at the URL and is re-asserted at every layer down to the enforcement seam, never defaulted:
URL /w/[workspaceId]/agents ← single source of truth
│
▼
WorkspaceProvider (src/components/providers) ← reads the route param, exposes
│ { workspaceId, workspace, role, can() }
▼
Component calls a service with workspaceId, never "current" or "all"
│
▼
Service listAgents(workspaceId, …) ← workspaceId is a REQUIRED arg
│ (the { signal }-taking shape of ./03-service-layer.md)
▼
Proxy route app/ui-api/w/[workspaceId]/… ← THE ENFORCEMENT SEAM:
│ verifies session user is a member of workspaceId AND has the
│ required role, THEN calls the backend
▼
Backend receives an already-authorized, already-scoped request
Two things are load-bearing in that diagram. First, workspaceId is present as
a value in the path, not a lookup — every layer receives it as an argument
rather than reading it from a shared place. Second, the proxy route is the
only place authorization is enforced; everything above it (the can() checks
in components) is UX, not security. Those two properties are what the rest of
this doc builds on, and what the Rules & gotchas protect.
This layers directly onto the two lifecycles in
./00-architecture-overview.md: the request
lifecycle gains a required workspaceId at the service and proxy hops, and the
hydration lifecycle keys its scoped slices by workspaceId so a switch
rehydrates instead of mutating in place.
Worked example: a workspace-scoped agent list at
/w/[workspaceId]/agents, plus switching workspaces and inviting a member. The
whole thing assumes the tenant identifier is always in the path.
-
Canonical types. One place defines the tenancy shape the whole frontend agrees on.
organizationIdis present and nullable — a forward-compat field, nothing more.// src/lib/tenancy.ts export type WorkspaceRole = 'admin' | 'member' | 'viewer' export interface Workspace { id: string name: string organizationId: string | null // forward-compat; unread today createdAt: string } export interface Membership { userId: string workspaceId: string role: WorkspaceRole } export interface Agent { id: string workspaceId: string // non-optional: an agent is always in one workspace name: string } // Central role→capability map. A capability that gates UI here MUST be // enforced by the proxy (see step 5) — never add one that isn't. const CAPABILITIES: Record<WorkspaceRole, ReadonlySet<string>> = { admin: new Set(['agent:read', 'agent:write', 'member:invite', 'member:remove']), member: new Set(['agent:read', 'agent:write']), viewer: new Set(['agent:read']), } export function can(role: WorkspaceRole, capability: string): boolean { return CAPABILITIES[role].has(capability) }
-
The route puts
workspaceIdin the path. The segment is required — the view cannot mount without it, so it can never be forgotten.app/w/[workspaceId]/agents/page.tsx ← agent list app/w/[workspaceId]/layout.tsx ← mounts WorkspaceProvider app/page.tsx ← bare "/" redirects to a workspace -
WorkspaceProviderreads the param and exposes the active tenant. This is a provider in the sense of./10-providers.md; it is the only thing that turns the URL segment into an in-memory active workspace, and it refuses to render a workspace the user isn't a member of.// src/components/providers/WorkspaceProvider.tsx 'use client' import { createContext, useContext } from 'react' import { notFound } from 'next/navigation' import { can, type Membership, type Workspace, type WorkspaceRole } from '@/src/lib/tenancy' interface WorkspaceContextValue { workspaceId: string workspace: Workspace role: WorkspaceRole can: (capability: string) => boolean } const WorkspaceContext = createContext<WorkspaceContextValue | null>(null) export function WorkspaceProvider({ workspaceId, workspace, memberships, children, }: { workspaceId: string workspace: Workspace memberships: Membership[] children: React.ReactNode }) { const membership = memberships.find((m) => m.workspaceId === workspaceId) // Not a member of the workspace in the URL → this route does not exist // for this user. The proxy will also reject the data calls (step 5); this // is the UX half of the same fact. if (!membership) notFound() const value: WorkspaceContextValue = { workspaceId, workspace, role: membership.role, can: (capability) => can(membership.role, capability), } return <WorkspaceContext.Provider value={value}>{children}</WorkspaceContext.Provider> } export function useWorkspace(): WorkspaceContextValue { const ctx = useContext(WorkspaceContext) if (!ctx) throw new Error('useWorkspace must be used inside WorkspaceProvider') return ctx }
-
The service method takes
workspaceIdas a required argument. This is the exact{ signal }-taking shape of./03-service-layer.mdand the required-tenant rule of./16-multitenant-performance.md— there is no overload withoutworkspaceId, and no "all workspaces" mode.// src/services/agent-service.ts export class AgentService { async listAgents( workspaceId: string, // required, first positional — impossible to omit { signal }: { signal?: AbortSignal } = {}, ): Promise<Agent[]> { const res = await fetch(`/ui-api/w/${workspaceId}/agents`, { signal }) if (!res.ok) throw new Error(`listAgents failed: ${res.status}`) return res.json() } } export const agentService = new AgentService()
-
The proxy route is where authorization actually happens. Following the proxy-route contract in
./02-proxy-routes.md, it reads the session (per./11-auth0-recipe.md), confirms the user is a member of the path'sworkspaceIdwith the required capability, and only then calls the backend. Everything above this line was UX; this line is the security boundary.// app/ui-api/w/[workspaceId]/agents/route.ts import { NextRequest, NextResponse } from 'next/server' import { getSessionSafely } from '@/src/lib/auth0' import { assertMembership } from '@/src/lib/authz.server' export async function GET( req: NextRequest, { params }: { params: Promise<{ workspaceId: string }> }, ) { const { workspaceId } = await params const session = await getSessionSafely() if (!session?.user) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 }) // Throws 403 if the user is not a member of workspaceId with 'agent:read'. // This is the enforcement the first product left to a query param the // client supplied — moved server-side, made non-optional. await assertMembership(session.user, workspaceId, 'agent:read') const agents = await callBackendListAgents(workspaceId) // backend host lives only here return NextResponse.json(agents) }
-
Switching a workspace is navigation, not a store reset. Because the active tenant is the URL, switching just changes the URL; the
[workspaceId]layout subtree remounts and its scoped slices rehydrate. There is no globalreset()and noreaction.// src/components/shared/WorkspaceSwitcher.tsx 'use client' import { useRouter } from 'next/navigation' import { useWorkspace } from '@/src/components/providers/WorkspaceProvider' import type { Membership } from '@/src/lib/tenancy' export function WorkspaceSwitcher({ memberships }: { memberships: Membership[] }) { const router = useRouter() const { workspaceId } = useWorkspace() return ( <select value={workspaceId} onChange={(e) => { // Remember the choice only as the default for the bare "/" entry — // NOT as the source of truth for the active tenant. document.cookie = `lastWorkspaceId=${e.target.value}; path=/; max-age=31536000` router.push(`/w/${e.target.value}/agents`) }} > {memberships.map((m) => ( <option key={m.workspaceId} value={m.workspaceId}> {m.workspaceId} </option> ))} </select> ) }
-
The bare
/picks a default, then hands off to the path. localStorage / cookie is used only here — to choose which workspace to land in — never to answer "which workspace am I in" once inside a/w/[workspaceId]route.// app/page.tsx import { redirect } from 'next/navigation' import { cookies } from 'next/headers' import { getSessionSafely } from '@/src/lib/auth0' import { getMembershipsForUser } from '@/src/lib/authz.server' export default async function RootPage() { const session = await getSessionSafely() if (!session?.user) redirect('/') const memberships = await getMembershipsForUser(session.user) if (memberships.length === 0) redirect('/onboarding') // no workspace yet const preferred = (await cookies()).get('lastWorkspaceId')?.value const target = memberships.find((m) => m.workspaceId === preferred)?.workspaceId ?? memberships[0].workspaceId redirect(`/w/${target}/agents`) }
-
Invitations and member roles are workspace-scoped. An invite is email + token bound to one
workspaceId; accepting it creates aMembershipwith a role the inviter chose. The invite proxy route reuses the sameassertMembership(..., 'member:invite')seam from step 5, so aviewerphysically cannot issue one — the button being hidden bycan()is only the UX half.// app/ui-api/w/[workspaceId]/invites/route.ts (POST) — enforcement excerpt await assertMembership(session.user, workspaceId, 'member:invite') await callBackendCreateInvite(workspaceId, { email, role }) // role ∈ WorkspaceRole
workspaceIdis a required path segment and a required argument — never an ambient default. Every tenant-scoped route lives under/w/[workspaceId]and every tenant-scoped service method takesworkspaceIdas a non-optional parameter. There is no "current workspace" global read inside a service and no "all workspaces" mode. This is the direct correction of the first product'slocalStorage["activeOrganization"]+ optionalorganization_idquery param, and it's the same required-tenant rule./16-multitenant-performance.mdenforces for performance — one rule, two payoffs (no leaks, no over-fetch). This is not left to review: thetenant-scopeCI gate in./15-architectural-guardrails.md(config:../seed/ci/eslint-tenant-scope.config.mjs) fails the build on a function that builds a/ui-api/w/...URL without aworkspaceIdparameter — the/w/path prefix is exactly the AST-visible signature that makes the rule mechanically enforceable.- The URL is the single source of truth for the active tenant; localStorage /
cookie only seeds the
/redirect. The moment code inside a/w/[workspaceId]route reads a stored "active workspace" to decide what to fetch, the four failure modes from What & why come back. Storage answers "where should a fresh visit land?" — never "which tenant is this view?" Thetenant-scopegate (see./15-architectural-guardrails.md) fails the build on anylocalStorage/sessionStorageread keyed by workspace/tenant/organization used to scope a request. - Switching a workspace is navigation, not a manual store reset. Change the
URL; let the
[workspaceId]subtree remount and rehydrate. Do not port the first product'sreaction(() => activeOrg, () => { reset(); refetch() })— a global reset is the brittle pattern this model removes. Scoped slices are keyed byworkspaceIdand hydrate through the core store per./05-zustand-slice-store.mdand./07-hydration-adapters.md. - Frontend role checks are UX; the proxy is the security boundary.
can()hides buttons and routes; it authorizes nothing. Every tenant-scoped proxy route callsassertMembership(user, workspaceId, capability)before touching the backend. If a capability gates UI but no proxy enforces it, you have rebuilt the first product'sSpace.permissionfield — a permission that existed in the model and was never checked. Every UI-gating capability must map to a proxy-enforced permission; if it can't, don't model it. Thetenant-scopegate (see./15-architectural-guardrails.md) fails the build on a proxy route underapp/ui-api/w/[workspaceId]/**whose handler never callsassertMembership— the mechanical floor under this rule, though it can only check that the assertion exists, not that the capability is the right one (that's a Layer 2 review concern). - One user-wide JWT; verify membership per request. Don't reach for Auth0
Organizations or org-scoped tokens. The first product used a single token
for all a user's tenants and verified membership server-side per call, and
that was the right call — it's simpler than minting per-tenant tokens and it
composes with the permissive-proxy posture in
./11-auth0-recipe.md. Membership is authoritative at the proxy (step 5), derived from the backend, not from a token claim the client could stale-cache. organizationIdis a nullable field, not a feature. It exists so an enterprise org umbrella (shared billing/SSO across several workspaces) can be added without a migration. Until an organization actually owns something (billing, SSO config, cross-workspace membership), build no org routes, no org switcher, and no org settings — a half-built org layer is worse than none, because it invites code to start branching on a boundary that isn't real yet.- Two tabs are two tenants — never share a mutable "active workspace"
singleton. Because the active tenant is the URL, a user viewing workspace A
in one tab and workspace B in another Just Works. Introducing a module-level
let activeWorkspace(or a non-URL Zustand field the whole app reads) re-breaks this the instant a second tab exists, and the bug is invisible until someone opens one. - A non-member hitting a workspace URL is a 404 in the UI and a 403 at the
proxy — and both must exist.
WorkspaceProvidercallsnotFound()so a guessed/stale link doesn't render a shell around empty data; the proxy independently returns 403 so the data never leaves the server regardless of what the client rendered. Relying on only one of the two leaks either data (no proxy check) or a confusing empty shell (no UI check).
Net-new for arche-ui — the single-tenant source app (the ClickHouse ETL UI) had no tenancy model to extract. The pattern here is distilled from GlassFlow's first product, a separate Preact + MobX State Tree repo, whose organization → space → pipeline model and its localStorage/query-param scoping are the lineage this doc keeps in part and corrects in part:
- frontend/src/api/api.model.ts (Organisation, Space, Member, Profile models —
the entity shapes;
Space.permissionis the unenforced-role lesson) - frontend/src/pages/Organization/page.model.ts (org-level
admin/memberroles,isAdminUI check,saveSelectedOrganization,findStoredOrganizationId— the localStorage active-tenant mechanism) - frontend/src/app/index.tsx (the
reactionthat reset stores on org switch — the brittle switch this model replaces with navigation) - frontend/src/pages/Pipelines/page.model.ts,
frontend/src/pages/Spaces/page.model.ts (inline
organization_id: localStorage.getItem("activeOrganization")at each call site — the ambient-scope anti-pattern) - frontend/src/modals/InviteToOrganization/modal.model.ts,
frontend/src/pages/Organization/page.model.ts
acceptInvite(email+token invitation flow, kept and re-scoped to the workspace) - frontend/src/app/app.model.ts (single user-wide Auth0 JWT set on the shared client — kept)