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
| Category | Example | Created by | Orgs are |
|---|---|---|---|
| Platform tenant | The default tenant | Platform team (superadmin) | Independent churches, each with own billing/branding |
| Denomination tenant | ICF Movement, FEG Switzerland | Denomination leader via /register | Legally independent churches, own billing possible |
| Single-church tenant | A multi-site church | Church leader via /register | Locations of the same legal entity |
| Temporary tenant | Summer Camp 2026 | Organizer | Event-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
| Domain | Purpose |
|---|---|
yourapp.io | Marketing site, status page, docs |
yourapp.app | Root webapp → platform tenant’s root org |
{slug}.yourapp.app | Org-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:
- Logs in via Zitadel
- App calls
GET /api/v1/me/organizations→ list of orgs (across all tenants) - If one org → auto-select, skip picker
- If multiple orgs → org picker
- All subsequent API requests include
X-Organization-Idheader (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:
| Scenario | Resolution |
|---|---|
| User exists only in source tenant | Update tenant_id on their User row |
| User exists in both tenants | Merge: add OrgMemberships from source User to target User, archive source User |
| User has roles in both | Keep 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_idchanges - User re-provisioning is handled by the existing OIDC auto-provision flow
Implementation approach
- Superadmin-only operation (CLI command + admin UI)
- Preview step: show affected data counts, user conflicts
- Execution: wrapped in a transaction per module, coordinated by a process manager
- Domain event:
OrgMigratedToTenant { orgId, sourceT tenantId, targetTenantId, affectedUserCount, ... } - Each module listens: updates its own
tenant_idcolumns - 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:
| Scenario | Billing model |
|---|---|
| Independent church in default tenant | Org pays its own subscription |
| Denomination, per-org billing | Each org pays separately |
| Denomination, central billing | Root 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
TenantSubscriptionChangeddomain 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.