Church App — Domain Model
Universal. Not church-specific. Works for denominations, scouts, youth organizations, clubs, movements. The domain language is generic. The UI labels are configurable per tenant.
Design Principles for the Domain
- Universal vocabulary. No “denomination”, “church”, “congregation” in the domain model. These are UI labels configured per tenant. The model uses structural terms: tenant, organization, group, event, user.
- Trees, not fixed levels. The organizational hierarchy is an arbitrary-depth tree, not a fixed 3-level structure. ICF has 4 levels (movement → country → city → campus). A local scout group has 2 (group → age sections). The model supports both.
- Everything is typed, types are configurable. An organization node can be a “church”, “campus”, “chapter”, “section” — the type is a tenant-configured label, not a domain concept. Same for groups (small group, volunteer team, community) and events (service, workshop, retreat, meeting).
- Relationships over rigid hierarchy. Groups can span organizations. Events can belong to multiple groups. Users can be in multiple organizations. The model is a graph, not a strict tree.
- Aggregate boundaries are about consistency. Each aggregate root protects its own invariants. Cross-aggregate consistency is eventual, via domain events.
- Reorganization is a first-class operation. Moving groups, restructuring the org tree, merging entities — these are expected operations, not edge cases. The model supports them with explicit domain events, cascading updates, and admin tooling.
- Two models for cross-tenant collaboration. Simple events (joint services, godi nights) use syndication: local RSVP, aggregate counts, consent-based name sharing. Complex events (camps, conferences) create their own temporary tenant with full features. Universal identity makes joining frictionless. Users always see exactly what data is shared and with whom.
- Identity is universal, membership is local. One person, one login (Zitadel), works across all tenant apps. But each tenant has its own User entity with its own profile, privacy settings, groups, giving, and data. Tenants don’t know about each other’s users. Deleting a user in tenant A has zero effect on tenant B.
- One platform, many shapes. Every tenant runs the same codebase. Tenant types (church, camp, conference, organization) are configuration templates that set default modules and UI labels. Any module can be toggled per tenant. The domain model doesn’t know if it’s powering a church or a scout camp.
Module Priority (Build Order)
| Priority | Module | Why this order |
|---|---|---|
| 1 | Organization Hierarchy | Foundation. Everything else references this. Must exist first. |
| 2 | People | Users need to exist before they can join anything. |
| 3 | Groups | The core engagement model. Groups are where community happens. |
| 4 | Events | Tightly coupled to groups and organizations. Needs both to exist. |
| 5 | News & Communication | Depends on the hierarchy (multi-level feeds). |
| 6 | Giving | Depends on organizations (funds per org) and users (donor identity). |
| 7 | Sunday / Service Experience | Special event type + media + live features. |
| 8 | Pinboard | Standalone, loosely coupled. Can come anytime. |
| 9 | Chat | Depends on groups (group chat) and users (1:1 chat). |
This document models priorities 1-4 only.
Strategic Note: Event/Camp Platform Opportunity
The founder built huulo.io (an event/camp web app) during his employment — the IP belongs to his employer. But the deep domain knowledge of event registration, cross-organization attendee management, camp logistics, and multi-party data sharing directly informs this platform’s design. The church app’s camp tenant model — with its universal naming, consent-based data sharing, and temporary tenant lifecycle — is an independently built, better-architected solution. If the church app succeeds, the event/camp features could be offered as a standalone product beyond the church market, serving scouts (BESJ, Cevi), youth organizations (Adonia), conferences, and any organization that runs events across multiple chapters or groups.
Bounded Context Map
┌────────────────────────────────────────────────────────────────────┐
│ EXTERNAL: IDENTITY CONTEXT (Zitadel — shared across all tenants) │
│ │
│ ZitadelUser, Authentication, Passkeys, Social Login, Sessions │
│ → Universal login. One person, one identity, many tenant apps. │
└───────────────────────────────┬────────────────────────────────────┘
│ externalAuthId (JWT sub claim)
▼
┌────────────────────────────────────────────────────────────────────┐
│ BOUNDED CONTEXTS (all tenant-scoped below) │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ ORGANIZATION │ │ PEOPLE │ │
│ │ CONTEXT │ │ CONTEXT │ │
│ │ │ │ │ │
│ │ Tenant │ │ User │ │
│ │ Organization │ │ UserProfile │ │
│ │ OrgType │ │ PrivacySettings │ │
│ │ OrgHierarchy │ │ OrgMembership │ │
│ │ OrgSettings │ │ │ │
│ └────────┬─────────┘ └─────────┬─────────┘ │
│ │ │ │
│ │ ┌───────────────────┘ │
│ ▼ ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ COMMUNITY │ │ ACTIVITY │ │
│ │ CONTEXT │ │ CONTEXT │ │
│ │ │ │ │ │
│ │ Group │ │ Event │ │
│ │ GroupType │ │ EventType │ │
│ │ GroupMembership │ │ EventOccurrence │ │
│ │ GroupHierarchy │ │ RSVP │ │
│ │ │ │ LinkedEvent │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ Communication between contexts: DOMAIN EVENTS only. │
│ Each context has its own read models for data it needs from others.│
└────────────────────────────────────────────────────────────────────┘
1. Organization Context
The Hierarchy
This is the foundational structure. Everything else hangs off this tree.
REAL-WORLD EXAMPLES → DOMAIN MODEL
ICF Movement (app) Scout Canton Zürich (app) Adonia (app)
├── ICF Switzerland ├── Pfadi Winterthur ├── Adonia Romandie
│ ├── ICF Zürich │ ├── Wölfe ├── Adonia Deutschschweiz
│ │ ├── ICF Zürich City │ ├── Pfadis │ ├── Musical Tour 2026
│ │ ├── ICF Zürich Oerlikon │ └── Pios │ └── Camp Sommer 2026
│ │ └── Micro Church West ├── Pfadi Zürich └── Adonia Ticino
│ ├── ICF Basel │ ├── Wölfe
│ └── ICF Bern │ └── Rover
├── ICF Germany └── Pfadi Uster
│ ├── ICF München
│ │ ├── ICF München Mitte
│ │ └── ICF München Ost
│ └── ICF Berlin
└── ICF Austria
└── ICF Wien
ALL of these map to the same domain model:
Tenant → tree of Organization nodes with configurable types.
Aggregate: Tenant
The billing and configuration entity. One per app store deployment. Not visible to users in most UIs.
TENANT (Aggregate Root)
├── id: TenantId (UUID)
├── name: string — "ICF Movement", "Summer Camp 2026"
├── slug: string — "icf", "camp-summer-2026"
├── type: TenantType — church, camp, conference, organization
│ — Determines default module set + UI labels at creation
│ — NOT a constraint: modules can be toggled after creation
├── lifecycle: TenantLifecycle — permanent | temporary { endsAt, retentionDays }
├── parentTenantId: TenantId | null — which tenant created this (for camp → church link)
├── branding: TenantBranding (VO)
│ ├── primaryColor: HexColor
│ ├── accentColor: HexColor
│ ├── logoUrl: Url
│ └── darkMode: boolean
├── defaultLocale: Locale — "de", "en", "fr"
├── supportedLocales: Locale[] — ["de", "en", "fr", "pt"]
├── orgTypeLabels: Map<OrgTypeKey, LocalizedLabel>
│ — Maps generic type keys to tenant-specific display names:
│ — "root" → "Bewegung" / "Movement"
│ — "region" → "Land" / "Country"
│ — "branch" → "Kirche" / "Church" / "Abteilung" / "Chapter"
│ — "location" → "Standort" / "Campus" / "Lokal"
│ — "micro" → "Micro Church" / "Kleingruppe"
├── groupTypeLabels: Map<GroupTypeKey, LocalizedLabel>
│ — "small-group" → "Kleingruppe" / "Small Group" / "Meute"
│ — "team" → "Team" / "Volunteer Team" / "Equipe"
│ — "community" → "Community" / "Gemeinschaft"
│ — "class" → "Kurs" / "Course" / "Stufe"
├── eventTypeLabels: Map<EventTypeKey, LocalizedLabel>
│ — "service" → "Gottesdienst" / "Service" / "Gruppenstunde"
│ — "workshop" → "Workshop" / "Atelier"
│ — "retreat" → "Retreat" / "Lager" / "Camp"
│ — "conference" → "Konferenz" / "Conference"
│ — "meeting" → "Treffen" / "Meeting" / "Sitzung"
├── moduleEntitlements: ModuleEntitlement[]
│ — The single source of truth for what this tenant can use
│ ├── moduleId: string — "giving", "chat", "sunday", "ai-admin"
│ ├── source: EntitlementSource — subscription | addon | trial | granted
│ │ — subscription: included in current tier (auto-managed by Stripe webhook)
│ │ — addon: separately purchased (e.g., "AI Admin Pack" for €19/mo)
│ │ — trial: time-limited free access (30 days, then reverts)
│ │ — granted: manually enabled by superadmin (pilot churches, partners)
│ ├── enabled: boolean — admin can still disable an entitled module
│ ├── limits: ModuleLimits | null — usage caps, quotas
│ │ ├── maxUsagePerMonth: int | null — e.g., AI translations: 500/month
│ │ ├── maxStorage: int | null — e.g., media storage: 5GB
│ │ └── maxMembers: int | null — e.g., chat: 500 concurrent
│ ├── expiresAt: DateTime | null — for trials and time-limited addons
│ └── currentUsage: UsageCounter — tracked for limit enforcement
├── status: TenantStatus — active, trial, suspended
├── [subscriptionTier: deferred — billing designed later with per-org billing in mind]
├── createdAt: DateTime
│
├── INVARIANTS:
│ — slug is globally unique
│ — must have at least one supported locale
│ — must have at least one root organization
│ — temporary tenants must have an endsAt date
│ — parentTenantId (if set) must reference an active tenant
│
└── EVENTS:
— TenantCreated { tenantId, type, lifecycle, parentTenantId }
— TenantBrandingChanged
— TenantModuleToggled
— TenantSubscriptionChanged
— TenantArchived { tenantId, retentionUntil }
— TenantPurged { tenantId }
Aggregate: Organization
A node in the organizational tree. Self-referencing via parentId. The type determines the UI label but not the behavior — all nodes work the same way structurally.
ORGANIZATION (Aggregate Root)
├── id: OrganizationId (UUID)
├── tenantId: TenantId — which tenant this belongs to
├── parentId: OrganizationId | null — null = root organization
├── type: OrgTypeKey — "root", "region", "branch", "location", "micro"
│ — Display label comes from tenant.orgTypeLabels[type][locale]
├── name: string — "ICF Zürich", "Pfadi Winterthur"
├── slug: string — "icf-zurich" (unique within tenant)
├── description: TranslatableText | null
├── location: PhysicalLocation | null — address, coordinates (for map/finder)
│ ├── address: Address (VO)
│ ├── coordinates: GeoPoint (VO)
│ └── timezone: Timezone
├── contactEmail: EmailAddress | null
├── contactPhone: PhoneNumber | null
├── coverImageUrl: Url | null
├── settings: OrgSettings (VO)
│ ├── defaultLocale: Locale — overrides tenant default
│ ├── privacyDefaults: PrivacyDefaults — default privacy settings for new users
│ ├── modulesOverride: ModuleConfig[] — can disable modules enabled at tenant level
│ └── customFields: JsonObject — tenant-specific configuration
├── sortOrder: int — sibling ordering within parent
├── status: OrgStatus — active, archived
├── createdAt: DateTime
├── updatedAt: DateTime
│
├── INVARIANTS:
│ — slug is unique within tenant
│ — parentId must reference an org in the same tenant
│ — root org (parentId = null): exactly one per tenant, or multiple if tenant is multi-root
│ — cannot be its own parent (no cycles)
│ — max tree depth: configurable per tenant (default: 5)
│
├── EVENTS:
│ — OrganizationCreated { tenantId, orgId, parentId, type, name }
│ — OrganizationRenamed { orgId, oldName, newName }
│ — OrganizationMoved { orgId, oldParentId, newParentId }
│ — OrganizationArchived { orgId }
│ — OrganizationSettingsChanged { orgId, changedFields }
│
├── NOTES:
│ — **Admin role cascading:** An org admin of a parent node (e.g., ICF Zürich) automatically
│ has admin access to all descendant orgs (e.g., ICF Zürich City, ICF Zürich Oerlikon)
│ via ltree ancestor queries. Tenant admins have admin access to all organizations
│ within the tenant.
│
└── QUERIES (read model):
— getTree(tenantId) → full tree for admin
— getChildren(orgId) → immediate children
— getAncestors(orgId) → path to root (for breadcrumbs, permission inheritance)
— getBySlug(tenantId, slug) → resolve URL to org
Value Objects (Organization Context)
TenantId — UUID wrapper
TenantType — enum: church, camp, conference, organization (extensible)
TenantLifecycle — { mode: permanent | temporary, endsAt: DateTime | null, retentionDays: int }
OrganizationId — UUID wrapper
OrgTypeKey — string enum, tenant-configurable ("root", "region", "branch", "location", "micro", custom...)
OrgStatus — enum: active, archived
PhysicalLocation — { address: Address, coordinates: GeoPoint, timezone: Timezone }
Address — { street, city, postalCode, country, canton/state }
GeoPoint — { latitude: float, longitude: float }
TranslatableText — { sourceLocale: Locale, texts: Map<Locale, string> }
LocalizedLabel — Map<Locale, string> — e.g. {"de": "Kirche", "en": "Church", "fr": "Église"}
HexColor — string, validated: #RRGGBB
Tree Implementation Strategy
-- Adjacency list (simplest, works for < 10K nodes which covers all use cases)
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
parent_id UUID REFERENCES organizations(id),
type VARCHAR(30) NOT NULL,
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) NOT NULL,
description JSONB, -- TranslatableText
location JSONB, -- PhysicalLocation
settings JSONB NOT NULL DEFAULT '{}',
sort_order INT NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- For efficient tree queries: materialized path
-- "/icf/icf-ch/icf-zurich/icf-zurich-city"
path LTREE NOT NULL,
depth INT NOT NULL DEFAULT 0,
UNIQUE(tenant_id, slug),
CONSTRAINT no_self_parent CHECK (parent_id != id)
);
-- PostgreSQL ltree extension for efficient tree queries
CREATE EXTENSION IF NOT EXISTS ltree;
CREATE INDEX idx_org_path ON organizations USING gist(path);
CREATE INDEX idx_org_tenant_parent ON organizations(tenant_id, parent_id);
-- Example queries:
-- All descendants of ICF Zürich:
-- SELECT * FROM organizations WHERE path <@ 'icf.icf_ch.icf_zurich';
-- All ancestors (breadcrumb):
-- SELECT * FROM organizations WHERE 'icf.icf_ch.icf_zurich.icf_zurich_city' <@ path;
-- Direct children:
-- SELECT * FROM organizations WHERE parent_id = :orgId ORDER BY sort_order;
Why ltree? PostgreSQL’s ltree extension gives you O(1) subtree queries, ancestor queries, and depth calculations — without recursive CTEs. Perfect for org trees with < 10K nodes. The path column is maintained by the application on insert/move, and gives the database superpowers for querying.
2. People Context
Note: The tenant-scoped entity is called User, not Member. Everyone who uses the platform has a login (via Zitadel), making User the natural name. The word ‘membership’ is preserved for relationships (OrgMembership, GroupMembership).
Universal Identity vs. Tenant-Scoped Membership
This is a critical architectural distinction:
┌─────────────────────────────────────────────────────────────────────┐
│ ZITADEL (Identity Provider — exists OUTSIDE all tenants) │
│ │
│ Person: Sarah Müller │
│ ├── zitadel_user_id: "auth-uuid-sarah" │
│ ├── email: sarah@example.com │
│ ├── passkey: ✅ registered │
│ ├── social login: Google ✅ │
│ └── ONE identity. ONE login. Works in ANY app. │
│ │
└──────────────┬─────────────────────────────┬────────────────────────┘
│ │
same login │ │ same login
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ TENANT: ICF Movement │ │ TENANT: FEG Schweiz │
│ (ICF App in App Store) │ │ (FEG App in App Store) │
│ │ │ │
│ User: Sarah Müller │ │ User: Sarah Müller │
│ ├── userId: "icf-123" │ │ ├── userId: "feg-456" │
│ ├── externalAuthId: │ │ ├── externalAuthId: │
│ │ "auth-uuid-sarah" │ │ │ "auth-uuid-sarah" │
│ ├── orgs: ICF Zürich │ │ ├── orgs: FEG Winterthur │
│ ├── groups: Video Team, │ │ ├── groups: Worship Team │
│ │ Small Group Alpha │ │ ├── giving: separate │
│ ├── giving: separate │ │ ├── privacy: separate │
│ ├── privacy: separate │ │ └── completely separate │
│ ├── chat history: here │ │ data silo │
│ └── completely separate │ │ │
│ data silo │ │ │
└──────────────────────────┘ └──────────────────────────┘
KEY RULES:
├── ONE Zitadel user → MANY tenant memberships
├── Each tenant has its own User entity (own profile, privacy, groups, giving)
├── Users in different tenants do NOT know about each other
├── No cross-tenant feed, no cross-tenant directory, no cross-tenant giving
├── The ONLY shared thing is the authentication identity (login credentials)
├── A user opens the ICF app → authenticates → sees ICF content only
│ Same user opens FEG app → authenticates (same passkey!) → sees FEG content only
└── Deleting a User in tenant A has ZERO effect on tenant B
How it works technically:
-
Zitadel is deployed once, shared across all tenants. It’s a single identity service (or cluster) that all apps authenticate against. Zitadel’s own “Organization” concept maps to our tenants for user management, but the user identity is global.
-
Each app (one per tenant) uses the same Zitadel instance as its OIDC provider. The app sends the user through the Zitadel login flow. Zitadel returns a JWT with the user’s
sub(subject = Zitadel user ID). -
The Symfony API receives the JWT, extracts the
sub, and looks up: “Does a User withexternalAuthId = subexist in this tenant?” If yes → authenticated. If no → new user onboarding flow. -
First login in a new tenant app:
- User downloads the FEG app.
- Logs in with same email/passkey as in the ICF app.
- Zitadel authenticates them (they already have an account).
- FEG API checks: no User with this
externalAuthIdin the FEG tenant. - → Onboarding: “Welcome! Choose your FEG church location” → creates a new User entity in the FEG tenant, linked to the same Zitadel user.
- Zero data imported from ICF. Clean start. Different profile photo if they want.
-
Session handling: Each app maintains its own session/token pair. Being logged into the ICF app doesn’t auto-log you into the FEG app. But the login process is instant because Zitadel has an existing session — the user taps “log in,” biometrics/passkey fires, done. No password re-entry.
What we explicitly do NOT build:
- No universal event feed across tenants. (That’s what event syndication is for.)
- No cross-tenant user directory or search.
- No “you’re also a user of ICF” badge in the FEG app.
- No shared giving history, chat history, or group memberships.
Exceptions for the platform tenant / default app:
- The default native app and root webapp (
yourapp.app) DO have a tenant/org picker — because they serve multiple tenants and the platform tenant hosts multiple independent churches. - Denomination-branded apps still know their tenant. No picker needed there.
What the user experiences:
- “I use the same face/fingerprint/passkey to log into both apps. Convenient.”
- “My ICF stuff is in the ICF app. My FEG stuff is in the FEG app. They don’t mix.”
- “If I delete my account in one app, the other is unaffected.”
Aggregate: User
A person within a specific tenant. Has one identity (via Zitadel) but a fully independent profile, memberships, and data per tenant.
USER (Aggregate Root)
├── id: UserId (UUID)
├── tenantId: TenantId
├── externalAuthId: string — Zitadel user ID (global across tenants)
├── profile: UserProfile (VO)
│ ├── firstName: string
│ ├── lastName: string
│ ├── displayName: string | null — optional override
│ ├── email: EmailAddress
│ ├── phone: PhoneNumber | null
│ ├── avatarUrl: Url | null
│ ├── dateOfBirth: Date | null
│ ├── locale: Locale — preferred language
│ └── bio: string | null
├── privacySettings: PrivacySettings (VO)
│ ├── profileVisibility: Visibility — public (within tenant), org-only, minimal
│ ├── emailVisibility: Visibility
│ ├── phoneVisibility: Visibility
│ ├── searchable: boolean — appear in directory search
│ └── contactableByNonGroupMembers: boolean
├── notificationPreferences: NotificationPreferences (VO)
│ ├── channels: Map<NotificationType, Channel[]>
│ ├── digestMode: DigestMode — instant, daily, weekly
│ └── quietHours: TimeRange | null
├── organizationMemberships: OrgMembership[]
│ ├── organizationId: OrganizationId
│ ├── role: OrgRole — admin, leader, member, guest
│ ├── joinedAt: DateTime
│ └── status: MembershipStatus — active, inactive, pending
├── status: UserStatus — active, suspended, deleted
├── createdAt: DateTime
├── updatedAt: DateTime
│
├── INVARIANTS:
│ — must belong to at least one organization within the tenant
│ — email is unique within tenant
│ — externalAuthId is unique within tenant (one User per Zitadel user per tenant)
│ — same externalAuthId CAN exist in multiple tenants (that's the whole point)
│ — cannot be admin of an org without being a member of it
│ — privacy settings respect org-level minimums (org can require email visible to leaders)
│
├── EVENTS:
│ — UserRegistered { tenantId, userId, orgId, email }
│ — UserProfileUpdated { userId, changedFields }
│ — UserJoinedOrganization { userId, orgId, role }
│ — UserLeftOrganization { userId, orgId }
│ — UserRoleChanged { userId, orgId, oldRole, newRole }
│ — UserSuspended { userId, reason }
│ — UserDeleted { userId } — triggers GDPR cascade within THIS tenant only
│ — UserPrivacySettingsChanged { userId, changes }
│
└── NOTES:
— A user joining ICF Zürich does NOT automatically join ICF Movement (root).
Organization membership is explicit per node.
But CONTENT from parent orgs cascades down (see News context).
— A user can belong to multiple organizations (e.g., helps at two campuses).
— The distinction between "org membership" and "group membership" is intentional:
org membership = "I belong to this location/chapter"
group membership = "I'm part of this team/small group/community"
— Deleting a User in tenant A does NOT affect the same person's User in tenant B.
The Zitadel user continues to exist. Only the tenant-scoped User is removed.
If the person wants to fully delete their Zitadel identity, that's a separate action
in Zitadel (which then triggers deletion cascades in ALL tenants via webhook).
Value Objects (People Context)
UserId — UUID wrapper
UserProfile — { firstName, lastName, displayName, email, phone, avatar, dob, locale, bio }
PrivacySettings — { profileVisibility, emailVisibility, phoneVisibility, searchable, contactable }
NotificationPreferences — { channels, digestMode, quietHours }
OrgMembership — { organizationId, role, joinedAt, status }
OrgRole — enum: admin, leader, member, guest
MembershipStatus — enum: active, inactive, pending
Visibility — enum: public, org-only, leaders-only, hidden
DigestMode — enum: instant, daily, weekly
EmailAddress — validated string, value object
PhoneNumber — validated string (E.164 format), value object
3. Community Context (Groups)
Aggregate: Group
The core engagement unit. A group is where community happens — small groups, volunteer teams, communities, classes, age sections, anything.
GROUP (Aggregate Root)
├── id: GroupId (UUID)
├── tenantId: TenantId
├── organizationId: OrganizationId — primary org this group belongs to
│ — A group always belongs to at least one org node.
│ — "Creative Community" belongs to "ICF Zürich".
│ — "Wölfe" belongs to "Pfadi Winterthur".
├── parentGroupId: GroupId | null — for sub-groups (tree structure)
│ — "Video Team" has parentGroup = "Creative Community"
│ — "Wölfe Trupp 1" has parentGroup = "Wölfe"
├── type: GroupTypeKey — "small-group", "team", "community", "class", custom...
│ — Display label from tenant.groupTypeLabels[type][locale]
├── name: string
├── slug: string — unique within organization
├── description: TranslatableText | null
├── coverImageUrl: Url | null
├── visibility: GroupVisibility
│ ├── OPEN — anyone in the org can see and join
│ ├── REQUEST — visible, but joining requires approval
│ ├── INVITE_ONLY — only visible to members + invitees
│ └── HIDDEN — only visible to members (e.g., leadership team)
├── settings: GroupSettings (VO)
│ ├── maxMembers: int | null — capacity limit
│ ├── allowSubGroups: boolean
│ ├── enableChat: boolean — group chat on/off
│ ├── enablePinboard: boolean — group pinboard on/off
│ ├── enableEvents: boolean — group events on/off
│ ├── meetingSchedule: RecurrenceRule | null — "every Tuesday 19:30"
│ ├── location: PhysicalLocation | null — where this group meets
│ └── customFields: JsonObject
├── memberships: GroupMembership[]
│ ├── userId: UserId
│ ├── role: GroupRole — leader, co-leader, member, guest
│ ├── joinedAt: DateTime
│ └── status: GroupMembershipStatus — active, inactive, pending
├── relatedGroups: GroupRelation[] — non-hierarchical links
│ ├── relatedGroupId: GroupId
│ └── relationType: string — "collaborates-with", "feeds-into", "sister-group"
├── tags: string[] — for discovery/filtering
├── status: GroupStatus — active, archived, draft
├── createdAt: DateTime
├── updatedAt: DateTime
│
├── INVARIANTS:
│ — slug unique within organization
│ — parentGroupId must be in the same organization (or same tenant at minimum)
│ — cannot be its own parent (no cycles)
│ — at least one member with role = leader
│ — users must be members of the group's organization (or its ancestors)
│ — max depth for sub-groups: configurable (default: 3)
│
├── EVENTS:
│ — GroupCreated { tenantId, orgId, groupId, parentGroupId, type, name }
│ — GroupUpdated { groupId, changedFields }
│ — GroupArchived { groupId }
│ — UserJoinedGroup { groupId, userId, role }
│ — UserLeftGroup { groupId, userId }
│ — GroupRoleChanged { groupId, userId, oldRole, newRole }
│ — GroupMembershipRequested { groupId, userId }
│ — GroupMembershipApproved { groupId, userId, approvedBy }
│ — SubGroupCreated { parentGroupId, childGroupId }
│ — GroupMovedToOrganization { groupId, oldOrgId, newOrgId }
│
└── RELATIONSHIPS (the creative community example):
Organization: ICF Zürich
│
├── Group: Creative Community (type: "community")
│ ├── Sub-Group: Video Team (type: "team")
│ ├── Sub-Group: Photo Team (type: "team")
│ ├── Sub-Group: Visual Design Team (type: "team")
│ └── Sub-Group: Stage Team (type: "team")
│
│ The Creative Community has an Event: "Creative Night" (monthly)
│ Each sub-group has an Event: "Workshop" at the Creative Night
│ → The Creative Night is the parent event
│ → The Workshops are child events
│ → A user in the Video Team sees:
│ - "Video Workshop" in their "My Events"
│ - "Creative Night" as the parent context
│ - Other workshops as "related events" they could join
Scout equivalent:
Organization: Pfadi Winterthur
│
├── Group: Wölfe (type: "age-section", ages 7-11)
│ ├── Sub-Group: Trupp Adler
│ └── Sub-Group: Trupp Bär
│
│ The Wölfe group has an Event: "Gruppenstunde" (weekly)
│ Each Trupp might have specific activities within it.
Cross-Organization Groups
Some groups span multiple organizations. A “Youth Leaders Network” might include leaders from ICF Zürich, ICF Basel, and ICF Bern. How to model:
Option A: Group belongs to the nearest common ancestor. The youth leaders group belongs to “ICF Switzerland” (parent of all three cities). Users from child orgs can join.
Option B: Multi-org membership. A group has a primary org but can include users from other orgs within the same tenant. This is simpler and covers the common case.
Recommendation: Option A for now. A group always belongs to one org node. If that node is higher in the tree (e.g., the tenant root), the group effectively spans all children. This keeps the model simple and the permission model clean (the group inherits from its org’s context).
4. Activity Context (Events)
Aggregate: Event
Something that happens. Can be one-time or recurring. Can belong to an organization (church-wide), a group (team meeting), or both. Can have child events (conference → sessions, creative night → workshops).
EVENT (Aggregate Root)
├── id: EventId (UUID)
├── tenantId: TenantId
├── organizationId: OrganizationId — which org level this event lives at
│ — Tenant-wide conference: orgId = root org
│ — Church service: orgId = specific location
│ — Group meeting: orgId = group's org (event also linked via groupId)
├── groupId: GroupId | null — optional link to a group
│ — "Creative Night" → groupId = Creative Community
│ — "Sunday Service" → groupId = null (org-level event)
│ — "Video Workshop" → groupId = Video Team
├── parentEventId: EventId | null — for sub-events / sessions
│ — "Video Workshop" parentEvent = "Creative Night"
│ — "Conference Session A" parentEvent = "Annual Conference"
├── type: EventTypeKey — "service", "workshop", "retreat", "meeting", custom...
│ — Display label from tenant.eventTypeLabels[type][locale]
├── title: string
├── slug: string
├── description: TranslatableText | null
├── coverImageUrl: Url | null
│
├── scheduling: EventSchedule (VO)
│ ├── EITHER single occurrence:
│ │ ├── startAt: DateTime
│ │ ├── endAt: DateTime
│ │ └── timezone: Timezone
│ ├── OR recurring (generates occurrences):
│ │ ├── recurrenceRule: RecurrenceRule (iCal RRULE compatible)
│ │ │ — "FREQ=WEEKLY;BYDAY=TU;DTSTART=20260315T193000"
│ │ ├── duration: Duration
│ │ ├── exceptions: DateTime[] — skipped dates
│ │ └── overrides: Map<DateTime, OccurrenceOverride> — date-specific changes
│ └── allDay: boolean — for multi-day retreats, camps
│
├── location: EventLocation (VO)
│ ├── type: "physical" | "online" | "hybrid"
│ ├── physicalLocation: PhysicalLocation | null
│ ├── onlineUrl: Url | null — Zoom link, YouTube stream, etc.
│ └── locationNote: string | null — "Room 3, 2nd floor"
│
├── registration: EventRegistration (VO) | null
│ ├── required: boolean — must RSVP, or just show up?
│ ├── maxCapacity: int | null
│ ├── waitlistEnabled: boolean
│ ├── registrationOpensAt: DateTime | null
│ ├── registrationClosesAt: DateTime | null
│ ├── formFields: FormField[] | null — custom registration questions
│ └── ticketPrice: Money | null — for paid events (connects to Giving)
│
├── rsvps: RSVP[] — tracked per occurrence for recurring events
│ ├── userId: UserId
│ ├── occurrenceDate: Date | null — which occurrence (null = single event)
│ ├── status: RSVPStatus — attending, maybe, declined, waitlisted
│ ├── respondedAt: DateTime
│ └── formResponses: JsonObject | null
│
├── visibility: EventVisibility
│ ├── PUBLIC — visible to all org members (and sub-org members)
│ ├── GROUP_ONLY — visible only to group members (if groupId set)
│ └── INVITE_ONLY — visible only to explicitly invited members
│
├── tags: string[]
├── status: EventStatus — draft, published, cancelled, archived
├── createdAt: DateTime
├── updatedAt: DateTime
│
├── INVARIANTS:
│ — slug unique within organization
│ — startAt < endAt (for single events)
│ — if groupId set, group must belong to the same org (or a child org)
│ — parentEventId must be in the same org (or tenant for cross-org conferences)
│ — cannot be its own parent
│ — maxCapacity >= current attending count (soft: allow waitlist overflow)
│ — cancelled events cannot accept new RSVPs
│
├── EVENTS:
│ — EventCreated { tenantId, orgId, groupId, eventId, type, title, startAt }
│ — EventUpdated { eventId, changedFields }
│ — EventCancelled { eventId, reason }
│ — EventPublished { eventId }
│ — UserRSVPd { eventId, userId, status, occurrenceDate }
│ — UserCancelledRSVP { eventId, userId, occurrenceDate }
│ — EventCapacityReached { eventId }
│ — EventOccurrenceSkipped { eventId, date, reason }
│
└── RELATIONSHIPS (the multi-level example):
TENANT LEVEL (ICF Movement):
└── Event: "ICF Conference 2026" (type: "conference", org: ICF Movement root)
├── Child Event: "Main Session 1" (type: "session")
├── Child Event: "Workshop: Leadership" (type: "workshop")
└── Child Event: "Workshop: Youth Ministry" (type: "workshop")
ORGANIZATION LEVEL (ICF Zürich):
└── Event: "Sunday Service" (type: "service", org: ICF Zürich, recurring: weekly)
└── Event: "Baptism Sunday" (type: "service", org: ICF Zürich, single)
GROUP LEVEL (Creative Community → Video Team):
└── Event: "Creative Night" (type: "meeting", org: ICF Zürich, group: Creative Community)
├── Child Event: "Video Workshop" (group: Video Team)
├── Child Event: "Photo Workshop" (group: Photo Team)
└── Child Event: "Design Workshop" (group: Visual Design Team)
USER PERSPECTIVE (member of Video Team):
"My Events" shows:
├── Video Workshop (tomorrow 19:00) — direct group membership
│ └── Part of: Creative Night — parent event context
├── Sunday Service (Sunday 10:00) — org membership
└── ICF Conference (June 12-14) — tenant-level, visible to all
"Recommended" shows:
├── Photo Workshop (tomorrow 19:00) — sibling group event
└── Alpha Course (starts next month) — org-level, tagged "newcomer"
Recurring Events: Occurrence Model
Recurring events don’t pre-generate all occurrences. Instead, occurrences are computed from the RecurrenceRule and materialized on demand (when someone RSVPs or when the date approaches).
EventOccurrence (generated, not stored as entity unless modified):
├── eventId: EventId — the recurring event template
├── date: Date — this specific occurrence
├── startAt: DateTime — computed from rule + timezone
├── endAt: DateTime
├── isException: boolean — skipped?
├── override: OccurrenceOverride | null — date-specific changes
│ ├── title: string | null — "Special Easter Service" instead of "Sunday Service"
│ ├── description: TranslatableText | null
│ ├── location: EventLocation | null — different venue for this one
│ └── cancelled: boolean
└── rsvps: RSVP[] — RSVPs for this specific occurrence
-- Only materialize into DB when:
-- 1. Someone RSVPs for this occurrence
-- 2. An override is created for this date
-- 3. The occurrence is cancelled/modified
-- Otherwise, compute from the rule on read.
Cross-Context Relationships
┌─────────────────────────────────────────────────────────────┐
│ ZITADEL (Universal Identity — outside all tenants) │
│ │
│ ZitadelUser 1 ────── * User (across different tenants) │
│ One person, one login, many tenant memberships │
└──────────────────────────┬──────────────────────────────────┘
│ authenticates via externalAuthId
▼
┌─────────────────────────────────────────────────────────────┐
│ WITHIN A SINGLE TENANT (all data below is tenant-scoped): │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ TENANT │ 1─────── │ ORGANIZATION │ (tree of nodes) │
│ └──────────────┘ has └──────┬───────┘ │
│ │ │
│ has users belongs to │
│ │ │ │
│ ┌──────▼───────┐ │ │
│ │ USER │──────┘ │
│ └──────┬───────┘ │
│ │ │
│ is member of │
│ │ │
│ ┌──────▼───────┐ │
│ │ GROUP │ (tree, → org) │
│ └──────┬───────┘ │
│ │ │
│ has events │
│ │ │
│ ┌──────▼───────┐ │
│ │ EVENT │ (tree, → org+grp)│
│ └──────┬───────┘ │
│ │ │
│ can reference │
│ │ │
│ ┌──────▼───────┐ │
│ │ LINKED EVENT │ (from other │
│ │ │ tenant via │
│ │ │ syndication) │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
Key relationships:
├── ZitadelUser 1 ──── * User (one per tenant, linked via externalAuthId)
├── Tenant 1 ──── * Organization (tree)
├── Organization 1 ──── * User (via OrgMembership)
├── Organization 1 ──── * Group
├── Group 1 ──── * User (via GroupMembership)
├── Group 1 ──── * Sub-Group (self-referencing tree)
├── Organization 1 ──── * Event
├── Group 0..1 ──── * Event (optional link)
├── Event 1 ──── * Sub-Event (self-referencing tree)
├── Event 1 ──── * RSVP ──── 1 User
└── Organization 0..* ──── * LinkedEvent (syndicated from other tenants)
Reorganization Operations
Real-world organizations restructure constantly. ICF moves micro churches between campuses, promotes a campus to its own city org, merges two groups, or even spins off a country organization. The domain model must handle all of these as first-class operations, not as “delete and recreate.”
Reorg Scenario Matrix
| Scenario | Complexity | What breaks | Frequency |
|---|---|---|---|
| Move group to different parent group | Low | Nothing. Memberships, events, chat stay intact. | Common |
| Move group to different organization | Medium | Users might not be in the new org. Content visibility shifts. | Occasional |
| Move org node in the tree | Medium-High | ltree paths change for node + all descendants. Content cascade changes. | Occasional |
| Merge two groups | Medium | Two membership lists combined. Duplicate members resolved. | Rare |
| Split a group into two | Medium | Memberships need to be reassigned. | Rare |
| Promote micro church to full org node | Low-Medium | Was a group, becomes an org. Users convert from group membership to org membership. | Occasional at ICF |
| Demote org node to group | Medium | Inverse of above. Rare but happens. | Rare |
| Cross-tenant org migration | Medium-High | Update tenant_id on org subtree + all related data. Event-driven, each module updates its own rows. | Occasional |
Operation 1: Move Group to Different Parent Group
Example: “Video Team” moves from “Creative Community” to the new “Media Community.”
BEFORE: AFTER:
Creative Community Creative Community
├── Video Team ──────▶ ├── Photo Team
├── Photo Team └── Design Team
└── Design Team Media Community (new)
└── Video Team (moved)
What happens:
group.parentGroupIdchanges. That’s it.- All group memberships stay intact.
- All events linked to the Video Team stay linked.
- Chat history stays.
- The group might also need to change
organizationIdif the new parent is in a different org (see Operation 2).
Domain event: group.moved { groupId, oldParentGroupId, newParentGroupId }
Complexity: Low. Single field update + event.
Operation 2: Move Group to Different Organization
Example: A small group that was under “ICF Zürich City” moves to “ICF Zürich Oerlikon” because most members live there now.
BEFORE: AFTER:
ICF Zürich City ICF Zürich City
├── Small Group Alpha ──────▶ └── (other groups)
└── (other groups) ICF Zürich Oerlikon
ICF Zürich Oerlikon └── Small Group Alpha (moved)
What happens:
group.organizationIdchanges.- Group memberships stay, BUT: users in the group who are NOT members of the new org (Oerlikon) need handling.
- Option A (recommended): Auto-add group users to the new org with role “member” if they’re not already. Emit
user.joined_organizationevents for each. This is the friendly path — the group moves, people come with it. - Option B: Warn the admin: “3 users in this group are not members of ICF Zürich Oerlikon. Add them or remove them from the group?” Let the admin decide.
- Events linked to the group:
event.organizationIdupdates to match. Content visibility shifts accordingly. - The admin UI should show a preview: “Moving this group will affect 15 users, 3 upcoming events, and 142 chat messages.”
Domain events:
group.moved_to_organization { groupId, oldOrgId, newOrgId }
user.joined_organization { userId, orgId, role: "member" } — for each auto-added user
event.moved_to_organization { eventId, oldOrgId, newOrgId } — for each group event
Complexity: Medium. Cascading updates to users and events.
Operation 3: Move Organization Node in the Tree
Example: ICF restructures. “ICF München” used to be directly under “ICF Germany” but now goes under a new “ICF DACH” node.
BEFORE: AFTER:
ICF Movement ICF Movement
├── ICF Switzerland ├── ICF DACH (new)
├── ICF Germany │ ├── ICF Switzerland (moved)
│ ├── ICF München │ ├── ICF Germany
│ └── ICF Berlin │ │ ├── ICF München
└── ICF Austria │ │ └── ICF Berlin
│ └── ICF Austria (moved)
└── ICF Netherlands (new)
What happens:
organization.parentIdchanges for the moved nodes.- The ltree
pathmust be recalculated for the moved node AND all its descendants. This is the most important operation — get it wrong and tree queries break. - Content visibility changes: users of ICF München now see content from “ICF DACH” (new ancestor) and no longer inherit directly from “ICF Movement” (but DACH is under Movement, so they still do indirectly).
- User org memberships don’t change — people still belong to ICF München.
- Groups under moved orgs don’t change — they still belong to their org.
Implementation:
-- Move ICF Switzerland under ICF DACH
-- 1. Update the node's parentId
UPDATE organizations SET parent_id = :dachOrgId WHERE id = :switzerlandOrgId;
-- 2. Recalculate ltree paths for the subtree
-- Old path: icf.icf_ch
-- New path: icf.icf_dach.icf_ch
-- All descendants: icf.icf_ch.* → icf.icf_dach.icf_ch.*
UPDATE organizations
SET path = :newDachPath || subpath(path, nlevel(:oldSwitzerlandPath) - 1),
depth = nlevel(:newDachPath || subpath(path, nlevel(:oldSwitzerlandPath) - 1)) - 1
WHERE path <@ :oldSwitzerlandPath;
Domain events:
organization.moved { orgId, oldParentId, newParentId, oldPath, newPath }
organization.subtree_paths_recalculated { rootOrgId, affectedCount }
Complexity: Medium-High. The ltree update is a batch operation but well-defined. The content visibility shift is automatic (queries use the new paths). No user or group changes needed.
Operation 4: Merge Two Groups
Example: “Young Adults A” and “Young Adults B” merge into “Young Adults.”
BEFORE: AFTER:
├── Young Adults A (12 members) ├── Young Adults (20 members)
└── Young Adults B (10 members) 2 were in both → deduplicated
What happens:
- Create the target group (or designate one as the survivor).
- Move all memberships from the dissolved group to the survivor. Deduplicate (some people might be in both).
- Move all events from the dissolved group to the survivor.
- Reassign chat history (if any) — or archive the old group’s chat as read-only.
- Archive the dissolved group (don’t delete — audit trail).
Domain events:
group.merged { survivorGroupId, dissolvedGroupId, movedUserCount, movedEventCount }
group.user_joined { groupId: survivor, userId, role } — for each moved user
group.archived { groupId: dissolved }
Complexity: Medium. Membership deduplication is the tricky part. Roles might conflict (leader in one, member in the other → keep the higher role).
Operation 5: Promote Group to Organization Node
Example: A “Micro Church West” was a group under ICF Zürich. It grows and becomes a real campus — now it’s an org node with its own groups.
BEFORE: AFTER:
ICF Zürich (org) ICF Zürich (org)
├── Groups: ├── ICF Zürich West (org, new child node)
│ ├── Micro Church West (group) │ ├── Groups:
│ ├── Small Group Alpha │ │ ├── Small Group Delta (moved from MC)
│ └── ... │ │ └── Worship Team (moved from MC)
│ │ └── Users: (moved from group membership → org membership)
├── Groups:
│ ├── Small Group Alpha
│ └── ...
What happens:
- Create a new Organization node as child of ICF Zürich.
- Convert group users to org members of the new node. Preserve roles (group leader → org admin/leader).
- Move sub-groups (if any) from the old group to be groups under the new org.
- Move events from the old group to the new org.
- Archive the old group.
This is a domain service operation, not a single aggregate method — it crosses the Organization, People, Community, and Activity contexts. Implemented as a saga/process manager:
PromoteGroupToOrganization saga:
├── Step 1: Create Organization node { parentId: zürich, name: "ICF Zürich West", type: "location" }
├── Step 2: For each group user → create OrgMembership in new org (role mapping: leader→admin, member→member)
├── Step 3: For each sub-group → update organizationId to new org
├── Step 4: For each event → update organizationId to new org
├── Step 5: Archive the original group
├── Step 6: Emit group.promoted_to_organization { groupId, newOrgId }
└── Rollback: if any step fails, reverse all previous steps
Complexity: Medium-High. Multiple contexts involved, but each step is simple. The saga orchestrates.
Operation 6: Cross-Tenant Org Migration
See ADR 016 for the strategic context (platform tenant, denomination tenants, etc.)
Example: ICF Bern was an independent tenant. ICF Movement creates a denomination tenant and wants to bring ICF Bern in. Or: a small church in the platform tenant grows and wants its own tenant.
This IS a first-class operation, not a nuclear option. The architecture supports it because:
- All entities have
tenant_idas an explicit column (not derived) - Cross-module references use string UUIDs (no foreign keys across modules)
- Entity IDs (UUIDs) don’t change — only
tenant_idchanges - Zitadel identity is universal — users don’t need to re-register
What moves:
Organization subtree (the org + all descendants)
├── Organizations — tenant_id updated, ltree paths recalculated under new parent
├── Users — tenant_id updated (or merged if user already exists in target tenant)
├── OrgMemberships — follow their Users
├── Groups — tenant_id updated
├── GroupMemberships — follow their Groups
├── Events — tenant_id updated
├── EventRegistrations — follow their Events
├── News posts — tenant_id updated
├── Media/files — re-keyed to target tenant storage path
└── Audit trail — stays in source tenant as historical record, fresh start in target
User conflict resolution:
| Scenario | Resolution |
|---|---|
| User only in source tenant | Update tenant_id on User row |
User exists in both tenants (same externalAuthId) | Merge: add OrgMemberships from source User to target User, archive source User |
| Conflicting roles | Keep the higher role per org |
Implementation:
- Superadmin-only (CLI command + admin UI)
- Preview: show affected data counts, user conflicts, target parent org
- Execute: emit
OrgMigratedToTenantdomain event → each module updates its owntenant_idcolumns - Post-migration: source subdomain redirects if source tenant becomes empty → archive
- GDPR: this is a data controller change — users should be notified
BEFORE: AFTER:
Platform Tenant ICF Movement Tenant
├── ICF Bern (org) ──────▶ ├── ICF Zürich
│ ├── Groups... ├── ICF Basel
│ ├── Events... └── ICF Bern (moved, with all data)
│ └── Users... ├── Groups...
├── Events...
ICF Movement Tenant └── Users...
├── ICF Zürich
└── ICF Basel
Domain events:
OrgMigratedToTenant { orgId, sourceTenantId, targetTenantId, affectedOrgIds[], affectedUserCount, userConflicts }
Complexity: Medium-High. Bulk UPDATE ... SET tenant_id = ? across tables, coordinated by domain events. The ltree recalculation and user conflict resolution are the tricky parts, but both are well-defined operations.
Reorg Safety Rails
All reorg operations should:
- Require admin or superadmin role. No group leader can restructure.
- Show a preview. “This will affect 47 users, 12 groups, 23 events.” Let the admin confirm.
- Be fully audit-logged. The Observe & Protect Pipeline captures everything automatically.
- Be reversible (soft). Nothing is hard-deleted. Archived entities can be restored. Moved entities track their previous location in the audit log.
- Emit rich domain events. Other modules (News, Chat, Search) react to the restructure via events — they update their read models, re-index search, etc.
Design Principle Added
6. **Reorganization is a first-class operation, not an afterthought.**
Moving groups, restructuring the org tree, merging entities —
these are expected operations, not edge cases. The model supports
them with explicit domain events, cascading updates, and admin
tooling. The ltree path enables efficient subtree recalculation.
Every move is audited and previewable before execution.
Cross-Tenant Collaboration (Event Syndication)
The Real-World Problem
Tenants are isolated by design — ICF’s data never touches FEG’s data. But church life is not isolated:
- Joint worship services. Church A (ICF Zürich) and Church B (FEG Winterthur) do a combined service. Users of both need to see the event.
- Godi movement. Organizes regional youth services. Multiple churches from different denominations participate. Godi has its own tenant/app, but events should appear in participating churches’ apps.
- Citywide prayer nights. Churches across denominations gather. One church hosts, all promote.
- Conferences. Cross-denomination events (Explo, Gott@Digital) want to be visible in multiple church apps.
The Constraint
You CANNOT share user data, merge feeds, or create cross-tenant group memberships. Tenants are GDPR-separate data controllers. What you CAN do: syndicate event references across tenant boundaries.
Model: Event-by-Event Linking (Not a Feed)
This doesn’t happen often — maybe 5-20 times a year for an active church. No need for a continuous feed/subscription model. Instead: the source tenant shares a specific event via a link, the target tenant admin reviews and accepts it.
Think of it like sharing a Google Doc: you get a link, you decide whether to add it to your workspace.
SIMPLE EVENT LINKING FLOW:
TENANT A (Godi Zürich) TENANT B (ICF Movement)
───────────────────── ───────────────────────
1. Admin creates "Godi Night"
2. Admin clicks "Share with
other tenants"
3. System generates a share link:
https://app.example.com/
syndication/invite/godi-night-2026-04
4. Admin sends link via email/chat 5. ICF admin receives link
to ICF admin (personal contact) clicks it → opens admin UI
"Hey, want to link our Godi
Night into your app?" 6. Admin sees event preview:
title, date, location,
sharing mode, registration
form (if any)
7. Admin chooses:
├── Which org to show it in
│ (ICF Zürich? All of ICF CH?)
├── Accept sharing mode
└── [Link this event]
8. LinkedEvent created in ICF.
ICF users see it in their feed.
No feed subscription. No automatic sync of all events. No admin queue to review. Just: share link → preview → accept → done. This is simple, requires zero setup, and works for the 5-20 cross-tenant events per year.
The share link is:
- Authenticated (only admins of tenants on the same platform can open it)
- Single-event scoped (not a feed of all events)
- Revocable (source admin can disable the share link)
- Contains: event metadata + sharing mode + registration form schema (if camp)
┌──────────────────────┐ share link ┌──────────────────────┐
│ TENANT: Godi Zürich │ ─────────────▶ │ TENANT: ICF Movement │
│ │ │ │
│ Event: "Godi Night" │ share link │ TENANT: FEG Schweiz │
│ │ ─────────────▶ │ │
│ Admin sends 2 links │ │ │
│ to 2 different │ share link │ TENANT: EMK Zürich │
│ tenant admins │ ─────────────▶ │ │
└──────────────────────┘ └──────────────────────┘
Each link is a deliberate, personal invitation.
Not a broadcast. Not a subscription.
Data Model (Local RSVP + Aggregate Sync + Consent UX)
The original syndication model treated LinkedEvents as read-only reference cards with an external registration link. This is too limited. Real use cases require:
- A user RSVPs within their own tenant app, not via an external link.
- They see friends from their own tenant who are also attending.
- They do NOT see attendees from other tenants (privacy).
- They may see a total attendance count across all tenants.
- The organizing tenant needs a total count from all linked tenants.
- For camps/conferences: an attendee list needs to be shared with explicit consent.
SYNDICATION ARCHITECTURE (revised):
┌────────────────────────────────────────────────────────────────────┐
│ SOURCE TENANT: Godi Zürich │
│ │
│ Event: "Godi Night" (syndicationId: "godi-night-2026-04") │
│ ├── Local RSVPs: 35 attending (Godi's own users) │
│ ├── Aggregate from linked tenants: │
│ │ ├── ICF Zürich: 42 attending │
│ │ ├── FEG Winterthur: 18 attending │
│ │ └── EMK Zürich: 7 attending │
│ ├── Total across all tenants: 102 attending │
│ └── Attendee list (for camps only, consent-based): │
│ ├── Godi users: full list (own data) │
│ ├── ICF: [names of users who consented to share] │
│ └── FEG: [names of users who consented to share] │
│ │
│ Syndication API: │
│ ├── GET /syndication/godi-zh/events (event list) │
│ ├── POST /syndication/godi-zh/events/{id}/rsvp-count (receive count) │
│ └── POST /syndication/godi-zh/events/{id}/attendees (receive list) │
└────────────────────────────────────────────────────────────────────┘
│ │ │
syndicate event aggregate counts attendee lists
▼ ▼ (camps only)
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ TENANT: ICF │ │ TENANT: FEG │ │ TENANT: EMK │
│ │ │ │ │ │
│ LinkedEvent: │ │ LinkedEvent: │ │ LinkedEvent: │
│ "Godi Night" │ │ "Godi Night" │ │ "Godi Night" │
│ │ │ │ │ │
│ LOCAL RSVPs: │ │ LOCAL RSVPs: │ │ LOCAL RSVPs: │
│ ├── Anna (ICF) ✓ │ │ ├── Peter (FEG) ✓ │ │ ├── Ruth (EMK) ✓ │
│ ├── Marco (ICF) ✓ │ │ ├── Sara (FEG) ✓ │ │ └── 6 others │
│ └── 40 others │ │ └── 16 others │ │ │
│ │ │ │ │ │
│ WHAT ANNA SEES: │ │ WHAT PETER SEES: │ │ │
│ "Godi Night" │ │ "Godi Night" │ │ │
│ "42 from ICF going"│ │ "18 from FEG going"│ │ │
│ "102 total" │ │ "102 total" │ │ │
│ Friends: Marco, │ │ Friends: Sara, │ │ │
│ Lisa (ICF only) │ │ Jonas (FEG only) │ │ │
└────────────────────┘ └────────────────────┘ └────────────────────┘
LinkedEvent (revised entity)
LINKED EVENT (subscriber side — now with local RSVP):
├── id: LinkedEventId (UUID)
├── tenantId: TenantId — the subscribing tenant
├── organizationId: OrganizationId — where to show this in the local tree
├── syndicationId: string — globally unique, stable reference to source event
├── sourceTenantSlug: string — "godi-zh"
├── sourceTenantName: string — "Godi Zürich"
│
├── Event metadata (synced from source):
│ ├── title: string
│ ├── description: TranslatableText
│ ├── startAt: DateTime
│ ├── endAt: DateTime
│ ├── location: EventLocation
│ ├── coverImageUrl: Url
│ ├── externalUrl: Url | null — optional link to source for more info
│ └── autoSync: boolean — auto-update when source changes?
│
├── Local RSVP (owned by THIS tenant):
│ ├── rsvps: LinkedEventRSVP[]
│ │ ├── userId: UserId — local tenant user
│ │ ├── status: RSVPStatus — attending, maybe, declined
│ │ ├── respondedAt: DateTime
│ │ └── shareWithOrganizer: boolean — EXPLICIT opt-in to share name with source tenant
│ ├── localAttendingCount: int — count of "attending" in THIS tenant
│ └── localMaybeCount: int
│
├── Aggregate data (received from source):
│ ├── totalAttendingAllTenants: int | null — sum across all linked tenants + source
│ ├── totalMaybeAllTenants: int | null
│ ├── maxCapacity: int | null — from source event
│ └── lastSyncedAt: DateTime
│
├── Sharing mode (set by source event):
│ ├── COUNT_ONLY — linked tenants send only aggregate counts to source
│ ├── ATTENDEES_OPTOUT — attendee names shared by default, user can opt out
│ ├── ATTENDEES_OPTIN — attendee names shared only if user explicitly opts in
│ └── NONE — no data flows back to source (pure broadcast)
│
├── status: LinkedEventStatus — active, hidden, archived
└── createdAt: DateTime
Syndication Protocol (Simplified: Share Link + Server-to-Server Sync)
PROTOCOL: Event-by-event linking via share links + REST API for data sync
SETUP (one-time per event, admin-to-admin):
1. SHARE LINK GENERATION (source admin):
POST /api/admin/events/{eventId}/share
→ Returns: { "shareUrl": "https://app.example.com/syndication/invite/{token}" }
→ Token is single-use-per-tenant, contains: eventId, sharingMode, formSchema
→ Admin sends this URL to the target tenant admin (email, chat, in person)
2. SHARE LINK ACCEPTANCE (target admin):
GET /api/admin/syndication/preview/{token}
→ Returns: event preview (title, date, location, sharing mode, form schema)
→ Admin reviews, selects org node, clicks "Link this event"
POST /api/admin/syndication/accept/{token}
Body: { "organizationId": "icf-zurich-uuid" }
→ Creates LinkedEvent in target tenant
→ Registers target tenant as a linked partner at source tenant
→ Share link is now consumed (can't be used by another tenant)
ONGOING SYNC (server-to-server, automatic after linking):
3. RSVP COUNT PUSH (linked tenant → source, debounced max 1/min):
POST /api/syndication/{syndicationId}/rsvp-aggregate
Auth: API key (generated during link acceptance)
Body: { "tenantSlug": "icf", "attending": 42, "maybe": 8 }
4. ATTENDEE PUSH (linked tenant → source, camps only, consent-based):
POST /api/syndication/{syndicationId}/attendees
Auth: API key
Body: { "attendees": [{ "name": "Anna Müller", "email": "...", "form": {...} }] }
→ Only users who passed the consent screen
5. AGGREGATE BROADCAST (source → all linked tenants):
POST /api/syndication/callback/{linkedEventId}/aggregate
Body: { "totalAttending": 102, "totalMaybe": 23, "remainingCapacity": 48 }
6. EVENT UPDATE SYNC (source → linked tenants, when title/time/location changes):
POST /api/syndication/callback/{linkedEventId}/update
Body: { "changedFields": { "startAt": "...", "location": {...} } }
Why share links, not feed subscriptions:
- Cross-tenant events are rare (5-20/year). A permanent feed is over-engineering.
- Share links are personal — admin A sends to admin B. Deliberate, not automated.
- No admin queue to review. No feed polling. No configuration screen in settings.
- The link carries everything needed: event metadata, sharing mode, form schema.
- A new share link can be generated for each additional tenant. Easy to track who has access.
User Experience (detailed)
ANNA (user of ICF Zürich) opens ICF app, sees "Godi Night" in events:
EVENT DETAIL SCREEN:
┌──────────────────────────────────────────┐
│ 📍 Community Event · Organized by Godi ZH │
│ │
│ GODI NIGHT │
│ Friday, April 15 · 19:30 - 22:00 │
│ Maag Halle, Zürich │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ 🟢 42 from ICF attending │ │
│ │ 🌍 102 total across all churches │ │
│ └──────────────────────────────────────┘ │
│ │
│ Friends going: │
│ 👤 Marco Rossi │
│ 👤 Lisa Weber │
│ 👤 +3 more from ICF │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ [ I'm attending ✓ ] │ │
│ └──────────────────────────────────────┘ │
│ │
│ ☐ Share my name with the organizer │
│ (Godi Zürich will see your name │
│ for event planning) │
│ │
│ More info at godi-zh.ch → │
└──────────────────────────────────────────┘
WHAT ANNA SEES:
├── "42 from ICF attending" — count from her tenant only
├── "102 total" — aggregate from source (all tenants combined)
├── Friends: only ICF users she knows (same privacy rules as local events)
├── She does NOT see: "Peter from FEG" or "Ruth from EMK"
├── She RSVPs within the ICF app. Her RSVP is stored in ICF's database.
├── The "share my name" checkbox is opt-in. If checked, her name flows to Godi.
└── The RSVP button works exactly like any local event. No redirects.
Consent UX: The “Scope Screen” (OAuth-Inspired)
When a user signs up for a cross-tenant event that requires data sharing (camps, conferences with registration forms), they must see exactly what’s being shared and with whom — like an OAuth consent screen shows which scopes an app requests.
CAMP REGISTRATION — CONSENT SCREEN:
┌──────────────────────────────────────────┐
│ │
│ 🏕️ Summer Camp 2026 │
│ Organized by: Godi Zürich │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ This event is organized by another │ │
│ │ organization. By registering, you │ │
│ │ agree to share the following data │ │
│ │ with Godi Zürich: │ │
│ │ │ │
│ │ ✅ Your name │ │
│ │ ✅ Your email address │ │
│ │ ✅ Your registration answers │ │
│ │ (T-shirt size, dietary needs, │ │
│ │ emergency contact) │ │
│ │ │ │
│ │ This data will be used for: │ │
│ │ Camp logistics and planning │ │
│ │ │ │
│ │ This data will be deleted: │ │
│ │ 30 days after the event ends │ │
│ │ │ │
│ │ You can withdraw your consent │ │
│ │ at any time in the event settings. │ │
│ └──────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ [ Agree & Register ] │ │
│ └──────────────────────────────────────┘ │
│ │
│ Cancel │
│ │
└──────────────────────────────────────────┘
DESIGN PRINCIPLES FOR THIS SCREEN:
├── Shown ONLY for cross-tenant events that request data sharing
├── NOT shown for COUNT_ONLY events (RSVP is local, no data leaves)
├── NOT shown for regular local events (same tenant, no sharing)
├── The scope list is generated FROM the sharing mode + registration form
├── Clear language: WHO gets the data, WHAT data, WHY, WHEN it's deleted
├── "Agree & Register" = single action (no separate RSVP + consent steps)
├── "Cancel" = back to event detail, no RSVP, no data shared
├── Withdrawal: user can open event → settings → "Withdraw data sharing"
│ → triggers deletion request to source tenant
└── The consent is audit-logged with timestamp and exact scope shown
For simple cross-tenant events (Godi Night, joint service) with COUNT_ONLY sharing mode: No consent screen needed. The user just taps “I’m attending.” Only the aggregate count (not their identity) flows to the source. Their RSVP stays entirely within their tenant. No different from a regular event.
For camps/conferences with ATTENDEES_OPTIN sharing mode: The consent screen appears as part of the registration flow. It’s a single step: read the scope, tap “Agree & Register,” done. Exactly like “Sign in with Google” shows you which data the app will access.
Source Tenant Admin View (Godi Zürich)
GODI ADMIN sees "Godi Night" event dashboard:
ATTENDANCE OVERVIEW:
┌──────────────────────────────────────────┐
│ Total attending: 102 │
│ Total maybe: 23 │
│ Capacity: 150 (48 remaining) │
│ │
│ Breakdown by source: │
│ ├── Godi Zürich (local): 35 attending │
│ ├── ICF Movement: 42 attending │
│ ├── FEG Schweiz: 18 attending │
│ └── EMK Zürich: 7 attending │
│ │
│ Shared attendee list (camp mode): │
│ ├── Godi users: 35 (full details) │
│ ├── ICF shared: 28 of 42 (opted in) │
│ ├── FEG shared: 12 of 18 (opted in) │
│ └── EMK shared: 5 of 7 (opted in) │
│ │
│ [ Export attendee list (CSV) ] │
│ [ Send message to all shared attendees ] │
└──────────────────────────────────────────┘
WHAT GODI ADMIN SEES:
├── Aggregate counts per linked tenant (always visible)
├── Individual names ONLY for users who opted in to share
├── Godi's own users: full details (own data)
├── For camp logistics: exportable list with names + contact for consented users
├── Can send a push notification to all attendees? NO — only to Godi users.
│ For ICF users, ICF's admin sends the notification through their own app.
└── Can see "28 of 42 ICF users shared their name" — knows coverage %
The Camp / Conference Use Case
Resolved: camps and conferences get their own temporary tenant (see section above). The syndication attendee sharing model (ATTENDEES_OPTIN) is still available for simpler cases where a camp doesn’t justify a full tenant — e.g., a day retreat with 30 people where you just need a headcount with names. But for any multi-day event with news, groups, chat, or a schedule: create a camp tenant.
The consent screen (OAuth-style scope display) is used in BOTH models:
- Syndication RSVP with name sharing: “Godi Zürich will see your name and registration answers.”
- Joining a camp tenant: “Summer Camp 2026 wants access to: your name, your email. You’ll be joining as a participant in the camp space.”
In both cases, the user sees exactly what data is shared, with whom, and for how long — before they agree.
Syndication Sharing Modes
The source event defines how much data flows back:
| Mode | Counts | Names | Registration data | Use case |
|---|---|---|---|---|
NONE | ❌ | ❌ | ❌ | Pure broadcast. Linked tenants show the event, source gets nothing back. |
COUNT_ONLY | ✅ | ❌ | ❌ | Joint services, godi nights. Source sees “42 from ICF” but no names. |
ATTENDEES_OPTIN | ✅ | ✅ (opted in) | ✅ (opted in) | Camps, conferences. Users choose to share. |
Default: COUNT_ONLY. Use ATTENDEES_OPTIN only for events with registration forms.
The Fundamental Challenge: Full-Featured Cross-Tenant Events
Syndication with local RSVP solves the simple case: “show this event in my app, let my users attend, share the count.” But a real camp or conference needs MORE:
- Camp news feed. “Bus leaves at 08:00 from parking lot B” — all participants need to see this, regardless of which tenant they came from.
- Camp groups. Workshop groups, cabin assignments, activity teams — participants from ICF, FEG, and EMK are mixed together.
- Camp chat. “Who’s bringing a guitar?” — a shared chat room for all attendees.
- Camp schedule. Sub-events, sessions, meals — a full event tree.
Syndication can’t do this. You can’t share news into another tenant. You can’t create cross-tenant groups. You can’t have cross-tenant chat. The tenant boundary is the whole point — and now it’s the problem.
Resolution: A Camp IS Its Own Tenant
The cleanest answer: a camp or conference creates its own tenant. Not a linked event inside another tenant — a separate, full-featured space with its own news, groups, chat, events.
THE CAMP TENANT MODEL:
┌─────────────────────────────────────────────────────────────────┐
│ ZITADEL (Universal Identity) │
│ │
│ Anna has ONE identity. She uses it in 3 tenant contexts: │
└───┬──────────────────┬──────────────────┬───────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌────────────────────────┐
│ ICF App │ │ FEG App │ │ Summer Camp 2026 │
│ (tenant) │ │ (tenant) │ │ (tenant — temporary) │
│ │ │ │ │ │
│ Her home │ │ She also │ │ She joined this camp │
│ church │ │ attends │ │ ├── Camp news feed │
│ │ │ here │ │ ├── Workshop groups │
│ │ │ │ │ ├── Cabin chat │
│ │ │ │ │ ├── Full schedule │
│ │ │ │ │ └── Camp-specific data │
│ │ │ │ │ │
│ Permanent│ │ Permanent│ │ Temporary (archived │
│ │ │ │ │ 30 days after camp) │
└──────────┘ └──────────┘ └────────────────────────┘
Anna's data in each tenant is COMPLETELY SEPARATE.
The camp tenant has its own User entity for Anna.
Her ICF profile, groups, giving are untouched.
Why this works:
- Zero model changes. A camp tenant uses the exact same domain model: organizations (camp → tracks), groups (workshops, cabins), events (sessions, meals), news, chat. Everything already works.
- Tenant isolation preserved. The camp has its own data silo. ICF’s data stays in ICF. FEG’s data stays in FEG. The camp has its own data.
- Universal identity makes it frictionless. Anna already has a Zitadel account. Joining the camp tenant is instant — same login, new context.
- Temporary by nature. Camp tenants are created for an event, active during it, archived after. Fits the
TenantStatus: active → archivedlifecycle. - Scales to huulo.io. This IS the huulo.io model. A standalone event/camp platform where each event is its own tenant. The church app just happens to share the same infrastructure and identity layer.
But: App Distribution Problem
Each tenant was designed as its own App Store app. You can’t publish a new app for every camp. Three options for how users ACCESS a camp tenant:
OPTION A: WEB APP / PWA (recommended for MVP)
├── Camp tenant is accessed via a web URL: camp2026.yourapp.com
├── Anna taps a link in the ICF app → opens camp web app in browser
├── Logs in with same Zitadel credentials (SSO, instant)
├── Full camp experience: news, groups, chat, schedule
├── Works on any device. No app store submission needed.
├── Can be added to home screen as PWA for app-like experience.
└── PROS: Zero deployment friction. Works immediately.
CONS: No push notifications (or limited via web push). Slightly worse UX.
OPTION B: MULTI-TENANT SUPPORT IN THE APP (Phase 2+)
├── The existing church app supports multiple tenant contexts
├── Like Slack workspaces: Anna opens ICF app, sees a tenant switcher
│ ├── 🏠 ICF Zürich (home)
│ └── 🏕️ Summer Camp 2026 (temporary)
├── Switching context loads the camp's content within the same app
├── Push notifications work natively (scoped to active tenants)
├── The camp appears after Anna joins via a QR code / invite link
├── Camp tenant disappears from the switcher when archived
└── PROS: Native UX. Push works. Seamless.
CONS: Significant app architecture work. Needs careful scoping.
OPTION C: DEDICATED CAMP APP (for very large events only)
├── Large conference (1000+ attendees) gets its own app in the store
├── Same codebase, different tenant config, different branding
├── Published manually (2-3 weeks lead time)
└── PROS: Full native experience with custom branding.
CONS: App review time. Only for recurring annual events.
Recommendation:
- MVP: Option A (web app). Camp tenants are web-only. A link in the church app opens the camp web experience. Good enough for a camp with 50-200 attendees.
- Phase 2: Option B (multi-tenant app). If camps become a major feature, add a tenant switcher to the native app. This is the Slack model — works beautifully but is a significant UX and architecture investment.
- Phase 3 / huulo.io: Option C for large annual conferences that justify their own app. This is just a deployment config change — the code is the same.
How Syndication and Camp Tenants Work Together
Both models coexist. They solve different problems:
SYNDICATION (LinkedEvent):
├── For: one-off cross-tenant events (godi night, joint service, prayer night)
├── Complexity: low (share link → local RSVP → aggregate count)
├── Features: RSVP, attendance count, optional name sharing
├── NO shared news, groups, chat
├── User stays in their home app
└── 5-20 events/year per tenant
CAMP TENANT:
├── For: multi-day events with collaborative features (camps, conferences, retreats)
├── Complexity: medium (create tenant, invite participants, run the event, archive)
├── Features: FULL platform — news, groups, events, chat, schedule, giving
├── Participants from multiple home tenants join as users
├── Accessed via web app (MVP) or tenant switcher (Phase 2)
└── 1-5 events/year per organization
DECISION GUIDE (for the admin):
├── "We have a joint service with FEG next month"
│ → Syndication. Share the event link. Done.
├── "We're organizing a 3-day camp with 150 people from 4 churches"
│ → Camp tenant. Create it, invite participants, full features.
└── "There's a Godi night this Friday"
→ Syndication. Share link, COUNT_ONLY mode.
Camp Tenant Lifecycle
1. CREATION
├── Admin of organizing church (or camp organizer) creates a new camp tenant
├── Via super admin dashboard or self-service (depending on subscription tier)
├── Defines: name, dates, branding, org structure (tracks, workshops)
├── Tenant type: "temporary" (auto-archive after end date + retention period)
└── Connected to a subscription (part of the organizing church's plan, or separate)
2. INVITATION
├── Generates invite links / QR codes
├── Shared via church apps (as a LinkedEvent with "Join camp" action)
├── Or via email, WhatsApp, printed on flyers
├── User clicks → consent screen (same OAuth-style scope screen):
│ "Summer Camp 2026 wants access to: your name, your email.
│ You'll be joining as a participant in the camp space."
└── User logs in with Zitadel → User entity created in camp tenant
3. ACTIVE PERIOD
├── Full features: news, groups, events (schedule), chat, giving (camp fees)
├── Camp admins manage content
├── Users interact within the camp tenant context
└── Home church apps are completely unaffected
4. WIND-DOWN
├── Camp ends. Content becomes read-only.
├── Photo sharing, final announcements still possible for a defined period.
├── Users can export their own data (photos, contacts made).
└── After retention period (e.g., 30 days): tenant archived.
5. ARCHIVE
├── Tenant status: archived. No more logins, no new content.
├── Data retained for compliance period (audit logs, financial records).
├── Users' camp-specific data is eventually purged per GDPR.
└── The camp disappears from tenant switchers / web URLs.
Tenant Types + Module Matrix = Infinite Configurability
This is the full picture: every tenant is the same codebase. The tenant type determines the default module set and UI labels. The module system allows dynamic activation/deactivation per tenant. The result: one platform that serves churches, camps, conferences, scout groups, and any other organization — each with exactly the features they need.
TENANT TYPES (preconfigured templates):
┌──────────────┬──────────────┬──────────────┬──────────────┬──────────────┐
│ │ CHURCH │ CAMP │ CONFERENCE │ ORGANIZATION │
│ │ (permanent) │ (temporary) │ (temporary) │ (permanent) │
├──────────────┼──────────────┼──────────────┼──────────────┼──────────────┤
│ Lifecycle │ Permanent │ Temp (auto- │ Temp (auto- │ Permanent │
│ │ │ archive) │ archive) │ │
├──────────────┼──────────────┼──────────────┼──────────────┼──────────────┤
│ Org tree │ Deep (move- │ Flat (camp │ Flat (conf │ Deep (like │
│ │ ment→church │ → tracks) │ → tracks) │ church) │
│ │ →campus) │ │ │ │
├──────────────┼──────────────┼──────────────┼──────────────┼──────────────┤
│ App access │ Own App │ Tenant │ Tenant │ Own App │
│ │ Store app │ switcher │ switcher │ Store app │
│ │ │ or web/PWA │ or web/PWA │ │
├──────────────┼──────────────┼──────────────┼──────────────┼──────────────┤
│ DEFAULT MODULES: │
├──────────────┼──────────────┼──────────────┼──────────────┼──────────────┤
│ News │ ✅ │ ✅ │ ✅ │ ✅ │
│ Groups │ ✅ │ ✅ (teams, │ ✅ (workshop │ ✅ │
│ │ │ cabins) │ groups) │ │
│ Events │ ✅ │ ✅ (schedule │ ✅ (sessions │ ✅ │
│ │ │ meals) │ talks) │ │
│ Push │ ✅ │ ✅ │ ✅ │ ✅ │
│ Directory │ ✅ │ ✅ │ ✅ │ ✅ │
│ Chat │ ⚪ optional │ ✅ │ ⚪ optional │ ⚪ optional │
│ Giving │ ⚪ optional │ ❌ │ ⚪ optional │ ❌ │
│ Sunday │ ⚪ optional │ ❌ │ ❌ │ ❌ │
│ Pinboard │ ⚪ optional │ ✅ │ ⚪ optional │ ⚪ optional │
│ Translation │ ⚪ optional │ ⚪ optional │ ✅ │ ⚪ optional │
│ Registration │ ❌ │ ✅ (forms, │ ✅ (tickets, │ ❌ │
│ │ │ T-shirts, │ sessions) │ │
│ │ │ dietary) │ │ │
│ Check-in │ ❌ │ ✅ (arrival) │ ✅ (badge) │ ❌ │
│ Photo sharing│ ❌ │ ✅ │ ⚪ optional │ ❌ │
│ Maps/venue │ ❌ │ ✅ (site map)│ ✅ (floor │ ❌ │
│ │ │ │ plan) │ │
├──────────────┼──────────────┼──────────────┼──────────────┼──────────────┤
│ UI LABELS: │
├──────────────┼──────────────┼──────────────┼──────────────┼──────────────┤
│ Root org │ "Movement" │ "Camp" │ "Conference" │ "Federation" │
│ Branch │ "Church" │ "Track" │ "Track" │ "Chapter" │
│ Location │ "Campus" │ "Venue" │ "Hall" │ "Branch" │
│ Group │ "Small Group"│ "Team/Cabin" │ "Workshop" │ "Team" │
│ Event │ "Service" │ "Activity" │ "Session" │ "Meeting" │
│ Member │ "Member" │ "Participant"│ "Attendee" │ "Member" │
└──────────────┴──────────────┴──────────────┴──────────────┴──────────────┘
EVERY cell in this matrix is just configuration, not code.
The domain model is identical underneath.
How it works technically:
-
Tenant type is set at creation and determines the default module set + UI labels. It’s a template, not a constraint. An admin can enable or disable any module after creation.
-
Module activation uses the existing
tenant_module_configsystem (defined in the architecture blueprint). Each module can be toggled at tenant level. The mobile app receives the enabled module list at login and dynamically shows/hides features. -
UI labels are the
orgTypeLabels,groupTypeLabels,eventTypeLabelsalready defined on the Tenant aggregate. The tenant type template pre-fills these, but they’re fully customizable. -
A camp admin creates a camp tenant, gets a pre-configured setup with news, groups, events, chat, registration, check-in, photo sharing. They can disable chat if they don’t want it, or enable giving if campers need to pay fees through the app.
-
A church admin has their permanent tenant with the church module set. When they create a camp tenant (or one is created for them), the camp gets its own tailored setup. The two tenants share nothing except the identity layer.
The app handles this via the tenant switcher (Phase 2):
TENANT SWITCHER UX:
┌──────────────────────────────────────┐
│ ≡ Switch context │
│ │
│ 🏠 ICF Zürich HOME │
│ Your home church │
│ │
│ 🏕️ Summer Camp 2026 ACTIVE │
│ June 12-15 · Bern │
│ Ends in 4 days │
│ │
│ 🎤 ICF Conference 2026 UPCOMING│
│ Sept 20-22 · Zürich │
│ Starts in 6 months │
│ │
│ 🏕️ Easter Retreat 2026 ARCHIVED │
│ Photos available until April 30 │
│ │
└──────────────────────────────────────┘
BEHAVIOR:
├── Home tenant is always pinned at top
├── Active temporary tenants show with their end date
├── Upcoming tenants appear after joining (via invite link)
├── Archived tenants fade out and eventually disappear
├── Switching is instant (JWT includes tenant scope)
├── Push notifications are labeled: "🏕️ Summer Camp: Bus leaves in 30 min"
└── Each context loads its own module set, navigation, and branding
Design Principle Updated
9. **One platform, many shapes.**
Every tenant runs the same codebase. Tenant types (church, camp,
conference, organization) are configuration templates that set
default modules and UI labels. Any module can be toggled per tenant.
The domain model doesn't know if it's powering a church or a scout
camp — it just knows organizations, users, groups, and events.
This is the product's secret weapon: build once, serve any community.
Module Entitlements = Monetization Engine
The module system is not just a technical pattern — it’s the entire business model. Subscription tiers unlock base modules. Premium add-ons unlock advanced features. Usage limits gate expensive operations. Everything is controlled through ModuleEntitlement on the Tenant aggregate.
MODULE ENTITLEMENT MATRIX:
┌──────────────────┬───────────┬───────────┬───────────┬───────────┐
│ MODULE │ FREE │ CHURCH │ CHURCH+ │ MOVEMENT │
│ │ (≤50 mem) │ CHF 39/mo │ CHF 79/mo │ CHF 149/mo│
├──────────────────┼───────────┼───────────┼───────────┼───────────┤
│ News │ ✅ │ ✅ │ ✅ │ ✅ │
│ Groups │ ✅ │ ✅ │ ✅ │ ✅ │
│ Events │ ✅ │ ✅ │ ✅ │ ✅ │
│ Push │ ✅ │ ✅ │ ✅ │ ✅ │
│ Directory │ ✅ │ ✅ │ ✅ │ ✅ │
│ Admin backend │ ✅ basic │ ✅ │ ✅ │ ✅ │
├──────────────────┼───────────┼───────────┼───────────┼───────────┤
│ Giving │ ❌ │ ✅ │ ✅ │ ✅ │
│ Sunday │ ❌ │ ✅ │ ✅ │ ✅ │
│ Pinboard │ ❌ │ ✅ │ ✅ │ ✅ │
│ Chat │ ❌ │ ✅ │ ✅ │ ✅ │
├──────────────────┼───────────┼───────────┼───────────┼───────────┤
│ Translation │ ❌ │ ❌ │ ✅ │ ✅ │
│ API access │ ❌ │ ❌ │ ✅ │ ✅ │
│ ChMS adapters │ ❌ │ ❌ │ ❌ │ ✅ │
│ Priority support │ ❌ │ ❌ │ ❌ │ ✅ │
├──────────────────┼───────────┼───────────┼───────────┼───────────┤
│ PREMIUM ADD-ONS (available on any paid tier): │
├──────────────────┼───────────────────────────────────────────────┤
│ AI Admin Pack │ +CHF 19/mo — AI-powered content drafting, │
│ │ sermon summaries, auto-tagging, suggested │
│ │ groups, engagement insights │
├──────────────────┼───────────────────────────────────────────────┤
│ Advanced │ +CHF 9/mo — unlimited AI translations │
│ Translation │ (base Church+ tier has monthly limit) │
├──────────────────┼───────────────────────────────────────────────┤
│ Camp/Conference │ +CHF 29/event — create a temporary tenant │
│ Tenant │ with registration, check-in, photo sharing. │
│ │ Included free in Movement tier. │
├──────────────────┼───────────────────────────────────────────────┤
│ Custom Branding │ +CHF 9/mo — custom app icon, splash screen, │
│ │ full white-label. Included in Movement tier. │
└──────────────────┴───────────────────────────────────────────────┘
How entitlements flow:
ENTITLEMENT LIFECYCLE:
1. Church signs up → Stripe subscription created → webhook fires
2. Symfony receives subscription.created webhook
3. Maps Stripe product/price to tier → resolves module entitlements
4. Writes ModuleEntitlement[] to Tenant aggregate
5. Mobile app fetches module list at login → shows/hides features
On upgrade (Church → Church+):
1. Stripe subscription.updated webhook
2. New entitlements added (Translation, API access)
3. App dynamically shows new navigation items on next refresh
On addon purchase (AI Admin Pack):
1. Stripe checkout.session.completed webhook
2. New ModuleEntitlement added with source: addon
3. Admin backend shows AI features immediately
On downgrade or addon cancellation:
1. Stripe webhook fires
2. Entitlement disabled (NOT deleted — data preserved)
3. Module becomes read-only: existing content visible, creation disabled
4. Admin sees: "Upgrade to Church+ to create new translations"
On trial:
1. Superadmin grants 30-day trial of AI Admin Pack
2. Entitlement with source: trial, expiresAt: +30 days
3. Cron job checks daily → expires trial → disables entitlement
4. Admin notified: "Your AI Admin Pack trial ends in 3 days"
USAGE LIMITS:
├── Translation: Church+ gets 500 AI translations/month
│ ├── Counter increments on each translation
│ ├── At 80%: soft warning in admin dashboard
│ ├── At 100%: new translations queued, not processed
│ ├── Admin sees: "Translation limit reached. Upgrade or wait until next month."
│ └── Movement tier: unlimited. Add-on: unlimited.
│
├── AI Admin Pack: fair-use policy (no hard cap at MVP)
│ └── Monitor usage, add limits if abused
│
└── Storage: media uploads per tenant
├── Free: 500MB, Church: 5GB, Church+: 20GB, Movement: 100GB
└── Addon: +50GB for CHF 5/mo
AI-powered admin features (the AI Admin Pack vision):
AI ADMIN PACK — what it could include:
Content:
├── Draft news posts from a topic/bullet points
├── Auto-generate social media snippets from news posts
├── Suggest event descriptions from minimal input
├── Translate admin-facing content (separate from user-facing translation)
└── Auto-tag content for better search/discovery
Engagement insights:
├── "Group Alpha hasn't met in 4 weeks" — nudge the leader
├── "15 users haven't opened the app in 30 days" — suggest re-engagement
├── "Sunday attendance dropped 20% this month" — flag for pastor
└── Weekly digest email to admin: key metrics + AI-generated insights
Sermon/content:
├── Auto-generate sermon notes from uploaded audio/video
├── Create discussion questions from sermon content
├── Suggest follow-up group study material
└── Auto-clip sermon highlights for social sharing
Admin assistance:
├── "Draft a message to the worship team about schedule changes"
├── Natural language query: "Show me users who joined in the last 3 months"
├── Auto-suggest event creation from recurring patterns
└── Smart scheduling: "Find a time that works for the leadership team"
ALL of these are Symfony backend features using the Anthropic API.
No on-device AI. The admin backend calls Claude/Sonnet, returns results.
Billed per-tenant as an add-on. Usage tracked for cost management.
Revised Event Catalog
SYNDICATION EVENTS:
├── event.syndication_enabled v1 { eventId, syndicationId, sharingMode }
├── event.syndication_disabled v1 { eventId }
├── event.syndication_updated v1 { eventId, changedFields }
├── event.share_link_generated v1 { eventId, token, sharingMode }
├── event.share_link_accepted v1 { eventId, token, subscriberTenantId, orgId }
│
├── linked_event.created v1 { tenantId, orgId, linkedEventId, syndicationId, sourceTenant }
├── linked_event.synced v1 { linkedEventId, changedFields }
├── linked_event.removed v1 { linkedEventId }
├── linked_event.rsvp_created v1 { linkedEventId, userId, status, shareWithOrganizer }
├── linked_event.rsvp_cancelled v1 { linkedEventId, userId }
├── linked_event.consent_granted v1 { linkedEventId, userId, purpose, scopes }
├── linked_event.consent_withdrawn v1 { linkedEventId, userId }
│
├── syndication.count_pushed v1 { syndicationId, subscriberTenant, attendingCount }
├── syndication.aggregate_received v1 { linkedEventId, totalAttending }
│
├── camp_tenant.created v1 { tenantId, name, type: "temporary", endsAt, organizerTenantId }
├── camp_tenant.user_joined v1 { tenantId, userId, consentScopes }
├── camp_tenant.archived v1 { tenantId, retentionUntil }
└── camp_tenant.purged v1 { tenantId }
Revised Design Principle
7. **Tenants collaborate through syndication OR shared temporary tenants.**
Simple cross-tenant events (joint services, godi nights) use syndication:
local RSVP, aggregate counts, optional consent-based name sharing.
Complex cross-tenant events (camps, conferences) create their own
temporary tenant with full features (news, groups, chat, schedule).
Universal identity (Zitadel) makes joining frictionless.
Users always know exactly what data is shared and with whom.
Content Cascading Rules
When a user opens the app, what do they see? Content cascades DOWN the org tree but NOT up.
CONTENT VISIBILITY RULES:
A user of "ICF Zürich City" (a child of "ICF Zürich", child of "ICF Switzerland", child of "ICF Movement"):
SEES:
├── News/Events from ICF Movement (root) — cascades down to all
├── News/Events from ICF Switzerland — cascades down to CH children
├── News/Events from ICF Zürich — cascades down to Zürich locations
├── News/Events from ICF Zürich City — their direct org
├── News/Events from their groups — personal relevance
└── Tenant-level announcements — always visible
DOES NOT SEE:
├── News from ICF München — different branch, no relation
├── News from ICF Basel — sibling org, not parent/child
├── Events from groups they're not in — unless marked PUBLIC at org level
└── Content from other tenants — obviously
RULE: Content published at org X is visible to members of X and all of X's descendants.
IMPLEMENTATION: Use the ltree path for efficient descendant queries:
WHERE org.path <@ :published_org_path
”My Stuff” Aggregation (Read Model)
The app’s home screen needs a personalized view. This is a read model, not a domain entity — precomputed for fast reads.
For User U, compute:
MY ORGANIZATIONS:
SELECT o.* FROM organizations o
JOIN user_org_memberships m ON m.organization_id = o.id
WHERE m.user_id = :userId AND m.status = 'active'
MY GROUPS:
SELECT g.* FROM groups g
JOIN group_memberships gm ON gm.group_id = g.id
WHERE gm.user_id = :userId AND gm.status = 'active'
MY EVENTS (upcoming, personalized):
-- Events from my orgs (including ancestor orgs via ltree):
SELECT e.* FROM events e
JOIN organizations o ON e.organization_id = o.id
WHERE o.path <@ ANY(
SELECT org.path FROM organizations org
JOIN user_org_memberships m ON m.organization_id = org.id
WHERE m.user_id = :userId
)
AND e.status = 'published'
UNION
-- Events from my groups:
SELECT e.* FROM events e
WHERE e.group_id IN (
SELECT gm.group_id FROM group_memberships gm
WHERE gm.user_id = :userId AND gm.status = 'active'
)
AND e.status = 'published'
ORDER BY start_at ASC
LIMIT 20
RECOMMENDED EVENTS (discovery):
-- Public events at my org level that I haven't RSVPd to
-- Groups I might be interested in (same org, matching tags)
Event Catalog (Domain Events Summary)
Every domain event is JSON-serializable with a version field. This is the contract for the Observe & Protect Pipeline and for future service extraction.
ORGANIZATION CONTEXT:
├── tenant.created v1 { tenantId, name, slug }
├── tenant.branding_changed v1 { tenantId, changes }
├── tenant.module_toggled v1 { tenantId, module, enabled }
├── organization.created v1 { tenantId, orgId, parentId, type, name }
├── organization.renamed v1 { orgId, oldName, newName }
├── organization.moved v1 { orgId, oldParentId, newParentId }
├── organization.archived v1 { orgId }
├── organization.settings_changed v1 { orgId, changedFields }
└── organization.subtree_recalculated v1 { rootOrgId, affectedCount }
PEOPLE CONTEXT:
├── user.registered v1 { tenantId, userId, orgId, email }
├── user.profile_updated v1 { userId, changedFields }
├── user.joined_organization v1 { userId, orgId, role }
├── user.left_organization v1 { userId, orgId }
├── user.role_changed v1 { userId, orgId, oldRole, newRole }
├── user.suspended v1 { userId, reason }
├── user.deleted v1 { userId }
└── user.privacy_settings_changed v1 { userId, changes }
COMMUNITY CONTEXT:
├── group.created v1 { tenantId, orgId, groupId, parentGroupId, type, name }
├── group.updated v1 { groupId, changedFields }
├── group.archived v1 { groupId }
├── group.user_joined v1 { groupId, userId, role }
├── group.user_left v1 { groupId, userId }
├── group.role_changed v1 { groupId, userId, oldRole, newRole }
├── group.membership_requested v1 { groupId, userId }
├── group.membership_approved v1 { groupId, userId, approvedBy }
├── group.sub_group_created v1 { parentGroupId, childGroupId }
├── group.moved v1 { groupId, oldParentGroupId, newParentGroupId }
├── group.moved_to_organization v1 { groupId, oldOrgId, newOrgId }
├── group.merged v1 { survivorGroupId, dissolvedGroupId, movedUserCount }
└── group.promoted_to_organization v1 { groupId, newOrgId }
ACTIVITY CONTEXT:
├── event.created v1 { tenantId, orgId, groupId, eventId, type, title }
├── event.updated v1 { eventId, changedFields }
├── event.published v1 { eventId }
├── event.cancelled v1 { eventId, reason }
├── event.rsvp_created v1 { eventId, userId, status, occurrenceDate }
├── event.rsvp_cancelled v1 { eventId, userId, occurrenceDate }
├── event.capacity_reached v1 { eventId }
├── event.moved_to_organization v1 { eventId, oldOrgId, newOrgId }
├── event.syndication_enabled v1 { eventId, syndicationId, sharingMode }
├── event.syndication_disabled v1 { eventId }
└── event.syndication_updated v1 { eventId, changedFields }
SYNDICATION & CAMP CONTEXT:
├── event.share_link_generated v1 { eventId, token, sharingMode }
├── event.share_link_accepted v1 { eventId, token, subscriberTenantId, orgId }
├── linked_event.created v1 { tenantId, orgId, linkedEventId, syndicationId, sourceTenant }
├── linked_event.synced v1 { linkedEventId, changedFields }
├── linked_event.removed v1 { linkedEventId }
├── linked_event.rsvp_created v1 { linkedEventId, userId, status, shareWithOrganizer }
├── linked_event.rsvp_cancelled v1 { linkedEventId, userId }
├── linked_event.consent_granted v1 { linkedEventId, userId, purpose, scopes }
├── linked_event.consent_withdrawn v1 { linkedEventId, userId }
├── syndication.count_pushed v1 { syndicationId, subscriberTenant, attendingCount }
├── syndication.aggregate_received v1 { linkedEventId, totalAttending }
├── camp_tenant.created v1 { tenantId, type: "temporary", endsAt, organizerTenantId }
├── camp_tenant.user_joined v1 { tenantId, userId, consentScopes }
├── camp_tenant.archived v1 { tenantId, retentionUntil }
└── camp_tenant.purged v1 { tenantId }
Status: Domain Model v1.3 — Core Contexts + Syndication + Camp Tenants + Tenant Type System Next: Model News, Giving, Sunday, Chat contexts once these four are validated.