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.
| 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.
source_account_idsource_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"
}
}tenant_idtenant_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"}}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)| 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 |
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.
nself doctor --deepnself doctor --deep runs check PERM-RLS-01 on every deployment. It verifies:
np_* table has RLS enabled and at least one policy.multiApp.supported: true has FORCE RLS active (relforcerowsecurity = true).tenant_id column has a Hasura select permission with a tenant_id row filter for the user role.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=userUse --strict to escalate warnings to errors. This check runs without a license key.
source_account_idAll 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 pluginFull list: plugins-pro/registry.json field multiApp.supported.
tenant_idIntroduced in the S74 Cloud tenancy work. As of v1.1.0:
np_claw_profile_ab_testsnp_ai_usage_rollupnp_claw_cost_eventsnp_auditlog_events — nullable tenant_id added for forward-compatnself tenant CLIThe nself tenant CLI (create, upgrade, suspend, destroy, audit) operates at the tenant_id data and RLS layer. Provisioning automation, Stripe billing charges, license revocation on destroy, and runtime suspension are all GA in v1.1.0.
See nself tenant command reference for details.
cli/internal/database/rls_tenant.go — PatternUserOwned, PatternPublic, PatternTenantScoped implementations