ADR 013: Data Scoping Strategy — Organization-Scoped Content

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

LayerScopeUsed for
TenantIdentity & isolationUsers, auth, API keys, custom domains, white-label config
OrganizationContent & membershipGroups, 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_id as primary scope
  • Org migration between tenants becomes trivial (only org.tenant_id changes, content untouched)
  • Cross-tenant data access is prevented by org→tenant validation in the authenticator
  • New content modules must use organization_id scoping (not tenant_id)
  • Identity modules (Users, API keys) continue using tenant_id scoping

Migration Path

Phase 1: Add organization_id alongside tenant_id on content tables

Phase 2: Migrate queries from tenant_id to organization_id

Phase 3: Drop tenant_id from content tables