Skip to content

Authoring Surfaces — the editors, the hub, the act-as bridge

This is the human seam of Atelier: the place where an operator turns "an application" from an idea into rows of data, and where the platform's core promise — what you author is what municipalities get — is enforced at the auth layer rather than hoped for in convention. Concretely it is a catalog of SvelteKit editors (port 13002) that write the forkable config row-classes — application identity, entity bindings, presentation, dashboards, actions, notifications, portal pages, report templates — backed by the BFF, which terminates one cross-plane bridge (X-Author-Tenant), runs one authorization predicate, and writes to the ontology (template/tenant plane) or BFF Postgres (ledgers). It matters because every other subsystem in this spec — see The Action Engine, Notifications as Ontology, Presentation & admin_entity_config, The Fork — is declared here. If this seam leaks across tenants or lets operator edits miss the fork, the whole declare-don't-code thesis fails.

Data model

NamePurposePlaneKey fields
applicationApp identity the hub edits (label/icon/color/nav_order/is_enabled/features); the unit of fork. code immutable.templatecode (immutable), application__id, features JSONB
application_entity_membershipWhich entity types an app surfaces; per-binding is_visible/role_visibility/ordering.templateapplication FK, entity_type FK
admin_entity_configPer-(app,entity,tenant) presentation: list_display/list_filter/search_fields/ordering + field_display/dashboard_config/calendar_config/map_config. App-level __dashboard__ row holds dashboard. #383: now an ontology entity.tenantapplication_code + entity_code + tenant_id (UNIQUE 3-tuple)
action_type (+ parameter/create/edit/submission_criteria/side_effect, action_placement)Execution-plane config the action editor authors; side_effect.event FK is the notification emit.templateapplication__id, key; children FK to action_type
notification_ruleSubscribes to a notification_event; recipients_resolver/channels/template FK/organization (NULL=global)/filter.templateevent FK, template FK, organization FK
notification_templatesubject/body_html/body_text + extends; Jinja preview. Per-tenant override = clone w/ organization.templatecode (locked), extends FK
notification_eventEmit vocabulary the pickers read. Operators NEVER author events (D5).vocabularycode, source_action FK
report_templateDocument twin of notification_template.templatecode
portal_page / portalCitizen page (route, page_template ref, overrides, embedded_slots) + site config. Save fires compile-on-save.tenantapplication FK, route, template FK
edit_eventsAuthoring audit ledger; one row per config PATCH under the TARGET tenant. #248: ontology entity.BFF-postgresentity_type, entity_id, actor_id, tenant
branding / campaignRuntime-tenant rows edited WITHOUT act-as (plain client).tenanttenant-scoped

How it's declared (authoring surfaces)

Operators author through the admin SvelteKit app. The catalog bifurcates, and that split is the most important honest distinction in this subsystem:

Template-plane authoring (act-as, two-clients pattern). The Application Authoring Hub (/settings/applications list+create, /settings/applications/[code] 10-tab detail), the Action editor (/settings/actions/[id]), Notification rules (/settings/notification-rules), Notification templates (/settings/notification-templates), Report templates (/settings/report-templates), and Portal authoring (/[application]/portal/). The hub links to the deep editors rather than embedding them (link-out, avoids dual-state) — it is the navigation home, not a super-editor.

Runtime-tenant editing (plain client, NO act-as). Branding (/settings/branding/+page.server.ts:7-8,62,118) and Campaigns (/settings/campaigns/proxy.ts:8) deliberately edit the operator's own live tenant row — they are not template authoring and carry no X-Author-Tenant.

The unifying convention is two-clients-per-page (admin/src/lib/api/authoring.ts:25-36, bff.ts:160-176): every template-plane page-server builds, from one Auth.js token, both authoringBff(fetch, token) — a createBffApi with authorTenant=template_municipality so every call carries X-Author-Tenant: template_municipality — for the authored rows, and a plain createBffApi(fetch, token) for vocabulary and instance previews (events, schema, org pickers, demo rows). This is verbatim in notification-rules/+page.server.ts:64-294 (plain for events+orgs, act-as for rules+templates) and applications/[code]/+page.server.ts:122-577. Raw form-action fetches use authoringHeaders(token) to set the same three headers manually. The dashboard editor adds a preview==live guarantee: applications/[code]/dashboard-preview/+server.ts:1-61 resolves draft blocks through the same admin DataProvider + resolveData + chartBlockToBlock the rendered dashboard uses.

How it's provisioned

An authored application reaches a tenant via the fork, not live inheritance (authoring-architecture.md:§6-§7). (1) The platform author edits template_municipality rows through the editors; every write carries X-Author-Tenant=template_municipality, and the act-as gate rewrites _resolved_tenant to the template. (2) POST /tenants/{code}/fork (app/tenants/fork.py) does a one-shot full-catalog copy + FK-rewire of application, memberships, action_type+children, placements, notification config, and admin_entity_config into the tenant plane. (3) The tenant admin logs in (tenant_id=<code> claim) and edits THEIR copy with the SAME editors and ZERO special-case code, because every config endpoint keys SQL by _resolved_tenant — for a tenant author that is their own claim tenant (no act-as needed). app/seed.py bootstraps the demo posture: personas, tier rules (platform_admins + tenant_admins_<T>), template content, and the lisbon fork. The whole thing is pinned live by tests/test_live_hub_edit_reaches_fork.py:1-30: author on template via the bridge → fork → the fork's copy carries the edit. Pre-#199 the hub authored system-tenant rows while the fork copied template_municipality rows (two masters; operator edits never reached tenants) — this test pins the converged single-master path.

The act-as bridge is the only cross-plane mechanism (auth.py:328-351). After normal JWT auth resolves _resolved_tenant, _apply_act_as validates the header shape against ^[a-z][a-z0-9_-]*$ (422), calls authoring_update_allowed (403 if false, 503 if UMS down), then rewrites claims["_resolved_tenant"] and sets _acting_as_tenant. Crucially, authoring_update_allowed (auth.py:275-325) reads the caller's raw SpiceDB tier-group membership (platform_admins → any tenant; tenant_admins_<T> → only T) via ums.list_principal_associations, deliberately independent of the data-plane admin_entity_config:write rule, so a projection bug over-granting that rule cannot amplify into a cross-tenant control-plane breach. The PATCH endpoint (admin_config.py:221-332) rejects the legacy ?tenant= param (422), skips the redundant write-grant check when _acting_as_tenant is set (already vetted), validates against AdminEntityConfig, upserts ontology-only, and logs an edit_events row under the TARGET tenant.

Extension points (the growth vector)

  • New editor for an existing row class: add admin/src/routes/settings/<thing>/+page.server.ts following two-clients; CRUD via form actions using authoringHeaders. No backend change if the entity proxy already serves the type.
  • New hub tab: drop a <Tab>.svelte under applications/[code]/tabs/, wire its data into the +page.server.ts Promise.all load and a form action.
  • New act-as-able tenant: nothing in the bridge is hardcoded per-tenant — provisioning a tenant_admins_<code> group + grant (seed/saga) unlocks a tenant author; platform_admins unlocks any target.
  • New runtime-tenant editor: use plain createBffApi with no authorTenant (branding/campaigns pattern).
  • Aux presentation editors (field_display, calendar_config, map_config) slot into the Presentation tab as deep editors; the dashboard_config one already shipped as DashboardEditor.svelte.
  • YAML round-trip (#94.5) imports/exports a whole vertical as a vertical_sheets file, reusing fork's FK-ordered materializer.

Invariants

  • X-Author-Tenant is honored ONLY for the matching UMS tier-group grant; no role (not even realm admin) substitutes (auth.py:275-325; #203 closed the role-only hole).
  • UMS unreachable on act-as → 503 (fail-closed); no grants → 403/deny.
  • The act-as grant is read from raw tier-group membership, never from data-plane rule resolution (auth.py:281-289).
  • Redundant PATCH write-grant check is skipped iff _acting_as_tenant (admin_config.py:250-255).
  • Legacy ?tenant= act-as param → 422; the header is the only bridge.
  • Every config reader keys SQL by _resolved_tenant with no NULL fallthrough (#161) — a non-forked tenant reads empty, never another tenant's rows.
  • Every authoring PATCH writes an edit_events row under the target tenant recording acting_as; audit is best-effort, never rolls back the write.
  • application.code immutable after first save; operators never author notification_event vocabulary (D5).
  • portal_page save keeps citizen placement + UMS ACL in lockstep (compile-on-save, fail-closed).

Gaps & open edges

  • Branding and campaigns are not template-plane authoring despite living under /settings — they edit the live tenant via the plain client. The authoring-architecture doc's "the editors all use the act-as bridge" framing does not cover them; a reader could wrongly assume uniformity.
  • Fork is copy-on-create with no live inheritance and no re-sync (authoring-architecture.md:104-106,161). A platform author who fixes a template bug after tenants have forked has no supported propagation path — explicitly unscoped. For an "author once, all tenants get it" thesis this is a real divergence-accumulation risk.
  • Fork re-run hydration maps applications + action_types only; template/recipient_rule kinds aren't re-hydrated on skipped apps — harmless today via content-key dedup but breaks after a partial Phase-2 failure (:158).
  • Legacy #114-era tier grants (application:create/acl_rule:create) are rejected by current UMS action-vocabulary validation on re-seed (warnings); the role-tier reconciler is the named-but-not-fully-landed fix.
  • Portal authoring's live read historically rode the is_superuser/is_service bypass in entity_proxy._should_enforce "until #143 removes the bypass" (79-portal-authoring.md:147-169) — a transitional control-plane smell.
  • action_side_effect dual-write: still writes the denormalized event_type string alongside the event FK during the 4a-4e window (slice 4f not yet cleaned).
  • Authz tests are live-gated. The unit test_act_as_header.py fakes authoring_update_allowed, so the SpiceDB membership read itself runs only in the live matrix — not in CI unit runs. The admin-api suite is not in CI and rots.
  • Spec drift: the admin_config PATCH endpoint was a discovered gap (spec line 890 "FALSE — GAP") that has since shipped (admin_config.py:221) but the #94 spec still reads as pre-existing; Diagnostics-tab and per-org instance authoring (O16-O21) are filed-but-not-confirmed-shipped from those specs alone.

Atelier — Platform Specification. Internal canonical reference.