App Provisioning — planes, applications, fork, seed, tenants
This is how Atelier turns "a declared application" into "a running, fully-configured tenant." A brand-new municipality does not get hand-configured: it comes up switched-on by copying a curated catalog of config rows from a single forkable worked example (template_municipality) and FK-rewiring them into the new tenant's plane. The payoff is the platform's core ergonomic claim — the same operator editors serve every tenant with zero special-case code, because each config endpoint is keyed by the caller's resolved tenant. Provisioning is the seam where the Data, Execution, and Surface planes (see The Action Engine, The Surface Plane, Notifications) all become tenant-local at once, in one ordered, idempotent, compensating operation.
The three data planes
Plane identity is the ontology tenant_id on each config row, never a code branch. app/tenant_planes.py:30-66 is the executable contract: SYSTEM_PLANE = "system", binding_source_tenant() (default template_municipality, override ADMIN_BFF_BINDING_SOURCE_TENANT), is_runtime_tenant(), system_plane_headers(). The rule of thumb (authoring-architecture.md:26): anything the fork copies is template/tenant-plane; anything structural it merely references is vocabulary.
| Row class | Purpose | Plane | Key fields |
|---|---|---|---|
entity_type / schema | Type schema; tenant-independent; registered by register_entity_types (toposorted, 409→additive migrate). NOT forked. metadata.{forkable,control_plane} drive fork+membership. | vocabulary | code, tenant_scoped, metadata.* |
notification_event / action_surface / public_entity_surface / page_template | Shared registries under tenant=system; referenced, never forked. | vocabulary | code |
tenant | Tenant catalog row; must pre-exist before any fork. | vocabulary | code, name |
application | Consumer-app catalog (code, icon, features, vertical); FK target for memberships/actions/placements. admin-bff/__legacy never fork. | template | code, vertical |
application_entity_membership | Which entity types an app surfaces; resolve_target_apps reads it. | template | application, entity_type, is_visible |
action_type (+action_parameter/_create/_edit/_submission_criteria/_side_effect) | Execution-plane defs; action_type forks Phase 1, children Phase 2. | template | key, application, organization |
action_placement | Surface binding of an action; Phase 2, anchor=application. | template | application, action_type |
notification_template / _recipient_rule / notification_rule | Operator notification config; Phase 2, no anchor (always copied, content-key deduped). | template | code/key + content key |
report_template | Report catalog; forkable, Phase 2; base-FK preserved when unmapped. | template | code, metadata.forkable |
portal / portal_page | Citizen-portal surfaces; forkable, Phase 2; portal in its own id-map bucket. | template | route, portal, placement |
nav_section / nav_item | Admin sidebar; materialized on template plane, copied by fork. | template | code, parent, order |
admin_entity_config | Per-tenant presentation (list_display, dashboard_config, child_collections…). #383 dropped the BFF-local table; now an ontology entity, tenant-scoped, copied per-app Phase 1. | tenant | tenant_id, application_code, entity_code |
organization (root) + organization_membership | Per-tenant root org is the anchor for every forked/imported row; admin membership flattens into tenant_admins_<code>. | runtime | code, path, depth=0 |
How it's declared
Operators author on the template plane through three equivalent surfaces, all writing the same row classes keyed by the resolved tenant: (1) the staff-admin authoring hub (admin/), where every call carries X-Author-Tenant: template_municipality via $lib/api/authoring.ts, gated by _apply_act_as/authoring_update_allowed against the caller's UMS admin_entity_config:write grant; (2) provisioning sheets under admin-api/provisioning/sheets/, imported by scripts/import_sheets.py → app/applications/yaml_import.py — entities, actions, public surfaces, views, notifications, portal pages, and the admin_configs: block (#440, yaml_import.py:1705-1720) for control-plane/identity presentation rows; (3) the admin config PATCH endpoints (app/routers/admin_config.py). A tenant author uses the identical hub editor against their forked copy — no X-Author-Tenant, tenant resolved from JWT — with zero extra code (authoring-architecture.md:53-67). Aggregates are authored as ontology VIEWS in the sheet views: block, never bespoke endpoints (see Analytics / Views).
How it's provisioned
The fork (app/tenants/fork.py:1257-1431, fork_tenant) is two structural phases plus three derived-side-effect phases:
- Phase 1 (
PER_APP_PLAN,fork.py:166-176): per application, copy the app spine —application→application_entity_membership→action_type— plus that app'sadmin_entity_config, all feeding ONE fork-wide id-map so cross-application references resolve (adapter apps point at other apps' action_types by design). Already-forked apps hydrate the map bykey-matching action_types and re-converge nav/backfill (fork_application:818-890). - Phase 2 (
_fork_tenant_wide, once): copy tenant-wide rows. The plan is derived from live schemas (fork_plan.derive_tenant_wide_plan,fork_plan.py:144-158): every type withmetadata.forkable: trueoutside the spine forks, FK rewrites inferred from declared relationships, toposorted. Each row copies iff its anchor FK (_ANCHOR_FK,fork.py:193-200) resolves; rows anchored outside the fork are SKIPPED, never 422'd;template/report_template-kind FKs to shared base rows are preserved when unmapped (_PRESERVE_IF_UNMAPPED,fork.py:206). - Phases 3-5: re-realize what a row-copy cannot carry — geolayers (
fork_layers), citizen READ grants (fork_citizen_acls), citizen APPLY grants (fork_citizen_apply_grants,fork.py:1402-1429; without it every citizen submit/vote 403s at Gate B).
The from-zero pipeline (scripts/prove_from_zero.sh:120-300 + scripts/provision_from_zero_tenant.py:26-79) is the canonical "fresh stack, no hand-seeding" proof, ordered: register_entity_types (bootstrap FLOOR = org tree + user only, seed.py:10147-10253) → provision_template_tenant (root-org anchor) → import_sheets → seed_system_registries → app.seed data tail (seed.py:13233-13429 — application catalog + membership graph via derive_application_entity_membership_rows at seed.py:9548-9624, role tiers, personas, org tree, nav materialize, then fork lisbon LAST) → seed_initial_tenant_admin (app/tenants/initial_admin.py:79-188 — the one staff identity a fork does NOT create: KC user + ontology user + root-org admin membership + UMS user + SpiceDB tuple + tenant-admin tier). The production path is the saga (app/tenants/saga.py) with reverse-order idempotent compensation. Reconcilers/backfills are convergence tools for already-provisioned stacks, NOT part of the happy path.
Extension points
- New forkable config type: declare it in a sheet with
metadata.forkable: true—derive_tenant_wide_planpicks it up from live schema; NOfork.pyedit (the explicit fix for the report_template/portal drift class). - New application/vertical: author its sheet, add it to the per-app entity-type lists so
derive_application_entity_membership_rowsemits ownership; it forks as a new spine. Thecreate-verticalskill scaffolds this end-to-end. - Control-plane/identity presentation: a row in a sheet's
admin_configs:block with an explicitapplicationrides theadmin_entity_config_explicitlane, homing it underidentityfrom any sheet. - Cross-vertical membership: add the pair to the
cross_verticallist — don't duplicate the entity_type (CLAUDE.md #47). - Override binding source:
ADMIN_BFF_BINDING_SOURCE_TENANT/ADMIN_BFF_SEED_TENANT_ID.
Invariants
- Plane identity is the row's
tenant_id, never a code branch (tenant_planes.is_runtime_tenant). - A type forks iff
metadata.forkableis true and it is outside the per-app spine — NOT keyed oncontrol_plane(fork_plan.py:92-101); keying oncontrol_planewould wrongly copy the org graph, users, and audit log. - Control-plane app rows (
admin-bff,__legacy) NEVER fork (NON_FORKABLE_APPLICATION_CODES). - Fork is idempotent (per-app skip + Phase-2 content-key dedup) and compensating (reverse-delete its own ledger on failure), since the ontology does not cascade application deletes.
- A Phase-2 row copies only if its anchor FK resolves; outside-fork anchors are SKIPPED not 422'd; shared-base FKs preserved when unmapped.
- Fork requires target tenant AND root org to pre-exist; fails fast on missing root org.
- Copy-on-create with NO live inheritance — later template edits do not propagate to existing forks (
authoring-architecture.md:105). - Every config endpoint keys SQL by the resolved tenant with no NULL fallthrough; the only cross-plane bridge is
X-Author-Tenant, honored solely for callers with the matching UMS grant. - The fork copies config + citizen ACLs but creates NO staff identity —
seed_initial_tenant_adminmust run for the back-office to be staff-reachable.
Gaps & open edges
- Doc/code divergence (admin_entity_config):
authoring-architecture.md:22still calls it "BFF Postgres"; #383 dropped that table — it is now an ontology entity (admin_config_store.py:1-12). The authoritative spec is stale here. - Stale fork docstrings:
fork.py:181andfork_plan.py:151say "everycontrol_planetype" forks; the running code keys onforkable(fork_plan.py:92-101), and the module header (fork_plan.py:6-15) explicitly corrects this. The contradiction lives one paragraph apart. - Stale seed docstring:
seed_applications(seed.py:9075) claims "system tenant" but writes under the binding plane (post-#199);resolver.py:60-142correctly reads the template plane. - No template re-sync: re-syncing existing forks from later template edits is unbuilt (copy-on-create only); a fleet-wide change needs manual reconcile/backfill or re-fork.
- Re-run hydration is partial: skipped-app hydration maps applications + action_types only;
template/recipient_rulekinds aren't re-hydrated (harmless today via content-key dedup; matters after a partial Phase-2 failure). - Control-plane authority residual: a cold stack's first metadata-sync is still authored by
is_superuser=trueon the admin-bff service account (prove_from_zero.shBOOTSTRAP);platform_adminsis empty untilprovision_platform_tenant(provision_platform_tenant.py:105-139) seeds an operator membership. The is_superuser-elimination work is unfinished. - Compensation never raises / isn't retried (
saga.py:31) — a failed compensation leaves dangling cross-service state only a separate reconciliation heals. - admin-bff is non-forkable: anything authored under it is shared, not per-tenant — a constraint when adding control-plane config.
- Portal/
form_definitiontenancy was deliberately left out of the authoring-unification arc (#75/#79 owns it). Demo litter (e2e*apps) accumulates on interrupted Playwright cleanup (cosmetic).