Multi-Tenancy Conventions
How ɳSelf separates multi-app isolation from Cloud multi-tenancy, and why the two mechanisms must never be mixed.
ɳSelf ships two distinct multi-tenancy mechanisms. They look similar at a glance but solve different problems. Mixing them causes silent data leaks across paying customers.
The two mechanisms
| Convention | Column | Type | Who uses it |
|---|---|---|---|
| Multi-App Isolation | source_account_id | TEXT NOT NULL DEFAULT 'primary' | Plugin authors, SDK middleware |
| Cloud Multi-Tenancy | tenant_id | UUID | nself tenant CLI, billing, cost metering |
These are not interchangeable.
Multi-App Isolation: source_account_id
source_account_id TEXT NOT NULL DEFAULT 'primary' appears in every np_* database table that declares multiApp.supported: true in its plugin.json.
It separates independent consumer apps within a single ɳSelf deployment.
Example: a self-hosted ɳSelf stack serving both a Flock app and a Veterans app. Each app’s data is invisible to the other. The column is a human-readable slug: 'primary', 'unity-flock', 'unity-veterans'.
Single-user deployments never set this column. The DEFAULT 'primary' means all rows belong to the default account, transparently.
The middleware enforces isolation via the X-Source-Account HTTP header on every request.
RLS pattern (PatternUserOwned):
CREATE POLICY np_chat_select ON np_chat_conversations
FOR SELECT
USING (
source_account_id = current_setting('app.source_account_id', true)
AND user_id = current_setting('app.user_id', true)
);
Plugin manifest declaration — every plugin that isolates by source_account_id must declare:
{
"multiApp": {
"supported": true,
"isolation_column": "source_account_id"
}
}
Cloud Multi-Tenancy: tenant_id
tenant_id UUID appears in tables that track usage, cost, and plan-level data for operators running ɳSelf Cloud at scale: np_claw_profile_ab_tests, np_ai_usage_rollup, np_claw_cost_events.
It separates paying customers of the Cloud operator.
Example: an ɳSelf Cloud operator serving acme-corp and beta-ltd as two independent customers. Each customer’s cost events, AI usage rollups, and billing records are scoped by their UUID, created via nself tenant create.
RLS pattern (PatternTenantScoped):
CREATE POLICY np_ai_usage_select ON np_ai_usage_rollup
FOR SELECT
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
Required Hasura row filter — every np_* table with a tenant_id column must have a Hasura metadata permission entry with this select row filter for the user role:
{"tenant_id": {"_eq": "X-Hasura-Tenant-Id"}}
Which to use
Use this decision tree when adding a new database column:
I need to store data per:
├─ App-within-a-deploy (e.g. Flock vs Veterans data in one stack)
│ └─ source_account_id TEXT NOT NULL DEFAULT 'primary'
│ Declare multiApp.supported: true in plugin.json
│ RLS: PatternUserOwned or PatternPublic
│
└─ Cloud customer of the operator (e.g. acme-corp vs beta-ltd)
└─ tenant_id UUID (nullable — not all deploys are Cloud)
Add Hasura row filter: {"tenant_id": {"_eq": "X-Hasura-Tenant-Id"}}
RLS: PatternTenantScoped
Backfill plan: NULL for existing rows (single-user safe)
What must never happen
| Forbidden | Why |
|---|---|
Use source_account_id to separate paying Cloud customers | TEXT slug is not UUID-safe; no billing integration; violates PatternTenantScoped |
Use tenant_id for multi-app isolation within one deploy | UUID is not ergonomic for slug-based app identity; billing metering attaches to the wrong surface |
Omit source_account_id from a table where multiApp.supported: true | Multi-app deploys get no isolation for that table |
Add tenant_id to any table without a Hasura row filter | Cross-tenant data visible via GraphQL — immediate data leak |
PR requirements
Every PR that adds source_account_id or tenant_id to a new table must include a sentence in the PR description explaining which convention applies and why, based on the decision tree above. PRs without this justification are blocked in code review.
Enforcement: nself doctor --deep
nself doctor --deep runs check PERM-RLS-01 on every deployment. It verifies:
- Every
np_*table has RLS enabled and at least one policy. - Every table with
multiApp.supported: truehas FORCE RLS active (relforcerowsecurity = true). - Every table with a
tenant_idcolumn has a Hasuraselectpermission with atenant_idrow filter for theuserrole.
Violations exit non-zero and print structured failure messages:
RLS-FORCE-MISSING table=np_chat_messages role=user
HASURA-FILTER-MISSING table=np_claw_cost_events role=user
Use --strict to escalate warnings to errors. This check runs without a license key.
Tables by convention (v1.1.0)
Using source_account_id
All np_* tables across the 40+ plugins that declare multiApp.supported: true. Key examples:
np_auditlog_events— audit-log pluginnp_chat_conversations,np_chat_messages— chat pluginnp_claw_conversations,np_claw_messages— claw pluginnp_notify_events— notify plugin
Full list: plugins-pro/registry.json field multiApp.supported.
Using tenant_id
Introduced in the S74 Cloud tenancy work. As of v1.1.0:
np_claw_profile_ab_testsnp_ai_usage_rollupnp_claw_cost_eventsnp_auditlog_events— nullabletenant_idadded for forward-compat
nself tenant CLI
The nself tenant CLI (create, upgrade, suspend, destroy, audit) operates at the tenant_id data and RLS layer. Provisioning automation, Stripe billing integration, license revocation on destroy, and runtime suspension are available in v1.1.0. Review the nself tenant command reference before using this in production paying-customer onboarding flows.
See nself tenant command reference for details.
See also
- Multi-Tenant Isolation Models — choosing row-level vs schema vs database-per-tenant for your own B2B SaaS
- nself doctor command reference — full list of PERM-RLS-* checks
cli/internal/database/rls_tenant.go—PatternUserOwned,PatternPublic,PatternTenantScopedimplementations