Skip to content

Vertical Sheets

A vertical sheet is the heart of Atelier. It's a single YAML file that fully describes a running application across all three planes of the platform:

  • Data — the entities your app stores and how they're shaped.
  • Execution — the actions and business logic that operate on that data.
  • Surface — the staff admin screens, citizen portal pages, map layers, dashboards, and public APIs that expose it.

You declare an application as data, and Atelier generates everything else: the schema, the staff back office, the citizen-facing portal, the analytics views, and the REST surface. There's no application code to write, build, or deploy — the sheet is the app.

Declare, don't code. Everything else in Atelier — the action engine, public surfaces, notifications, analytics — is ultimately a block in this one grammar. Learn the sheet and you've learned the platform.

A small example

Here's a minimal sheet that declares one entity, one action, and one public read surface:

yaml
identity:
  code: street-lighting
  name: Street Lighting
  description: Citizens report broken streetlights; crews schedule and close repairs.
  route_namespace: street-lighting
  icon: lightbulb
  nav_order: 40
  is_active: true

entities:
  - entity_type: light_fault
    schema:
      display_name: Light Fault Report
      tenant_scoped: true
      fields:
        - field_key: description
          field_type: { type: string }
          required: true
        - field_key: reporter_email
          field_type: { type: string }
          pii: contact
        - field_key: status
          field_type:
            type: enum
            choices:
              - { value: open, label: Open }
              - { value: scheduled, label: Scheduled }
              - { value: fixed, label: Fixed }
          indexed: true
        - field_key: location
          field_type: { type: geometry }
        - field_key: reported_at
          field_type: { type: datetime }
      relationships:
        - rel_code: organization
          target_type_code: organization
          cardinality: many_to_one
          required: true
          reverse_code: light_faults

actions:
  - key: mark_fixed
    label: Mark fixed
    target_model: light_fault
    execution_mode: engine
    status: published
    is_active: true
    permissions_policy:
      view: ["admin", "staff"]
      apply: ["admin", "staff"]
      edit: ["admin"]
    edits:
      - { key: set-fixed, field: status, value: fixed }
    placements:
      - { key: mark-fixed-detail, surface: admin_detail, target_model: light_fault }

public_surfaces:
  light_fault:
    published_states: [open, scheduled, fixed]
    status_field: status
    public_fields: [description, status, location, reported_at]
    filterable_fields: [status]
    is_active: true

Import that, and you have: a light_fault entity with a generated admin list and detail view, a "Mark fixed" action wired into the back office, and a read-only public endpoint that citizens can query for open faults. No additional wiring required.

The building blocks

A sheet is composed of declarative blocks, each targeting one of the three planes.

BlockPlaneWhat it declares
identityNames the application, its route namespace, icon, and navigation order.
entities[].schemaDataAn entity type and its fields. A schema-bearing entry owns the type; a schema-less entry references an existing one.
actionsExecutionOperations on your data — parameters, create/edit effects, eligibility criteria, side effects, and where the action surfaces in the UI.
notification_event / template / ruleExecutionEvents your app emits, the messages they render, and the rules that decide who's notified.
viewsData / AnalyticsAggregate definitions — counts, group-bys, joins — that power dashboards and public tallies.
layersSurfaceGeographic overlays for the map, sourced from an entity, a view, or an external feed.
public_surfacesSurfaceThe single source of truth for what a citizen can read — which entity or view, which states, which fields.
portal / portal_pagesSurfaceThe public website and its route bindings.
presentation / dashboardSurfaceHow the staff admin renders each entity's list, detail, and dashboard.
reportsSurfaceDocument templates (PDFs, letters) generated from entity data.

actions[].submission_contract uses one canonical language for durable actions. Callable roles (normalize, validate, duplicates, prepare_create, prepare_writes) reference registered functions. Data roles (bindings, reference, attachments, audit_policy) declare materialization and audit behavior and are not function references.

public_surfaces also compile into one canonical row-gate model. Use explicit row_gate declarations for new sheets when possible; legacy published_states, unrestricted_read, and view-backed declarations are normalized into the same runtime model during import. Layers-only maps should be declared as layers-only surfaces rather than relying on an absent marker feed.

Two ways to author

There's one canonical destination — the application definition the rest of the platform reads — reachable two ways, and they stay perfectly in sync.

Write the YAML. Author a sheet by hand and import it. This is ideal for version-controlled, reviewable application definitions.

Use the authoring hub. Atelier ships a visual authoring surface where operators build the same applications through forms and editors. Because Atelier can export any live application back into sheet form, the two directions round-trip cleanly: what you click, you can declare, and what you declare, you can edit visually. Export then re-import always produces a zero diff.

How provisioning works

Both authoring paths run through the same provisioning pipeline:

  1. Parse — read the sheet and confirm it's well-formed.
  2. Validate — check every block against the live schema, so errors are caught before anything is written. This includes every parameter's default_binding: a reference the runtime couldn't resolve refuses at import, with the action and parameter named, instead of surfacing as a broken form at first render.
  3. Plan — diff the sheet against what already exists, producing a precise set of create/update operations.
  4. Apply — provision the entity types first, then walk the rest of the blocks in strict dependency order: application, entities, actions, notifications, reports, views, layers, public surfaces, and portal pages.

The reference vocabulary

Anywhere a sheet binds a dynamic value — a parameter's default_binding, an edit, a create field — it uses one reference vocabulary, validated at import and resolved at runtime by the same kernel:

ReferenceResolves to
$now / $todayThe current timestamp / date, pinned once per request.
$entity.<field>A field (or relationship) of the target row.
$parameters.<key>A value the caller submitted.
$user.<claim>The acting user — the authenticated principal.
$subject.<fact>The person the action is for (staff-assisted intake); allowlisted facts only — id, sub, name, email, birthdate.
$config.<type>A per-organization configuration row.
$created.<n>.<field>A record this same action just created (effect-side only: creates).
$function.<name>A registered server function (effect-side only: edits and creates).

Two mistakes refuse at import rather than at runtime: an unknown reference in a default_binding (a typo like $usr.email), and an $entity.* default on a bulk action — one form serves many rows there, so a per-row default has no single value to show.

Schema changes are additive by construction — provisioning evolves a type by adding to it, never by dropping or rewriting existing data. Re-importing a sheet is idempotent: running it twice converges on the same state rather than duplicating rows.

Tenancy: template and fork

Atelier's multi-tenant model is built directly on sheets.

  • A template application is provisioned from the sheets once. It carries the full vocabulary, presentation, actions, and surfaces for a vertical.
  • Onboarding a new tenant forks the template — every configurable row is copied and re-pointed onto the new tenant in a single operation.

After the fork, that tenant's administrators edit their own copy using the exact same editors, with no special-case code. Every configuration endpoint is keyed to the caller's resolved tenant, so each tenant's changes stay isolated to that tenant. Tenant data never crosses the boundary.

Invariants you can rely on

These guarantees hold for every sheet Atelier provisions:

  • identity.code is the application's identity. Everything the sheet declares is provisioned under it.
  • Tenant scoping is explicit. Every entity schema states whether it's tenant-scoped; the choice is made up front and is fixed once the type exists, keeping tenant isolation unambiguous.
  • Ownership is clear. An entity entry with a schema owns its type; without one, it references a type that already exists.
  • Evolution is additive. Schema changes only ever add — a non-additive change surfaces as a warning in the plan, never as a destructive write.
  • Actions stay in scope. An action targets an entity declared in the same sheet.
  • Personal data is declared. A field holding personal data carries a pii: classification (contact for communication channels, identity for person names, quasi for indirect identifiers like birthdates or subject ids; none marks a field reviewed as not personal). The classification lives on the live schema, so the per-tenant personal-data inventory in diagnostics — and every privacy lane derived from it — follows from the declaration. A field whose name looks personal but carries no classification is flagged as a warning at import.
  • Erasure derives from the declarations. An entity whose rows belong to a citizen declares the join once — metadata.pii_subject: { field: reported_by } (the field holds the account id) or { rel: reserved_by } (the relationship points at the user) — and the platform's erasure workflow scrubs its classified fields automatically. No declaration means the entity's personal data belongs to a third party (an event organizer, an investor lead) and a citizen's erasure never touches it.
  • Public surfaces are governed. Any view exposed to citizens is aggregated per tenant, so only tenant-isolated, row-free results ever leave the platform.
  • Round-trips are lossless. Export-then-import always diffs to zero.
  • Portal routes are namespaced. Public routes live under the application's route namespace, keeping every vertical's pages cleanly separated.

Extending a sheet

The grammar grows with your needs:

  • Add a vertical — author a new sheet with an identity.code, declare your entities (referenced types before the entries that point at them), actions, surfaces, views, and portal pages, then import.
  • Add an entity type — an entities[] entry with a schema provisions a new type; without one it references an existing type.
  • Add a public read surface — a public_surfaces row keyed by an entity or a view, with the states you want published.
  • Add analytics — declare a view with the group-bys and aggregates you need; surface it on a dashboard or expose its tally publicly.

Because the sheet drives data, logic, and surface together, extending one block is usually all it takes to ship a new capability end to end.

Atelier — declare your application, generate the product.