Multi-tenancy
When a single Loom app serves multiple organisations (each with their own users, customers, invoices), every read and write must stay inside the active org's data. Loom expresses this with a single Thread-level flag — tenant_scoped: true — and the runtime handles the rest.
The flag
Add tenant_scoped: true and a tenant_id field that links to a Tenant Thread:
model: Customer
label: Customer
icon: users
tenant_scoped: true
fields:
- name: tenant_id
type: link
label: Tenant
required: true
target: Tenant
- name: company_name
type: text
required: true
The validator enforces that tenant_scoped: true ships with a matching link field (named tenant_id by default; override with tenant_key:) targeting Tenant and marked required: true. Forget any of those and loom check fails.
What the generator does
For every tenant-scoped Thread, the generated handlers:
| Operation | What changes |
|---|---|
List |
WHERE tenant_id = ? injected from auth.MustTenantFromContext(ctx) |
Get |
WHERE id = ? AND tenant_id = ? |
Create |
Sets record.TenantId = &tenantID server-side. The client body's tenant_id is ignored — clients can't move a record into a different tenant by lying. |
Update |
Pins tenant_id from context AND adds WHERE id = :id AND tenant_id = :tenant_id. A stale row from a different tenant won't be silently rewritten. |
Delete |
WHERE id = ? AND tenant_id = ? |
This is structural: cross-tenant access becomes impossible at the SQL layer, not merely role-permission-checked. A bug in role logic can't leak rows because the SQL never returns them.
Where the tenant comes from
The runtime expects a JWT with a tnt claim. The User struct in internal/runtime/auth.go has a Tenant uuid.UUID field; Auth.Issue(u) includes the tnt claim when u.Tenant != uuid.Nil. From a handler:
tenantID := auth.MustTenantFromContext(ctx)
Returns the active tenant's UUID, panics if absent. The panic is intentional: a tenant-scoped handler reaching this line without a tnt claim means the auth middleware is misconfigured — that's a 500 (caught by chi's recoverer), not a silent fallback to "give them every tenant's data".
For optional checks (handlers that may run for platform-level users with no tenant), use the soft variant:
if tenantID, ok := auth.TenantFromContext(ctx); ok {
// tenant-aware path
}
Getting a tenant-scoped JWT
/auth/login issues a token with tnt empty — the user has authenticated but hasn't picked a tenant. Tenant-scoped routes will 500 (panic) on that token because MustTenantFromContext finds nothing.
/auth/switch-tenant exchanges that token for one with tnt set, after verifying the user is a TenantMember of the requested tenant. See Reference → Auth Endpoints for the full request/response shape.
What's NOT tenant-scoped
Some Threads are platform-level by design and should NOT carry tenant_scoped: true:
User— users belong to many tenants (one user record, manyTenantMemberrows). Scoping User by tenant breaks the cross-tenant identity model.Tenant— the tenant itself. Self-referential scoping makes no sense.TenantMember— the join Thread. Its scoping decision lives at a higher policy layer (e.g., "only platform admins can list every membership; tenant owners list their own tenant's memberships"), not per-row CRUD.
apps/core/ ships these three plus four genuinely tenant-scoped Threads (LLMCredential, Agent, AgentRun, AuditEvent) as a reference layout.
When to use it vs. when to skip
Use tenant_scoped: true for any Thread holding tenant-private data — Customer, Invoice, AgentRun, AuditEvent, LLMCredential, etc.
Skip it for:
- Cross-tenant data (User, Tenant, public catalog tables, shared reference data).
- Single-tenant apps. The flag is harmless to leave off; queries stay simple
SELECT * FROM …without theWHERE tenant_id = ?clause. - Threads where tenant-scoping is too coarse — e.g., a "shared workspace" model. Implement that with a custom permission layer rather than
tenant_scoped.
Indexes you'll want eventually
Loom emits a KEY index on every searchable: true field. For a tenant-scoped Thread querying with WHERE tenant_id = ? AND status = ? you'll want a compound (tenant_id, status) index, which Loom doesn't emit yet. Add it manually via a migration or as a loom stitch step once the flag is stable. Tracked in TASKS.md.
See also
- Concepts → Permissions — role-based gating layered on top of tenant scoping
- Concepts → Secret Fields — frequently used together (e.g.,
LLMCredential.encrypted_key) - Reference → Auth Endpoints —
/auth/login+/auth/switch-tenantmechanics