User Pools / Namespaces — audit + design
Status: implemented + verified — 2026-06-08 (migration 017),
extended 2026-06-09 (migration 018: multiple registration namespaces).
Model (A) per-namespace uniqueness was chosen (§4). Captures the
current identity model, answers the namespacing questions, and
documents the shipped read/write user-pool feature. Verified
end-to-end against Postgres (see §7). wristleo and claimleo are
the first real non-default pools.
The pool model: one default pool + other pools (migration 018, simplified 2026-06-10)
An app’s pool configuration is:
- Default pool (registration) —
apps.registration_namespace(single value). New users registered through the app get this as their home namespace (users.namespace, the per-pool email-uniqueness anchor and thenamespaceJWT claim). Empty ⇒default. - Other pools (login) —
apps.read_namespaces. Two jobs:- login + register match existing users across
[default pool, ...other pools](home namespace ORuser_namespacestag); - new registrants are tagged (
user_namespacesrows) into every other pool, so they belong to the app’s whole pool set.
- login + register match existing users across
Worked example — claimleo with default pool default and other
pools [wristleo, claimleo]: a new user lives in the shared default
pool (every future default-reading app picks them up) and carries
wristleo + claimleo tags; existing default or wristleo users
log straight in — no duplicate identity.
Notes:
- Tags are created only for new registrations. Reusing an existing
user (e.g.
register_or_loginof adefaultuser via claimleo) does not tag them — a possible future opt-in (tag_existing_on_login). apps.registration_namespaces(plural) is a legacy column from the original 018 shape — when non-empty its first entry acts as the default pool. New configs use the singular field; the admin UI clears the plural on save.- Resolution helpers:
App.WriteNamespace()= the default pool;App.EffectiveReadNamespaces()=[default pool, ...other pools](ordered, de-duplicated) — element 0 is always the home pool for new users, elements 1..n are the tag set.
Pool administration (2026-06-10)
Pools are managed at runtime via system_admin endpoints (no migration needed — pools stay virtual):
| Method | Path | Behavior |
|---|---|---|
| GET | /admin/namespaces | Every known pool: distinct user_count + home_count/tag_count breakdown + app_codes referencing it. Pools referenced only by app config appear with zero users (“empty”). |
| GET | /admin/users/{id}/namespaces | { namespace: <home>, namespaces: [<tags>] } |
| PUT | /admin/users/{id}/namespace | Move the home pool. 409 CONFLICT when the email already exists in the target pool (per-(pool,email) uniqueness). A tag equal to the new home is cleaned up. |
| POST | /admin/users/{id}/namespaces | Tag the user into an extra pool (idempotent; tagging the home pool → 400). |
| DELETE | /admin/users/{id}/namespaces/{ns} | Remove a tag. Removing the home pool → 400 (“set a different default pool instead”). |
SDK: @rw3iss/auth-client NamespacesFlow (listNamespaces is
cached 60s client-side for type-ahead pool pickers; mutations
invalidate). Demo UI: Applications & Services → User pools tab
(catalog), the app editor’s pool pickers, and the admin user page’s
User pools section.
1. Audit — how identity works today
There is no user “pool” or “namespace” concept today. Users are a single global identity space.
users.emailis globally unique.migrations/001declaresCREATE UNIQUE INDEX users_email_unique ON users(email) WHERE deleted_at IS NULL. One email = exactly one user row, forever, across the entire platform.userRepo.GetByEmail(email)is global. BothAuthService.RegisterandAuthService.Loginresolve the user with a single globalGetByEmail— no app/namespace scoping in the lookup.- Apps are an access + policy layer, not an identity namespace.
An
appsrow carriesservice_codes,auto_grant_on_signup,allowed_redirect_urls, and the migration-013 registration policy (allowed_email_domains,allowed_auth_methods,default_organization_id,frontend_url). None of these segregate identity — they gate who may enter (user_appsmembership) and which permissions/org the token carries. - Organizations are the closest existing grouping, but they’re RBAC membership groupings layered on the one global user — not identity pools. A user is one global identity that belongs to N orgs.
Answers to the specific questions
How is it namespacing users? It isn’t. Every user lives in one implicit global pool keyed by a globally-unique email.
Does it put all users in the default pool unless given a different app namespace? Effectively there is only the (implicit) global pool. Apps cannot direct a user into a different identity pool today —
app_codechanges access and token scope, never which user record an email maps to.
When users log in, is it checking the auth user pools by namespace correctly? There are no pools to check. Login looks the user up by global email, then verifies
user_appsaccess membership for the resolvedapp_code. So it checks app access, not pool identity.
Conclusion: the requested workflow (apps choosing read pools + a
write pool, with existing-user reuse across pools) is not currently
supported and requires a new namespace dimension on users plus
read/write namespace config on apps.
2. Desired workflow (restated)
- Every user belongs to one namespace/pool (default:
default). - An app may declare:
registration_namespace(the write pool) — which pool new users created through this app are written into. Defaultdefault.read_namespaces(the read pools) — the set of pools this app authenticates / validates users against at login. Default: just its write pool (which, unconfigured, isdefault).
- Login: resolve the app → find the user by email within the app’s read namespaces → authenticate.
- Register: resolve the app → if a user with that email already exists in any read namespace, reuse it (no duplicate identity); otherwise create a new user in the write namespace.
- All of this is opt-in. An app with no namespace config reads and
writes
default→ behaves exactly as today.
This lets an app capture new users into its own pool while still
honoring existing users from the shared default pool (or any other
listed pool), so people don’t re-create themselves per app.
3. Design
3.1 Schema (new migration 017_user_namespaces)
-- users gain a home namespace.ALTER TABLE users ADD COLUMN namespace VARCHAR(100) NOT NULL DEFAULT 'default';
-- Email uniqueness becomes per-namespace (see §4 for the decision).DROP INDEX users_email_unique;CREATE UNIQUE INDEX users_namespace_email_unique ON users(namespace, email) WHERE deleted_at IS NULL;DROP INDEX idx_users_email;CREATE INDEX idx_users_namespace_email ON users(namespace, LOWER(email)) WHERE deleted_at IS NULL;
-- apps gain read/write pool config (both optional → default behavior).ALTER TABLE apps ADD COLUMN registration_namespace VARCHAR(100), -- NULL/'' ⇒ 'default' ADD COLUMN read_namespaces TEXT[] NOT NULL DEFAULT '{}'; -- empty ⇒ [write ns]All existing rows get namespace='default'; all existing apps get
registration_namespace=NULL + read_namespaces='{}' → unchanged
behavior.
3.2 Domain helpers (on App)
// WriteNamespace — pool new users register into. Defaults to "default".func (a *App) WriteNamespace() string { if a == nil || a.RegistrationNamespace == nil || *a.RegistrationNamespace == "" { return "default" } return *a.RegistrationNamespace}
// ReadNamespaces — pools this app authenticates against. Always// includes the write namespace; falls back to ["default"] when nothing// is configured. De-duplicated.func (a *App) ReadNamespaces() []string { ... } // union(read_namespaces, WriteNamespace())User gains Namespace string (db:"namespace", default "default"
in domain.NewUser).
3.3 Repository
- Add
GetByEmailInNamespaces(ctx, email, namespaces []string) (*User, error)→WHERE email=$1 AND namespace = ANY($2) AND deleted_at IS NULL, ordered sodefault(or the first listed) wins deterministically when an email exists in more than one readable pool. - Keep
GetByEmail(email)for legacy/global flows; it resolves thedefaultnamespace (every pre-feature user isdefault, so no behavior change). SSO / password-reset / check-email / admin lookup stay on this path in v1 (see §6 limitations).
3.4 Register flow change
At the existing collision check, replace the global GetByEmail with a
read-namespace lookup driven by the resolved registrationApp:
readNs := registrationApp.ReadNamespaces() // ["default"] when unconfiguredexisting, err := s.userRepo.GetByEmailInNamespaces(ctx, email, readNs)// ...same mode dispatch (register / register_or_login / register_or_return)...// On create:user := domain.NewUser(email, first, last)user.Namespace = registrationApp.WriteNamespace() // "default" when unconfiguredSo a brand-new email gets written to the app’s write pool; an email already present in a readable pool is reused (login / link), never duplicated.
3.5 Login flow change
readNs := resolvedApp.ReadNamespaces()user, err := s.userRepo.GetByEmailInNamespaces(ctx, email, readNs)Base-user mode (no app) and the legacy/Cognito fallback keep using the
default namespace.
3.6 Token claims
Add an optional namespace claim to the access token (and surface it
on /auth/me) so downstream services + the client can see which pool
the authenticated identity belongs to. Empty/default omitted for
wire economy if desired.
3.7 Admin API + DTO
POST /admin/apps + PUT /admin/apps/{id} accept
registration_namespace (string) + read_namespaces (string[]),
echoed on read. Validation: kebab/lower-case, ≤100 chars, no spaces.
3.8 auth-client
The namespace is app-level server config, not a per-login client
input — so the browser client doesn’t pass it on login. Client work
is: surface namespace on the decoded user/claims type, and (in
auth-server-ts / auth-server-nest admin helpers, if present) accept
the two new fields when creating/updating an app. Docs updated to
explain pools.
3.9 Backwards compatibility
Every change defaults to default. Unconfigured apps + all existing
users behave identically. The feature only activates when an app sets
registration_namespace and/or read_namespaces.
4. The decision that needs sign-off
Email uniqueness scope. The feature as described implies the same email can be a separate identity in two pools (you can “register new users to a unique pool” that already exist elsewhere only via the read-list; truly new ones get their own record). Two ways to model it:
- (A) Per-namespace uniqueness —
UNIQUE(namespace, email).alice@x.comindefaultandalice@x.cominwristleoare distinct users. Full pool segregation (Cognito-style). Matches your description. Cost: drops the global “one email = one human” invariant; every email-only flow (SSO upsert, password-reset-request, check-email, admin lookup-by-email) must either take a namespace or default todefault. Recommended — it’s the only model that actually delivers separable pools. - (B) Global uniqueness + namespace tag — keep
UNIQUE(email);namespaceis just a “home pool” label on the single global record. Lighter + preserves the global invariant, but an email is always the same person everywhere, so apps can’t truly own a private pool of the same address. Simpler, but doesn’t fully satisfy the ask.
This doc specifies (A). It’s the consequential, mostly-one-way schema-semantics change, which is why it’s flagged before the migration runs.
5. Implementation plan (layers, in order)
migrations/017_user_namespaces.{up,down}.sql— §3.1.internal/domain/user.go(+NewUserdefault),internal/domain/app.go(fields +WriteNamespace/ReadNamespaces).internal/repository/interfaces.go+postgres/user_repository.go—GetByEmailInNamespaces;Create/scans includenamespace.internal/service/auth_registration.go/auth_login.go— Register + Login resolution (§3.4, §3.5).internal/auth/jwt/*— optionalnamespaceclaim (§3.6).internal/api/dto/*+handlers/app.go+service/app_service.go— accept/echo the two app fields (§3.7).internal/config— no new env required (defaults live in schema).- auth-client + auth-server-ts/nest types + docs (§3.8).
- Docs: this file,
APP_REGISTRATION.md(§ pools),How_It_Works.md(multi-tenant model),README.md, auth-clientREADME.md.
6. Known limitations for v1 (documented, deferrable)
SSO upsert, password-reset-request,Resolved (GlobalSKU integration §1). These flows now resolve the app context and use the app’s read pools:/auth/check-email, and admin-lookup-by-email resolve indefaultonly.- App-context resolution uses the existing
app_codechannel (no new middleware): theX-App-Codeheader for unauthenticated flows (SSO start, password-reset-request), with the request-bodyapp_codeas a fallback; for the authenticated/auth/check-emailthe JWTapp_codeclaim is authoritative (header → body fallback). No app context ⇒default(unchanged behavior). - SSO upsert carries
app_codethrough the SSOstate(captured at/auth/sso/url), resolves the user byGetByEmailInNamespacesacross the app’s read pools, writes new users into the app’s write pool, and tags + grants the app namespace (auto-grant honored). - password-reset-request / resend-verification / check-email all use
resolveReadNamespaces(appCode)instead of the baredefaultlookup. - admin lookup-by-email (
POST /admin/users/lookup) is a bulk, cross-pool resolver and returns each user’snamespace, so a namespaced caller disambiguates by that field; theven_user_idbackfill path now also gets uids directly from bulk-import (§4).
- App-context resolution uses the existing
- A user belongs to exactly one home namespace. “Same human, two pools” is by design two records under model (A); linking them is out of scope.
7. Local test plan (after implementation)
make docker-up; seedsystem_admin.- Create two apps:
app-default(no namespace config) andwristleo(registration_namespace=wristleo,read_namespaces=["default","wristleo"]). - Register
new@x.comviawristleo→ lands inwristleo. - Register/login an existing
defaultuser viawristleo→ reused fromdefault, not duplicated. - Login
new@x.comviaapp-default→ not found (isolation: it’s inwristleo, whichapp-defaultdoesn’t read). - Verify
app_id/namespace/roles claims +user_appssegregation.