Skip to content

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 classPurposePlaneKey fields
entity_type / schemaType schema; tenant-independent; registered by register_entity_types (toposorted, 409→additive migrate). NOT forked. metadata.{forkable,control_plane} drive fork+membership.vocabularycode, tenant_scoped, metadata.*
notification_event / action_surface / public_entity_surface / page_templateShared registries under tenant=system; referenced, never forked.vocabularycode
tenantTenant catalog row; must pre-exist before any fork.vocabularycode, name
applicationConsumer-app catalog (code, icon, features, vertical); FK target for memberships/actions/placements. admin-bff/__legacy never fork.templatecode, vertical
application_entity_membershipWhich entity types an app surfaces; resolve_target_apps reads it.templateapplication, 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.templatekey, application, organization
action_placementSurface binding of an action; Phase 2, anchor=application.templateapplication, action_type
notification_template / _recipient_rule / notification_ruleOperator notification config; Phase 2, no anchor (always copied, content-key deduped).templatecode/key + content key
report_templateReport catalog; forkable, Phase 2; base-FK preserved when unmapped.templatecode, metadata.forkable
portal / portal_pageCitizen-portal surfaces; forkable, Phase 2; portal in its own id-map bucket.templateroute, portal, placement
nav_section / nav_itemAdmin sidebar; materialized on template plane, copied by fork.templatecode, parent, order
admin_entity_configPer-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.tenanttenant_id, application_code, entity_code
organization (root) + organization_membershipPer-tenant root org is the anchor for every forked/imported row; admin membership flattens into tenant_admins_<code>.runtimecode, 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.pyapp/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 — applicationapplication_entity_membershipaction_type — plus that app's admin_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 by key-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 with metadata.forkable: true outside 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_sheetsseed_system_registriesapp.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: truederive_tenant_wide_plan picks it up from live schema; NO fork.py edit (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_rows emits ownership; it forks as a new spine. The create-vertical skill scaffolds this end-to-end.
  • Control-plane/identity presentation: a row in a sheet's admin_configs: block with an explicit application rides the admin_entity_config_explicit lane, homing it under identity from any sheet.
  • Cross-vertical membership: add the pair to the cross_vertical list — 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.forkable is true and it is outside the per-app spine — NOT keyed on control_plane (fork_plan.py:92-101); keying on control_plane would 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_admin must run for the back-office to be staff-reachable.

Gaps & open edges

  • Doc/code divergence (admin_entity_config): authoring-architecture.md:22 still 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:181 and fork_plan.py:151 say "every control_plane type" forks; the running code keys on forkable (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-142 correctly 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_rule kinds 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=true on the admin-bff service account (prove_from_zero.sh BOOTSTRAP); platform_admins is empty until provision_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_definition tenancy was deliberately left out of the authoring-unification arc (#75/#79 owns it). Demo litter (e2e* apps) accumulates on interrupted Playwright cleanup (cosmetic).

Atelier — Platform Specification. Internal canonical reference.