The Surface Plane — applications, page templates, widget catalog, the shared renderer
The surface plane is Atelier's "what is experienced" layer (m10 PRD §2.1, dev_docs/specs/m10-composable-application-system-prd.md:30). It projects the data plane (entities) and the execution plane (actions — see The Action Engine) into running staff-admin and citizen-portal experiences without writing UI code. Operators DECLARE an experience as an ordered list of Block descriptors — declarative pointers, never baked data — and a single, deliberately presentation-only renderer shipped in @aiaiai-pt/design-system (version 0.38.0 at portal/node_modules/@aiaiai-pt/design-system/package.json:3, renderer/* export) dispatches each Block to a widget. Both hosts import this ONE renderer; #492 consolidated them onto it. This is the compound-software thesis made concrete: any entity renders in any widget, any action triggers from any surface, and the widget catalog grows without breaking existing compositions.
Data model
| Name | Purpose | Plane | Key fields |
|---|---|---|---|
Block (descriptor) | Atomic, persisted unit of a page/dashboard — a pointer (widget intent + data binding + placement). Stores only a pointer; rows fetched at render. | runtime | type (untrusted hint), slot, name, binding, props, importance(optional|structural), ownsH1, errorSummary, lock, bleed, layout{x,y,w,h}, children(reserved) |
WidgetKind | Bounded, governed enum of ~35 widget kinds — the compound-growth catalog. | vocabulary | list/detail/form/map/calendar/kpi/content/vote/status/forms/hero/lookup/feed/filters/actions/aggregate/subscriptions/consent/deliveries/owned-list + presentational set (feature-grid/cta/media-text/steps/testimonial/faq/logo-strip/media-gallery) |
RegistryEntry | One dispatchable widget: known-good key, Svelte component, tester→score. | runtime | key (TH-08 literal), payload, tester (10=byKind, 20=byTypeOnKind, −1=NOT_APPLICABLE) |
page_template (#74) | Shared page-shape catalog: named ordered slots + per-slot lock/importance/a11y reservations + locked defaults. NOT forked. | vocabulary | code (9: landing/service-flow/browse/detail/tracker/results/content/account/error), slots(json), steps, rhythm, is_active; unique(code); tenant_scoped=False |
portal_page (#79) | Tenant binding of a route → template + sparse content overrides, OR an embedded forked slot tree. | tenant | route, application FK, template+overrides OR kind=forked; tenant_scoped=True |
admin_entity_config __dashboard__ row | App-level dashboard authoring blob. | template | entity_code='__dashboard__', dashboard_config{kpi_cards, embeds, chart_blocks} |
action_surface | System registry of surfaces an ACTION can render/trigger on (execution-plane registry, distinct from the widget registry). | vocabulary | code (public_submit/voting/browse/detail/map/…), audience(citizen|staff|programmatic), presentation; tenant_scoped=False |
BlockLayout (#176) | Optional 12-col grid coords; consumed by both render and authoring. | runtime | x, y, w(1..12), h(≥1) |
The canonical seam is portal/node_modules/@aiaiai-pt/design-system/components/renderer/types.ts:149-202: Block, Binding ({kind, entity, action, filter, limit, order, expand} — "render <kind> of <entity|action>, scoped by filter"), the WidgetKind union, and WidgetProps — the uniform {data, schema, actionDef, props, app?, ownsH1?, locale?, dataPath?, now?} contract every widget receives so one dispatcher can mount any widget.
How it's declared
Three authoring surfaces, all keyed to the render-registry keys, none touching DS editing metadata:
- Dashboard authoring — admin route
/settings/applications/[code], Dashboard tab (admin/src/routes/settings/applications/[code]/tabs/DashboardEditor.svelte). Operators add/reorder/remove KPI cards, charts, and Metabase embeds. The chart-type dropdown is populated byaggregateChartTypeKeys()(admin/src/lib/dashboard/widget-authoring.ts:56-65), which PROBES the live DS registry — callingresolveWidgetper hardcoded candidate and keeping only keys that round-trip, so no free-text and a removed widget silently drops out. A live preview renders each draft chart through the SAMEresolveData+resolveWidgetpath as the live dashboard. - Portal page authoring —
portal_pagerows bind a route to apage_templatecode + a sparse content override map (or aforkedslot tree). Overrides flow throughresolveSlots(portal/src/lib/templates/catalog.ts:103-245). - Catalog authoring —
page_templateandaction_surfaceare seeded vocabulary (system tenant), edited by platform authors via provisioning sheets /seed.py, not per-tenant operators.
chartBlockToBlock (widget-authoring.ts:120-139) is the single builder turning a ChartBlockConfig into a Block, consumed by the live render, the preview proxy (dashboard-preview/+server.ts:47), and the editor preview — making preview == live true by construction.
How it's provisioned
- Vocabulary plane (
system, NOT forked):page_templaterows (admin-api/app/seed.py:6389-6472, tenant_scoped=False, code unique),action_surfacerows (seed.py:8800+), and the DS widget registry itself (compiled code in the npm package) are global and read by every tenant. - Template plane (
template_municipality, fork source): the__dashboard__admin_entity_configrow, theportalrow, andportal_pagerows are authored here via the same editors withX-Author-Tenant: template_municipality(admin/src/lib/api/authoring.ts:20-30), then copied + FK-rewired by the one-shot full-catalog fork (admin-api/app/tenants/fork.py). - Tenant plane (per fork, e.g. lisbon): a tenant admin edits THEIR copy with the identical editors and zero special-case code, because every config endpoint is keyed by the caller's resolved tenant. Inferred (not re-confirmed at file:line this session): that the fork copies the
__dashboard__andportal_pagerows specifically — established by the X-Author-Tenant doctrine and prior fork work; confirmable infork_plan.py.
The portal renders through a single dynamic catch-all (portal/src/routes/[...path]/+page.server.ts:39-192): match the path to a portal_page → load the shared page_template → resolveSlots → per-block resolveData via the public provider → apply the page-level fail-closed status gate.
Extension points (the growth vector)
- New widget: implement a Svelte component honoring
WidgetProps, thenregisterWidget(byKind(key, kind, component))(orbyTypeOnKindfor a type-ranked variant) at host startup — no change todispatch.ts,resolve-data.ts, or existing widgets (open/closed;registry.ts:93-95). A new data shape adds aWidgetKindplus a branch inbffPathForBinding. - New chart type: register the DS aggregate widget, add its key to
AGGREGATE_CHART_CANDIDATES+CHART_TYPE_LABELS; the probe auto-includes it once the registering DS is consumed. - New page template: append a
page_templaterow under system — every portal can reference it immediately;resolveSlotscompiles any valid slots contract. - New portal page / action surface: append a
portal_page(tenant) oraction_surface(system) row; the single[...path]route and the placement editor's surface picker pick them up by row. - 2D layout: set
Block.layout; the renderer switches to the 12-col grid when any block carries layout (hasGridLayout,grid.ts:19-44), fully back-compatible with the stacked default. - New host: implement a
DataProviderover that host's own security-scoped lane and reuseresolveData/resolveWidgetunchanged (the admin host is the worked example over the authed/viewslane).
Invariants
- TH-08 / R-SEC-07: the widget identifier reaching the DOM is ALWAYS the matched registry entry's known-good key literal — never the operator's untrusted
block.type/binding.kind(dispatch.ts:73,types.ts:152-156). - Fail-closed blast radius (§14.8): an unmappable binding never fetches; a structural slot that can't load surfaces a visible error (and the portal returns the right HTTP status); an optional slot soft-empties — never a 200 with a broken shell (
dispatch.ts:90-99). - Per-host security boundary: each host owns its own
DataProvider; the public (auth-optional/bff) and authed (operator-Bearer/viewsor/me) transports are NEVER shared — the admin provider THROWS on citizen lanes (admin-data-provider.ts:104-130; SECURITY INVARIANT commentdata-provider.ts:29-37). - A Block stores only a POINTER (
types.ts:1-14); preview == live via the singlechartBlockToBlockbuilder. - Exactly one
<h1>per page:resolveSlotsstampsownsH1onto only the first block of an owns-h1 slot;validateTemplaterejects >1 owns-h1 / >1 error-summary slot (catalog.ts:200-209, 242-243). page_templateis tenant_scoped=False (shared catalog, code is the natural PK); per-tenant structural divergence lives ONLY on tenant-scopedportal_pagerows.- DS stays presentation-only: it carries no editing metadata; per-widget authoring field-schema lives in the host, keyed to the render-registry keys (
widget-authoring.ts:1-15).
Gaps & open edges
- The m10 PRD diverged from what shipped and is not marked superseded. The PRD describes
applications:/pages:/widgets:YAML, semanticdata_sourcespecs, interaction-pattern lenses (Monitor/Triage/Execute/…), and a "surface type catalog." What shipped isBlock[]+ aWidgetKindenum +page_template/portal_pagerows + ontology VIEWs as the aggregate source (see The Aggregate/Views Plane). A reader treating the PRD as ground truth would be misled. - The admin host only PARTIALLY uses the converged renderer: only
aggregatechart_blocksroute throughresolveWidget([application]/+page.svelte:216-240); KPI cards (StatCard/StatGrid loop) and Metabase embeds (iframe) on the same dashboard are still bespoke. "One renderer drives admin" is true only for charts. - The
kpiWidgetKind has NO wired public read path:bffPathForBindingreturns null for it (resolve-data.ts:225-230); stat-grid is registered but its data path is unimplemented, and admin KPI stats load via a separate server path. The catalog advertises a kind the data layer doesn't serve. Block.children(grid-in-grid) is declared but RESERVED — single-level grids only (types.ts:196-201).- No single enumerable catalog: the DS-internal mutable
_entriesis the real dispatch source; the portal's hand-maintainedWIDGET_ENTRIESmirror (35 entries) and the hardcodedAGGREGATE_CHART_CANDIDATESallowlist can drift — the probe confirms availability but does not discover keys. portal-config.tsstill hardcodes a VALONGO-specific default (tokens,enabledVerticals=['occurrences']) as the fail-soft base; the data-driven overlay is wired but the built-in default is demo-tenant-specific. A 10s config cache with no cross-process bust means an admin branding edit only appears in the portal after the TTL lapses.- Free tenant STRUCTURAL composition (
lock=falseviaauthoredBySlot) exists inresolveSlotsbut is a11y-review-gated; the authoring UI for free composition is not confirmed present this session.