Sprinter Docs

URL as the sole source of truth for active tenant

Replace JWT-driven, profile-backed "active tenant" state with a URL-first model. Per-tab and per-device independence by construction; switchTenant becomes pure navigation.

See the full design document in documents/work/2026-04-17-tenant-url-source-of-truth/spec.md and the accompanying ADR-0003.

Problem

A request's active tenant currently has four coupled sources: URL header, cookie, JWT claim, and profile column. The Node layer prefers URL; RLS prefers JWT. They can disagree, and when they do, data from one tenant can bleed into a page scoped to another. The 2026-04-16 prefetch incident was the headline failure; cross-device drift is a silent second failure mode caused by auth.users.raw_app_meta_data.active_tenant_id being a shared row.

Solution

URL is the sole authorization input for tenant. JWT claim, profile column, and "active" cookie are deleted or demoted. The database reads the URL-derived tenant via a PostgREST db-pre-request hook that pins a transaction-local GUC. get_active_tenant_id() returns the GUC, no fallback.

APIs pass the tenant explicitly via URL path (/api/v1/t/<slug>/...) or tenant-embedded API keys (amble_<slug>_<random>). MCP uses OAuth 2.1 with tenant pinned in the token's audience claim (per MCP spec 2025-06-18).

Acceptance highlights

  • Two-tab test: user in tenants A and B opens /t/a and /t/b — each tab stays in its own tenant across any sequence of actions.
  • Cross-device test: user signed into tenant A on laptop and tenant B on phone — switching on one device does not affect the other.
  • /api/tenants/switch route is deleted. Tenant switcher becomes <Link>.
  • profiles.active_tenant_id column is dropped.
  • auth.users.raw_app_meta_data.active_tenant_id is no longer written by any code path.
  • pgTAP test covers private.set_tenant_context() happy path and deny path.

See the full spec for all acceptance criteria and the four-step rollout plan.

Supersedes

jwt-tenant-remove-profile-fallback.mdx — that spec removes only the profile fallback but keeps JWT as the runtime source. This one removes both.

On this page