Architecture
Multi-tenancy
Multi-tenancy is the most important invariant in the codebase. A bug that leaks data across workspace boundaries is a security incident. The enforcement is layered: traits, global scopes, policies, and a regression test that fails the build.
The trait pattern
Two traits do the heavy lifting:
App\Concerns\BelongsToWorkspaceโ for models with a directworkspace_idcolumn.App\Concerns\BelongsToAgentโ for models that belong to an agent (and transitively to that agent's workspace). The trait's global scope joins throughagentsto filter byagents.workspace_id = current.
Both traits add a global scope that's applied to every
Eloquent query against the model. They also auto-fill
workspace_id on creating, so you can't accidentally insert
a row into the wrong workspace.
CurrentWorkspace
The current workspace is resolved per request by middleware:
- Authenticated requests โ read
users.default_workspace_id, verify membership, set the singleton. - Widget requests โ derive from the JWT's
agent_idclaim, look up the agent, set the singleton fromagents.workspace_id. - System / queue jobs โ explicitly set per job, never inherited.
The resolver lives at App\Support\CurrentWorkspace. After
the request, the middleware's finally block clears it so
state can't leak between Octane requests.
Bypassing the scope (rare)
Some queries genuinely need to look across workspaces โ admin reports, impersonation, system jobs. The bypass is explicit:
// System-level operation: rolling up usage across all workspaces
Workspace::withoutWorkspaceScope()->each(...)
The convention is every withoutWorkspaceScope()
call must have a justifying comment immediately above it. Code
review enforces this; the /tenancy audit slash command
flags violations.
Switching workspaces
A user with multiple memberships uses the workspace switcher in the
sidebar. Switching POSTs to /workspaces/{id}/select, which
updates users.default_workspace_id and redirects. The
next page load resolves the new workspace.
The switcher only renders when the user has 2+ memberships โ single- workspace users see a quiet label instead of a menu, since "switch" among one option is just clutter.
Per-workspace data
Tenant-scoped tables (every Eloquent model with the trait):
- Direct
workspace_id:agents,integration_connections,plan_subscriptions,usage_events,audit_logs,invitations. - Via agent:
agent_versions,behavior_rules,cta_rules,curated_answers,experiments,sources,documents,chunks,visitors,conversations,messages,leads,content_gaps.
Cross-workspace tables (no scope):
usersโ global. Membership in any workspace is in the pivot.workspacesโ global. The workspace itself isn't scoped to a workspace.plansโ global, platform-admin-managed.jobs/failed_jobs/notificationsโ Laravel infrastructure.app_settingsโ singleton, platform-wide.
Vector store isolation
Vector store metadata mirrors the tenancy contract โ every point has
agent_id and workspace_id labels, and every
query filters by agent_id. Cloudflare Vectorize and Qdrant
both support this natively (Qdrant's payload-filter / Vectorize's
metadata-filter).
A bug that filtered by agent_id alone but not the agent's
actual workspace would still be safe โ agent IDs are ULIDs, globally
unique. A bug that didn't filter at all would be a leak. The
Retriever always filters explicitly.
Authorization (policies)
Tenancy is "is this row in my workspace?". Authorization is "what can
I do with rows in my workspace?". Policies live in
app/Policies/:
WorkspacePolicyโ view/update/delete the workspace itself, transfer ownership.AgentPolicyโ viewAny / view / create / update / delete / publish / rollback.SourcePolicyโ manage sources within an agent.LeadPolicyโ read / update / delete leads.IntegrationConnectionPolicyโ connect / disconnect.
The check pattern is consistent: $user->can('update', $agent)
or abort_if(! $user->can(...)). Policies use the
Tenancy helper to resolve the user's role in the resource's
workspace and then call capability methods on the
WorkspaceRole enum.
Tests
The regression test is MultiTenancyTest under
tests/Feature/. It seeds two workspaces with overlapping
data and asserts that queries from each can only see their own rows.
It also walks every model in app/Models/ to confirm any
model with workspace_id uses the trait.
Run:
php artisan test --filter=MultiTenancyTest
Required to pass on every CI build. PRs that break it can't merge.