Skip to main content

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

ConventionColumnTypeWho uses it
Multi-App Isolationsource_account_idTEXT NOT NULL DEFAULT 'primary'Plugin authors, SDK middleware
Cloud Multi-Tenancytenant_idUUIDnself 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

ForbiddenWhy
Use source_account_id to separate paying Cloud customersTEXT slug is not UUID-safe; no billing integration; violates PatternTenantScoped
Use tenant_id for multi-app isolation within one deployUUID 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: trueMulti-app deploys get no isolation for that table
Add tenant_id to any table without a Hasura row filterCross-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:

  1. Every np_* table has RLS enabled and at least one policy.
  2. Every table with multiApp.supported: true has FORCE RLS active (relforcerowsecurity = true).
  3. Every table with a 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=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 plugin
  • np_chat_conversations, np_chat_messages — chat plugin
  • np_claw_conversations, np_claw_messages — claw plugin
  • np_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_tests
  • np_ai_usage_rollup
  • np_claw_cost_events
  • np_auditlog_events — nullable tenant_id added 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