Vertical Sheets — the declarative provisioning grammar
A vertical sheet is the keystone authoring artifact of Atelier: one YAML file under admin-api/provisioning/sheets/sheet_<name>.yaml that fully expresses a running application across all three functional planes — DATA (entities[].schema), EXECUTION (actions), and SURFACE (public_surfaces, layers, portal_pages, presentation/dashboard). The sheet's identity.code is the application_id that everything provisions under (CONFIRMED app/applications/yaml_import.py — parse_sheet rejects any sheet lacking identity.code). This is the declarative substrate the rest of the platform builds on: an operator declares an app as data, the importer turns it into ontology rows, and the generated staff admin, citizen portal, and APIs read those rows. It is also the round-trip format the authoring hub round-trips through (yaml_export.build_sheet), so export→import always diffs to zero. Everything authored elsewhere in Atelier — see The Action Engine, Public Surfaces & the Citizen Portal, Notifications as Ontology — is ultimately a block in this grammar.
Data model
| Block / row class | Purpose | Plane | Key fields |
|---|---|---|---|
identity → application | Names the app; identity.code = application_id. name→label, is_active→is_enabled. | template (vocabulary for the app row class) | code, name, route_namespace, icon, nav_order, features |
entities[].schema | DATA plane: provisions/evolves an entity TYPE. WITH schema = owns the type; WITHOUT = reference-only. | vocabulary (type) + template (membership) | entity_type, schema.display_name, schema.tenant_scoped, fields[], relationships[], unique_constraints |
application_entity_membership | Binds a type to the app with nav visibility/order/role visibility. | template | entity_type, visible, ordering, role_visibility |
admin_entity_config (presentation/dashboard) | Per-entity admin presentation; dashboard: upserts the __dashboard__ row. | ontology-only (#383 table dropped) | entity_code (or __dashboard__), PRESENTATION_COLUMNS |
admin_configs → admin_entity_config_explicit | #440 control-plane presentation rows; per-row application, type-gate bypassed. | template / control-plane | application (per-row), entity_type, config |
action_type + children | EXECUTION plane: action_parameter/create/edit/submission_criteria/side_effect/placement. | template | key, target_model, execution_mode, permissions_policy |
notification_event/template/recipient_rule/rule | Events minted into SYSTEM vocab (code-keyed); templates/rules tenant-scoped; rules carry no org stamp. | vocabulary (event) + template (rest) | event.code, template.code, rule.name/event/channels/filter |
view | Analytics: ontology VIEW definitions (global). normalize_view_decl canonicalizes. | vocabulary (global) | code, source_type_code, select, group_by, aggregate, join |
layer | Geo overlays against geolayers. Source kinds entity_type/view/file/external. | runtime (geolayers) | code, render_as, source.kind/ref/geom_property, style |
public_entity_surface | SURFACE read-exposure SoT: which entity OR view a citizen reads. | template | entity_type (or view code), published_states, source_kind, public_fields |
page_template | #313 shared portal page-template catalog (tenant_scoped=False). | vocabulary (global registry) | code, slots[], steps? |
portal/portal_page | Public site + route bindings. portal 1:1 app; page keyed by route. | template | portal.code, portal_page.route/template/overrides |
report_template | #246 document twin of notification_template; org-stamped at apply. | template | code, extends?, entity_type_code |
standards_relay | #252 NGSI-LD mirror target the standards-gateway reads. | vocabulary (tenant_scoped=False) | standard, entity_types, target_broker_url |
How it's declared
Two front-doors lead to the same ontology rows. (1) Author the YAML directly under admin-api/provisioning/sheets/ and import it (python -m scripts.import_sheets, or per-sheet scripts/check_sheet.py). sheet_a1_public_space_occupancy.yaml is the canonical worked smart-city example; sheet_00_platform.yaml is the platform substrate (page_template catalog, nav, control-plane types) that imports first. (2) The operator authoring hub under admin/src/routes/settings/ drives POST /applications/import-yaml/apply (routers/applications.py wraps the shared core and writes a snapshot). Because yaml_export.build_sheet exports live rows back to the same grammar, what you click you can declare and vice-versa — the round-trip diffs to zero, guaranteed by running _collections plus the normalize_* canonicalizers identically on both diff sides. The create-vertical Claude skill is the canonical 14-step CREATE→DECLARE→SEED recipe. The recognized top-level blocks are fixed in _APPLIED_BLOCKS (CONFIRMED yaml_import.py:97-115); anything else (Martha workflows, portal widget trees) belongs to another service and becomes a plan note.
How it's provisioned
One request-free core, prepare_sheet_plan (CONFIRMED app/applications/sheet_apply.py:49), is shared by both front-doors. It runs a four-stage pipeline: parse (parse_sheet) → validate against the LIVE ontology schema (validate_sheet, plus _validate_views, _validate_action_children #457) → diff (build_plan, verbs create/update/skip, NO delete) → apply (apply_schema_ops creates/evolves TYPES first, then apply_plan walks APPLY_ORDER in strict FK-dependency order — application → membership → admin configs → actions → notifications → reports → views → layers → public surface → page_template → portal/portal_page → standards_relay; CONFIRMED yaml_import.py:42-93).
Boot path (template plane): the entrypoint runs app.seed → seed_ontology → scripts.import_sheets as the SA on PROVISION_SHEET_TENANT (default template_municipality), importing all sheets in manifest topo-order. It is fail-open per sheet — a parse/validate/apply failure is logged and skipped so provisioning never blocks the BFF (CONFIRMED import_sheets.py:16-17,144-149). Tenant path: a one-shot full-catalog fork (fork.py, 5 phases) copies and FK-rewires every template-plane row class onto the new tenant; or ADR-0004 bespoke onboarding issues two import-yaml/apply calls via the X-Author-Tenant header. After either, a tenant admin edits THEIR copy with the same editors and zero special-case code, because every config endpoint is keyed by the caller's resolved tenant. Post-walk, two reconciliations run: sync_imported_action_apply_rules (UMS apply-rule sync + action_loader.clear_cache()) and _resync_citizen_acls_from_import (whenever the plan touched a portal_page or action_placement). CI gates — check_sheet --applied (hard, app-row-exists proof), build_sheet_manifest --check (staleness), run_sheet_tests — exist but are NOT on the boot path.
Extension points (the growth vector)
- Add a vertical: create
sheet_<name>.yamlwithidentity.code, declareentities[].schema(FK targets before referencers), actions, surfaces, views, portal_pages; import. Thecreate-verticalskill is the recipe. - Add a grammar block: add it to
_APPLIED_BLOCKS, give it a class inAPPLY_ORDER(in FK order), flatten in_collections, add an apply branch inapply_plan(the else-clause raises "No apply handler" — fails loud), add avalidate_sheetshape gate, and round-trip it inbuild_sheet. - Add an entity type: an
entities[]entry WITHschemaprovisions it; withoutschemait must reference a pre-existing type. - Add a control-plane row: use the
admin_configs:lane (per-rowapplication, type-gate bypassed) rather thanentities[].presentation. - Add a public read surface: a
public_surfacesrow keyed by entity_type or a view code (source_kind: view);published_statesrequired. - Add a manifest guard: extend
_classify/_detect_*inbuild_sheet_manifest.py.
Invariants
identity.codeis required and IS theapplication_ideverything provisions under.schema.tenant_scopedis REQUIRED-EXPLICIT boolean on every schema block — no default, immutable after registration (schema_apply.py:72-81). A wrong/absent value leaks rows across every tenant.- An
entitiesentry WITH a schema OWNS the type; WITHOUT one it MUST reference a pre-existing type. - Schema evolution is ADDITIVE-ONLY; non-additive drift becomes a plan-note WARNING, never a destructive write.
- The diff has NO delete verb; recovery is delete + re-run (O30).
- Export→import round-trips to a zero diff.
actions[].target_modelmust be an entity declared in the same sheet.- Any view backing a
public_entity_surfaceMUSTgroup_by tenant_id; a grouped view's filter may only reference grouped/aggregated columns. - notification events are minted into the SYSTEM vocabulary (find-or-create by code);
notification_rulerows carry no org stamp. - Re-import is idempotent via per-class find-or-create / 409-converge, not via the diff alone.
- Portal routes must root under
route_namespace(the importer adopts a portal_page by(tenant, route)alone — a bare route silently overwrites another vertical's page).
Gaps & open edges
- Fail-open hides fatal sheets. A bad sheet is silently dropped at boot and surfaces only downstream as "nav blank / citizen 404."
check_sheet.pyis the hard-gate counterpart but is NOT on the boot path, and per project MEMORY the admin-api suite/these gates are not in main CI (the Django suite SKIPs) — so they rot. - Manifest guards are advisory. route-collision, type-collision, unrooted-portal-route, unresolved-placement-binding, intake-without-surface are written as
flagsdata; only--check(staleness) returns non-zero. Live sheets ship with flags (occurrences: portal-pages-without-backing; c6: intake-without-surface). identity.featuresvs the derived feature registry (UNVERIFIED).route_namespace,features:{map,calendar},icon,nav_orderride verbatim into theapplicationPOST body via_collections; I did not confirm the ontologyapplicationschema accepts them. This sits in tension with #386 ("map/calendar are DERIVED not stored, no tenant_feature entity") and looks like divergence worth reconciling.- Layers apply fail-soft. If geolayers is unreachable at the pre-loop probe, every layer op is skipped — a sheet can apply "successfully" with zero map layers and no hard error. Guards the from-zero civic crash class but can mask a real geolayers auth wall as "no layers."
- Idempotency is asymmetric. notification_template/rule, portal, page_template, etc. have explicit find-or-create (blind POSTs historically duplicated rows — 359 proposal_received, 3 default portals, 16× phase_opened cited in-code). action_type/membership/children/placements rely on the app-scoped diff — safe only because a fresh app carries no application-FK'd rows; an implicit, undertested assumption.
- No fleet-wide view-collision guard. Views are global but applied per-sheet; two sheets declaring the same view code with different specs silently keep whichever applied first.
- Citizen-ACL resync trigger is narrow. It fires only on portal_page/action_placement changes; a sheet altering citizen exposure via
public_entity_surfacealone would not trigger the resync. - Dual layer shapes. Layers accept both a code-keyed mapping and a legacy list form — a latent inconsistency an author can trip on.