D Diagent docs

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 direct workspace_id column.
  • App\Concerns\BelongsToAgent โ€” for models that belong to an agent (and transitively to that agent's workspace). The trait's global scope joins through agents to filter by agents.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_id claim, look up the agent, set the singleton from agents.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.