Skip to content
GitHub
Decisions

ADR-105: Tenancy authority & ownership model — self-service org provisioning + cold-start REST contract

Context

The rebuilt platform is implicitly operator-provisioner-centric: a customer org/owner/domain is minted only by raw SQL in dev seeds (tools/dev/customer_seed.py). The tenancy substrate exists — platform.org_members (roles owner/operations, RLS, the access-token hook injecting app_metadata.organization_role), platform.domain_members, AcceptInvite, CreateDomain, CheckWorldModelAvailability, the 1:1 domains.world_id link, and clean cold-start 404s on the customer read routes — but the application-layer orchestration that lets a real customer provision their own tenancy does not. There is no CreateOrg use case, no signup route, no world-gated domain-create flow, and AcceptInvite binds only domain_members (an invitee never gets an org_members row, so they resolve no organization_role).

ADR-039 D7 already decided the intent — “first OAuth user of a brand-new org (self-service signup): the callback creates the orgs row and sets the user as owner; the only OAuth path granting owner without a pre-existing invite” — but it was written for a callback-centric shape that the rebuild never implemented. This ADR realizes that intent in the rebuilt route architecture and settles the authority/ownership questions the implementation forces, before SPEC-528 D2–D8 build against them.

The forcing question is an auth chicken-and-egg: a brand-new GoTrue user has no org_members row, so the access-token hook (20260602020000_platform_access_token_hook.sql) injects no organization_role and no decision scopes, so the customer auth gate (apps/api/.../customer/auth.py) — which forbids a caller carrying no decision scope — rejects the token 403. The user cannot reach any customer route, including the one that would create their org. Onboarding therefore cannot sit behind the customer gate; it needs its own auth posture. That, plus the ownership invariants and the cold-start contract, warrants an ADR because the decisions shape more than one deliverable (the ownership core, the signup route, the dashboard onboarding UI, and the no-seeding migration).

Decision

D1 — Authority principle: the customer owner owns tenancy; the operator participates, never owns

The customer owner holds ownership of and responsibility for the org, its accounts, and its domains. Operators provision worlds (worlds are operator purview) and participate in org/domain management via assumed identity (ADR-087 D2 + ADR-033 D4) — one customer tenancy per request, under RLS — not via standing membership in the customer org. An operator is never a row in a customer’s org_members/domain_members (per ADR-086 D5, amended SPEC-552). The operator is not the owner of customer tenancy.

D2 — Org-membership ownership model: customer members hold owner; operations is Spectral-staff-only

org_members.organization_role ∈ {owner, operations} (the model from ADR-086 D5). For a customer org, the only role its members hold is owneroperations is exclusively the Spectral-staff role (held by members of the single Spectral-internal org, never a customer org). Consequences:

  • CreateOrg (self-service signup) writes the founding user as the sole owner of the new org.
  • An invited customer user (via AcceptInvite) becomes a co-owner of the customer org — there is no customer non-owner org role in the model today. A finer customer member-role taxonomy (e.g. a read-only member) is a genuine model extension, deferred to SPEC-302 (it would add a role to the enum + the token hook + RLS, out of scope for the dogfood). Per-domain roles (admin/contributor/observer on domain_members) already provide finer customer access control within an org.

org_members writes stay platform_role-only (the “never self-assigned” design from the SPEC-552 migration): org roles are written by application use cases running under platform_role, never via an app_role self-INSERT. Member reads gain co-member visibility (an org’s members may read their org’s membership), beyond today’s self-only RLS.

D3 — The ≥1-owner invariant

An org always retains at least one owner. Removing or demoting the last owner is rejected with a clean error (surfaced as a 409/422 at the edge); the system never leaves an org ownerless. The invariant is enforced in the member-management use cases (the role-write path), with a DB-side guard where natural.

D4 — Org-less signup auth posture (the crux)

Self-service signup runs under a distinct auth path from CustomerAuthGate:

  • It verifies the GoTrue JWT (signature + sub/email) but requires no org membership — a freshly-authenticated user with no org (hence no organization_role/decision scopes, and no org_id to bind) is the expected caller. (The customer gate, which forbids a caller carrying no decision scope, is correct for the membership-gated surfaces and is left unchanged.)
  • CreateOrg runs under platform_role in one transaction (org + the founding owner org_members row + the core.users mirror), because app_role cannot write org_members/core.users and a brand-new user has no org_id to bind a request scope.
  • This is the single path that grants owner without a pre-existing invite — the rebuilt-architecture realization of ADR-039 D7 (a POST /orgs onboarding route, not an OAuth callback). It coexists with the invite path (AcceptInvite).
  • Signup is idempotent: a re-signup by the same authenticated user resolves to their existing org, never a duplicate.

This onboarding surface (route module + composition seam) is kept separate from the membership-gated customer/ surface so the org-less auth + the platform_role writes are isolated and greppable.

D5 — Two cold-start states are valid resting states with a clean REST contract

A provisioned org passes through two legitimate config states that are never errors and never 5xx:

  • Org-has-no-domains (org-layer cold-start) — a signed-up owner with zero domains is a valid resting state. The “can’t delete the last domain” control is dropped; zero domains is legitimate. Reads list cleanly (e.g. GET …/domains → empty list).
  • Domain-has-no-published-world — a domain whose world is not yet deployed. The customer read/decision routes (/decide, /actions, world-model-state) return a clean 404 + RFC 9457 body (ADR-006 D3), with a diagnostic message — never a 5xx.

D6 — Domain creation is customer-owned but world-gated

Creating a domain is a customer-owned action (an org member, under their own authority) but is world-gated: it requires an operator-authored published world (worlds are operator purview, D1), composing CheckWorldModelAvailability, and sets the 1:1 domains.world_id link (ADR-098) in the same operation. When no published world is available the request is rejected with a clean 4xx. This is the home of the relocated SPEC-357 cold-start composition (CreateDomain × CheckWorldModelAvailability), dropped from the worlds-authoring epics because domain-create is tenancy/provisioning, not worlds authoring.

Alternatives considered

Put signup behind the customer auth gate. Impossible — a new user has no org, so the hook injects no decision scopes and the gate (which forbids a scopeless caller) 403s before any route runs. The org-less path is the only way to bootstrap.

Let app_role self-INSERT org_members (signup under app_role + request_scope). Rejected — it opens write policies on a tenancy-authority table and contradicts the “never self-assigned” design; a new user also has no org_id to bind the scope. CreateOrg under platform_role (the trusted mint) is the correct posture, mirroring the proven seed write-order.

Introduce a customer non-owner org role now (e.g. member). Rejected for the dogfood — operations is staff-only and there is no customer member role in the model; adding one touches the enum, the token hook, and RLS. Per-domain roles already give finer in-org access. Deferred to SPEC-302; invited customer users are co-owner meanwhile.

Keep the “can’t delete the last domain” control. Rejected — an org with zero domains is a legitimate resting state (a freshly-onboarded owner before they create their first domain); forcing ≥1 domain contradicts the cold-start model (D5).

Auto-provision a default/demo world so domain-create never blocks. Rejected — the “no general-purpose world model” posture (SPEC-260) stands; domain-create is genuinely world-gated and the no-published-world case is a clean 4xx, not a fallback.

Consequences

  • New application-layer orchestration (SPEC-528 D2–D8): a CreateOrg use case (txn: org + owner + core.users mirror) + the ≥1-owner invariant + a co-member SELECT RLS policy on org_members; an org-less signup auth verifier + POST /orgs; AcceptInvite extended to write an org_members (co-owner) row; org member-management use cases (platform_role writes + owner-check); a world-gated POST /orgs/{org}/domains; the dashboard onboarding UI (a route that bypasses the org-id gate); and the migration of customer_seed.py/qa off raw SQL onto the onboarding API.
  • A new onboarding/tenancy route module + composition seam in apps/api, parallel to customer/ — the first customer-facing platform-write surface; isolates the org-less auth + platform_role writes.
  • core.users mirror writes move from seeds into application code (a repository method), with an INSERT grant to platform_role.
  • The operator never gains customer-org membership — D1 keeps operator participation on the assumed-identity path; this ADR does not add operators to customer org_members.
  • ADR-039 D7 is realized, not amended — the first-user→owner intent now lives in a POST /orgs route rather than an OAuth callback; D7’s “single owner-without-invite exception” still holds.
  • Forward-compatible with a future customer member-role + advanced member management (SPEC-302) and with per-domain tenancy growth — the owner/operations split and the cold-start contract do not bake in the alpha shape.

References

  • Implements: Linear epic SPEC-528 (D1 = this ADR / SPEC-667; built across SPEC-668–674).
  • Realizes / builds on: ADR-039 D7 (first-user→owner), ADR-086 D5 (org-role model), ADR-098 (1:1 domain↔world), ADR-087 + ADR-033 D4 (operator assumed-identity), ADR-006 D3 (RFC 9457 errors).
  • Substrate already shipped: SPEC-552 (org_members + access-token hook), SPEC-595 (customer-JWT membership-RLS).
  • Deferred: SPEC-302 (customer member-role taxonomy, advanced member management, cross-IdP federation, billing).