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
| Name | Purpose | Plane | Key fields |
|---|---|---|---|
application | App identity the hub edits (label/icon/color/nav_order/is_enabled/features); the unit of fork. code immutable. | template | code (immutable), application__id, features JSONB |
application_entity_membership | Which entity types an app surfaces; per-binding is_visible/role_visibility/ordering. | template | application FK, entity_type FK |
admin_entity_config | Per-(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. | tenant | application_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. | template | application__id, key; children FK to action_type |
notification_rule | Subscribes to a notification_event; recipients_resolver/channels/template FK/organization (NULL=global)/filter. | template | event FK, template FK, organization FK |
notification_template | subject/body_html/body_text + extends; Jinja preview. Per-tenant override = clone w/ organization. | template | code (locked), extends FK |
notification_event | Emit vocabulary the pickers read. Operators NEVER author events (D5). | vocabulary | code, source_action FK |
report_template | Document twin of notification_template. | template | code |
portal_page / portal | Citizen page (route, page_template ref, overrides, embedded_slots) + site config. Save fires compile-on-save. | tenant | application FK, route, template FK |
edit_events | Authoring audit ledger; one row per config PATCH under the TARGET tenant. #248: ontology entity. | BFF-postgres | entity_type, entity_id, actor_id, tenant |
branding / campaign | Runtime-tenant rows edited WITHOUT act-as (plain client). | tenant | tenant-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.tsfollowing two-clients; CRUD via form actions usingauthoringHeaders. No backend change if the entity proxy already serves the type. - New hub tab: drop a
<Tab>.svelteunderapplications/[code]/tabs/, wire its data into the+page.server.tsPromise.allload 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_adminsunlocks any target. - New runtime-tenant editor: use plain
createBffApiwith noauthorTenant(branding/campaigns pattern). - Aux presentation editors (
field_display,calendar_config,map_config) slot into the Presentation tab as deep editors; thedashboard_configone already shipped asDashboardEditor.svelte. - YAML round-trip (#94.5) imports/exports a whole vertical as a
vertical_sheetsfile, reusing fork's FK-ordered materializer.
Invariants
X-Author-Tenantis 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_tenantwith no NULL fallthrough (#161) — a non-forked tenant reads empty, never another tenant's rows. - Every authoring PATCH writes an
edit_eventsrow under the target tenant recordingacting_as; audit is best-effort, never rolls back the write. application.codeimmutable after first save; operators never authornotification_eventvocabulary (D5).portal_pagesave 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_servicebypass inentity_proxy._should_enforce"until #143 removes the bypass" (79-portal-authoring.md:147-169) — a transitional control-plane smell. action_side_effectdual-write: still writes the denormalizedevent_typestring alongside theeventFK during the 4a-4e window (slice 4f not yet cleaned).- Authz tests are live-gated. The unit
test_act_as_header.pyfakesauthoring_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_configPATCH 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.