Skip to content

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.pyparse_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 classPurposePlaneKey fields
identityapplicationNames the app; identity.code = application_id. name→label, is_activeis_enabled.template (vocabulary for the app row class)code, name, route_namespace, icon, nav_order, features
entities[].schemaDATA 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_membershipBinds a type to the app with nav visibility/order/role visibility.templateentity_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_configsadmin_entity_config_explicit#440 control-plane presentation rows; per-row application, type-gate bypassed.template / control-planeapplication (per-row), entity_type, config
action_type + childrenEXECUTION plane: action_parameter/create/edit/submission_criteria/side_effect/placement.templatekey, target_model, execution_mode, permissions_policy
notification_event/template/recipient_rule/ruleEvents 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
viewAnalytics: ontology VIEW definitions (global). normalize_view_decl canonicalizes.vocabulary (global)code, source_type_code, select, group_by, aggregate, join
layerGeo overlays against geolayers. Source kinds entity_type/view/file/external.runtime (geolayers)code, render_as, source.kind/ref/geom_property, style
public_entity_surfaceSURFACE read-exposure SoT: which entity OR view a citizen reads.templateentity_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_pagePublic site + route bindings. portal 1:1 app; page keyed by route.templateportal.code, portal_page.route/template/overrides
report_template#246 document twin of notification_template; org-stamped at apply.templatecode, 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.seedseed_ontologyscripts.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>.yaml with identity.code, declare entities[].schema (FK targets before referencers), actions, surfaces, views, portal_pages; import. The create-vertical skill is the recipe.
  • Add a grammar block: add it to _APPLIED_BLOCKS, give it a class in APPLY_ORDER (in FK order), flatten in _collections, add an apply branch in apply_plan (the else-clause raises "No apply handler" — fails loud), add a validate_sheet shape gate, and round-trip it in build_sheet.
  • Add an entity type: an entities[] entry WITH schema provisions it; without schema it must reference a pre-existing type.
  • Add a control-plane row: use the admin_configs: lane (per-row application, type-gate bypassed) rather than entities[].presentation.
  • Add a public read surface: a public_surfaces row keyed by entity_type or a view code (source_kind: view); published_states required.
  • Add a manifest guard: extend _classify/_detect_* in build_sheet_manifest.py.

Invariants

  • identity.code is required and IS the application_id everything provisions under.
  • schema.tenant_scoped is 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 entities entry 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_model must be an entity declared in the same sheet.
  • Any view backing a public_entity_surface MUST group_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_rule rows 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.py is 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 flags data; only --check (staleness) returns non-zero. Live sheets ship with flags (occurrences: portal-pages-without-backing; c6: intake-without-surface).
  • identity.features vs the derived feature registry (UNVERIFIED). route_namespace, features:{map,calendar}, icon, nav_order ride verbatim into the application POST body via _collections; I did not confirm the ontology application schema 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_surface alone 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.

Atelier — Platform Specification. Internal canonical reference.