Last updated 2026-05-28

Multi-Tenancy

notify carries a tenant_id on every persisted row and every RPC. The model is deliberately lightweight: tenants are an isolation key, not a billing identity or a feature gate.

When you'd care

  • You run one notify deployment that serves multiple customer organizations and need to keep their data apart.
  • You issue JWTs that already carry a tenant claim and want that to flow through to the inbox.
  • You're using the EntDB driver and want to map notify tenants onto EntDB tenant shards.

Where the tenant id comes from

Two paths, depending on the service:

  • Producer surface (NotificationInternalService) — the body's tenant_id field. The internal-token check confirms the caller is trusted; the caller is expected to know which tenant it is acting on behalf of.
  • Client surface (NotificationClientService) — the JWT's tenant (or tenant_id) claim. The request body has no tenant field at all on these RPCs.

How tenant scoping works in the store

Every Store method that reads or writes carries a tenantID parameter, and every implementation honors it as a hard filter:

  • CreateNotification — the (TenantID, UserID, NotificationID) triple is the uniqueness key. Re-using a notification id under a different tenant produces a new row, not an idempotent hit.
  • GetNotification — a row that exists under a different tenant returns ErrNotFound. The same goes for cross-user reads under the same tenant.
  • QueryUserNotifications — scoped by (TenantID, UserID). The cursor and unread count are also scoped.
  • UpsertDevice / ListDevices — same shape.

The conformance suite exercises this with a dedicated UserIsolation subtest that creates three rows ((acme, u1, x), (acme, u2, x), (beta, u1, x)) and asserts that QueryUserNotifications for (acme, u1) returns exactly the first.

One deployment, many tenants

Notify is happy to serve many tenants from one container — the tenant id is just data. There is no per-tenant configuration knob today, no per-tenant rate limiting, no per-tenant provider override. Provider configuration is global, and the same providers serve every tenant.

If you need per-tenant behaviour (e.g. different SMS sender numbers per customer), the right shape today is library mode: wire one Notifier per tenant with a tenant-specific ProviderRegistry sharing a single underlying Store. The container will grow this as a follow-up wave; until then library mode owns the customization story.

EntDB driver and tenant shards

EntDB itself is tenant-sharded. The notify EntDB driver maps onto a single EntDB tenant per process via NOTIFY_ENTDB_TENANT_ID:

-e NOTIFY_STORE_DRIVER=entdb \
-e NOTIFY_ENTDB_ADDRESS=entdb:50051 \
-e NOTIFY_ENTDB_TENANT_ID=notify-prod

Inside notify's logical model, you can still serve many tenants over that one EntDB shard — they all live under the same EntDB tenant, differentiated by the tenant_id field on each row. The EntDB tenant is the storage shard; the notify tenant is the application-level isolation key.

Operationally large customers may want a dedicated EntDB shard per customer. The shape is to run a notify container per shard, pointing each at its dedicated NOTIFY_ENTDB_TENANT_ID.

Postgres driver and tenant scoping

The Postgres schema carries tenant_id as a column on both tables. Every WHERE clause includes it; every UNIQUE constraint starts with it. There is no Postgres-level row security or schema fan-out — the application-level filter is the contract.

-- Composite uniqueness keyed on tenant.
CREATE UNIQUE INDEX notify_notifications_tenant_user_nid_uq
ON notify_notifications (tenant_id, user_id, notification_id);
-- Paging index orders within a tenant + user.
CREATE INDEX notify_notifications_user_created
ON notify_notifications (tenant_id, user_id, created_at_ms DESC, id DESC);

Cross-tenant operations are intentionally absent

Why this design. There is no "fanout to all tenants" RPC, no "list every tenant" admin endpoint, no global query. The tenant boundary is a hard wall because the auth boundary is — the JWT pins the recipient to one tenant, and the producer surface is per-call tenant-scoped. Cross-tenant operations would either need an admin role concept the platform doesn't model (yet) or a producer with super-user reach that defeats the isolation promise. Neither pays off enough at v0.1 to be worth the footgun.

Related