ADR 013: Data Scoping Strategy — Organization-Scoped Content
Status
Accepted (updated 2026-03-12: shifted primary content scope from tenant to organization)
Date
2026-03-11 (updated 2026-03-12)
Context
The platform is multi-tenant. Initially, every resource was scoped by tenant_id. After deeper analysis of real-world usage (independent churches, denominations, org restructuring, org migration between tenants), we identified that Organization is the natural content boundary, while Tenant remains the identity/isolation boundary.
Key insight: content belongs to a church (org), not to a platform grouping (tenant). When a church moves between tenants, its content shouldn’t need touching.
Decision
1. Two scoping layers
| Layer | Scope | Used for |
|---|---|---|
| Tenant | Identity & isolation | Users, auth, API keys, custom domains, white-label config |
| Organization | Content & membership | Groups, Events, News, Media, OrgMemberships |
2. Content tables use organization_id, not tenant_id
// Content repositories — scoped by organization
public function findById(GroupId $id, OrganizationId $orgId): ?Group;
public function findById(EventId $id, OrganizationId $orgId): ?Event;
// Identity repositories — scoped by tenant
public function findById(UserId $id, ?TenantId $tenantId = null): ?User;
tenant_id is not stored on content tables. The tenant is derivable: content → org → org.tenant_id.
3. Content visibility follows the org tree upward
A user in org X sees content from org X and all ancestor orgs (parent, grandparent, up to root). Never sibling content.
-- Find ancestor org IDs using ltree
SELECT id FROM organization_organizations WHERE path @> :currentOrgPath
-- Content query
SELECT * FROM events WHERE organization_id IN (:orgAndAncestorIds)
4. Command-level enforcement
Every content write command carries organizationId:
final readonly class UpdateGroupCommand
{
public function __construct(
public string $organizationId, // from SecurityUser's current org context
public string $groupId,
// ...
) {}
}
5. API headers
Authorization: Bearer <jwt> → identity (who)
X-Organization-Id: <uuid> → context (which org — tenant derived server-side)
The authenticator loads the org, derives tenantId from org.tenant_id, and resolves the user within that tenant. See ADR 015 for details.
6. Identity scoping remains tenant-based
Users, API keys, and auth flows remain tenant-scoped. A user’s identity (email, roles, external ID) is per-tenant. Their org memberships (which churches they belong to) are per-org within that tenant.
Consequences
- Content modules (Groups, Events, future News/Media) use
organization_idas primary scope - Org migration between tenants becomes trivial (only
org.tenant_idchanges, content untouched) - Cross-tenant data access is prevented by org→tenant validation in the authenticator
- New content modules must use
organization_idscoping (nottenant_id) - Identity modules (Users, API keys) continue using
tenant_idscoping