Appearance
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: trueImport 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.
| Block | Plane | What it declares |
|---|---|---|
identity | — | Names the application, its route namespace, icon, and navigation order. |
entities[].schema | Data | An entity type and its fields. A schema-bearing entry owns the type; a schema-less entry references an existing one. |
actions | Execution | Operations on your data — parameters, create/edit effects, eligibility criteria, side effects, and where the action surfaces in the UI. |
notification_event / template / rule | Execution | Events your app emits, the messages they render, and the rules that decide who's notified. |
views | Data / Analytics | Aggregate definitions — counts, group-bys, joins — that power dashboards and public tallies. |
layers | Surface | Geographic overlays for the map, sourced from an entity, a view, or an external feed. |
public_surfaces | Surface | The single source of truth for what a citizen can read — which entity or view, which states, which fields. |
portal / portal_pages | Surface | The public website and its route bindings. |
presentation / dashboard | Surface | How the staff admin renders each entity's list, detail, and dashboard. |
reports | Surface | Document 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:
- Parse — read the sheet and confirm it's well-formed.
- 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. - Plan — diff the sheet against what already exists, producing a precise set of create/update operations.
- 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:
| Reference | Resolves to |
|---|---|
$now / $today | The 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.codeis 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 (contactfor communication channels,identityfor person names,quasifor indirect identifiers like birthdates or subject ids;nonemarks 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 aschemaprovisions a new type; without one it references an existing type. - Add a public read surface — a
public_surfacesrow keyed by an entity or a view, with the states you want published. - Add analytics — declare a
viewwith 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.