ADR 016: Platform Tenant, Tenant Types, and Cross-Tenant Org Migration

ADR 016: Platform Tenant, Tenant Types, and Cross-Tenant Org Migration

Status

Accepted (updated 2026-03-12: org-scoped content simplifies migration, every tenant has root org, billing on org level)

Date

2026-03-12

Context

The platform serves churches of all sizes — from small independent congregations to large multi-site denominations. The question: how do small churches that can’t justify a custom-branded native app still get a native app experience?

Initial thinking assumed every church needs its own tenant. But the ICF Movement case reveals the pattern: ICF is one tenant, and its 80+ legally independent churches are organizations within it. Each ICF location can have its own billing, branding, and admin autonomy — yet they share a denomination-level tenant.

The same model works for the platform itself: the platform acts as a “movement” for independent churches that don’t belong to any denomination.

The org tree already isolates siblings

A user in ICF Zürich sees: Zürich content + ICF Movement content (ancestors). They do NOT see ICF Basel’s org-level content (sibling). Content flows down from ancestors, not across from siblings. This means independent churches as sibling orgs in a shared tenant are naturally isolated.

Decision

1. Four tenant categories

CategoryExampleCreated byOrgs are
Platform tenantThe default tenantPlatform team (superadmin)Independent churches, each with own billing/branding
Denomination tenantICF Movement, FEG SwitzerlandDenomination leader via /registerLegally independent churches, own billing possible
Single-church tenantA multi-site churchChurch leader via /registerLocations of the same legal entity
Temporary tenantSummer Camp 2026OrganizerEvent-specific structure, time-bounded

These are not enforced as an enum — they’re usage patterns of the same tenant model. The TenantType enum (church, camp, conference, organization) describes what the tenant contains, not who manages it.

2. Every tenant has a root org (including the platform tenant)

Same model everywhere — no special cases:

DEFAULT TENANT ("Church App")
└── Church App (root org, type: 'root')    ← platform operator controls this
    ├── Grace Chapel (child)               ← independent church
    ├── City Church (child)
    │   └── City Church Youth (grandchild)
    └── ICF Zürich (child)

ICF MOVEMENT TENANT
└── ICF Movement (root org, type: 'root')  ← denomination controls this
    ├── ICF Zürich (child)
    ├── ICF Bern (child)
    └── ICF Basel (child)
  • Platform tenant’s root org is managed by the platform operator (superadmin)
  • Root org content is visible to all child orgs (content visibility walks up the tree)
  • Sibling orgs are isolated from each other (no cross-sibling visibility)
  • Each org has its own admin, branding, billing, and registration mode
  • Org discoverability is opt-in (churches choose whether to appear in search)
  • The default native app and the root webapp domain (yourapp.app) connect to the platform tenant’s root org

3. Domain structure

DomainPurpose
yourapp.ioMarketing site, status page, docs
yourapp.appRoot webapp → platform tenant’s root org
{slug}.yourapp.appOrg-specific webapp (slug resolves to org → tenant derived server-side)

4. Default app UX

A user opens the default (platform-branded) native app or yourapp.app:

  1. Logs in via Zitadel
  2. App calls GET /api/v1/me/organizations → list of orgs (across all tenants)
  3. If one org → auto-select, skip picker
  4. If multiple orgs → org picker
  5. All subsequent API requests include X-Organization-Id header (see ADR 015)

5. Cross-tenant org migration is a first-class operation

Moving an org (with all its data) from one tenant to another is expected and supported. Scenarios:

  • Small church outgrows the platform tenant → extract into own tenant
  • Independent church joins a denomination → move into denomination tenant
  • Denomination restructures → move orgs between tenants
  • Superadmin consolidates duplicate tenants

What “move org to another tenant” means

Because content is org-scoped (not tenant-scoped, see ADR 013), moving an org is lightweight:

What changes:
├── Organization.tenant_id (and child orgs)
├── User records (re-provisioned in target tenant)
└── OrgMemberships (updated to point to new user records if needed)

What does NOT change (org-scoped, stays untouched):
├── Groups (scoped by organization_id)
├── Events (scoped by organization_id)
├── News posts, Media, etc. (all org-scoped)
└── Audit trail (stays with the org)

User conflict resolution

A person (same Zitadel externalAuthId) might already have a User in the target tenant:

ScenarioResolution
User exists only in source tenantUpdate tenant_id on their User row
User exists in both tenantsMerge: add OrgMemberships from source User to target User, archive source User
User has roles in bothKeep the higher role per org

Why this is feasible

The architecture supports it because:

  • Content is org-scoped (organization_id), not tenant-scoped — no content rows to update
  • Cross-module references use string UUIDs (no foreign keys across modules)
  • Entity IDs (UUIDs) don’t change — only org.tenant_id changes
  • User re-provisioning is handled by the existing OIDC auto-provision flow

Implementation approach

  1. Superadmin-only operation (CLI command + admin UI)
  2. Preview step: show affected data counts, user conflicts
  3. Execution: wrapped in a transaction per module, coordinated by a process manager
  4. Domain event: OrgMigratedToTenant { orgId, sourceT tenantId, targetTenantId, affectedUserCount, ... }
  5. Each module listens: updates its own tenant_id columns
  6. Post-migration: source tenant’s subdomain redirects to target (if source becomes empty → archive)

6. Billing lives on Organization, not Tenant

Each org can have its own subscription/billing:

ScenarioBilling model
Independent church in default tenantOrg pays its own subscription
Denomination, per-org billingEach org pays separately
Denomination, central billingRoot org pays, covers all children

subscriptionTier is removed from the Tenant model. Billing is a future module that attaches to Organization.

Consequences

  • The platform tenant is created during initial deployment (seed data), not via /register
  • The domain model doc must be updated: remove subscriptionTier, add platform tenant concept, upgrade cross-tenant move from “nuclear option” to “first-class operation”
  • Future billing module must support per-org billing, not just per-tenant
  • Org discoverability settings needed (opt-in for platform tenant, default-on for denomination tenants)
  • The TenantSubscriptionChanged domain event is deferred until billing is designed

Relationship to other ADRs

  • ADR 013 (Tenant Scoping): Unchanged. After migration, all data is in the new tenant — scoping works as before.
  • ADR 014 (Registration Workflows): The platform tenant adds a nuance: “join tenant” flow on the default app resolves to the platform tenant.
  • ADR 015 (Tenant Context Resolution): The default app and root webapp domain resolve to the platform tenant’s UUID.