Skip to content

The four atoms

A Mantle Manifest declares exactly four resource kinds under the cms.mantle.aotter.net/v1 API group. This page explains what each atom owns, how they compose, and how to decide where a new feature belongs. Field-level rules live in the Reference section.

The four kinds

They map one-to-one onto the primitives Postgres has shipped for thirty years.

AtomPostgres equivalentExternally exposed by itself?Has user code?
SchemaCREATE TABLENo. Reached only through a View or a Procedure.No
ViewCREATE VIEWYes. Auto-mounted on its declared public or staff REST and MCP surface.No
ProcedureCREATE FUNCTIONNo. Transport-agnostic; needs a Trigger to gain a surface.Yes — a handler in your project's registry.
TriggerCREATE TRIGGER plus route and tool bindingYes. It is the binding atom.No

A four-word gloss covers most questions: Schema is state, View is the read API, Procedure is the write API, Trigger is the binding.

Reads and writes are asymmetric on purpose

Views mount themselves; Procedures do not. That asymmetry matches HTTP safe-versus-unsafe semantics. A View is a named, read-only query: idempotent, cacheable, and safe to expose the moment its surface is declared. A Procedure changes state, so its path, method, tool name and authorization are decisions an author must make explicitly, one Trigger at a time. There is no Schema.spec.expose.rest switch and no /api/<collection> shortcut; exposing collections directly was considered and rejected, because a Schema stores drafts, server-stamped fields and rows the author never meant to publish.

The consequence for authors: a Procedure that has no Trigger is unreachable from outside the runtime, and calling its name as a URL returns 404.

The composition rule

Anything more domain-shaped than these four is not an atom. A Form, Membership, Email, Webhook, Workflow or ScheduledJob is a composition of Schemas, Views, Procedures and Triggers plus your own TypeScript. An earlier iteration of this grammar shipped eleven domain-shaped kinds; authors could not decide whether a contact form was a Form, a Workflow or an Email, and each kind grew to subsume its neighbors.

If you find yourself wanting a fifth kind, sketch the same thing as a composition of the four first. Almost always it works.

The Manifest envelope

Every document carries the same four top-level keys. Unknown keys at any known level are rejected with INVALID_MANIFEST_ENVELOPE.

yaml
apiVersion: cms.mantle.aotter.net/v1
kind: Schema | View | Procedure | Trigger
metadata:
  name: posts
spec: {}

There is no namespace field. Names are unique within a kind, so a Schema and a View may share a name; two Schemas may not. Multi-tenancy belongs in your application layer, not in manifest metadata.

One feature usually needs several atoms, so YAML's --- separator keeps the file count down. Core reads every immediate .yaml and .yml file in the manifest directory and parses each document independently:

yaml
# manifests/contact.yaml
apiVersion: cms.mantle.aotter.net/v1
kind: Procedure
metadata:
  name: send-contact-message
spec:
  input:
    type: object
    additionalProperties: false
    required: [name, message]
    properties:
      name: { type: string, minLength: 1, maxLength: 80 }
      message: { type: string, minLength: 1, maxLength: 4000 }
  output: { type: object }
  handler: { kind: ref, ref: send-contact-message }
---
apiVersion: cms.mantle.aotter.net/v1
kind: Trigger
metadata:
  name: contact-http
spec:
  source: { kind: http, method: POST, path: /api/contact }
  target: { procedure: send-contact-message }

Parsing is all-or-nothing: one error in one document means no ParsedManifestSet at all.

Schema — the entity

A Schema declares one collection: the JSON Schema for each entry's data, its indexes, its Admin presentation and its lifecycle mode. Entries also carry the native columns id, status, version, createdAt, updatedAt and authorId outside data.

yaml
apiVersion: cms.mantle.aotter.net/v1
kind: Schema
metadata:
  name: notes
spec:
  title: Notes
  lifecycle: publishing
  schema:
    type: object
    additionalProperties: false
    required: [title, body]
    properties:
      title: { type: string, minLength: 1, maxLength: 200 }
      body: { type: string, x-mcp-hint: markdown }
  searchableFields: [title]

Full field rules, the JSON Schema subset and the x-mantle-bind / x-mantle-ref / x-mcp-hint keywords are in the Schema reference.

View — the read surface

A View is a named read-only query over Schemas, mounted on exactly one surface. No Trigger is involved.

yaml
apiVersion: cms.mantle.aotter.net/v1
kind: View
metadata:
  name: published-notes
spec:
  title: Published notes
  surface: public
  from: notes
  fields: [id, title, updatedAt]
  filter:
    eq: { field: status, value: published }
  orderBy:
    - { field: updatedAt, direction: desc }
  limit: 20

surface: public serves GET /api/views/published-notes and the query_view_published_notes MCP tool on /mcp; surface: staff moves both behind the staff gate. See Reads: Views, REST and MCP and the View reference.

Procedure — the typed callable

A Procedure declares typed input, typed output, optional requires, and one handler. It is the only atom with a code seam. The handler is either kind: builtin — a CRUD shortcut over one Schema — or kind: ref, an opaque key into the handlers map your project registers.

yaml
spec:
  handler: { kind: builtin, op: create, schema: notes }

Builtin ops are create, update, upsert, delete and archive. request_publish and publish are deliberately absent: they are lifecycle operations, not CRUD primitives. See Writes: Procedures, Triggers and lifecycle hooks and the Procedure reference.

Trigger — the binding

A Trigger says "when X happens, run Procedure Y". Its source is one of three kinds, and its target.procedure names a declared Procedure.

yaml
spec:
  source: { kind: http, method: POST, path: /api/contact }
  # or: { kind: mcp, surface: public }
  # or: { kind: lifecycle, schema: notes, on: [before_create] }
  target: { procedure: send-contact-message }

Several Triggers may target one Procedure, which is how the same handler becomes an HTTP endpoint, an MCP tool and a lifecycle hook without duplicated logic. Adding a transport is additive; the Procedure never changes. See the Trigger reference.

How to think when extending

  1. What entities does the feature need? One Schema each.
  2. What named queries? One View each. Ad-hoc reads stay inside handler code; only sanctioned queries become Views, because a View is an external read endpoint.
  3. What operations? One Procedure each. One Procedure equals one typed function call. Compose in handler code, not in YAML.
  4. What invokes them? One Trigger per declared HTTP, MCP or lifecycle source.
  5. Who is allowed? requires.auth for static identity and scope, plus one optional requires.guard.procedure for a live business check. See Authorization.

The Postgres heuristic

When you are unsure whether something deserves to be an atom, ask what it would be in Postgres.

  • If it is a CREATE X an application developer writes — TABLE, VIEW, FUNCTION, TRIGGER — it maps to one of the four.
  • If it is a CREATE X Postgres only needs because it is a database engine — INDEX, MATERIALIZED VIEW, TABLESPACE, STATISTICS, EVENT TRIGGER, EXTENSION, LANGUAGE, PUBLICATION, FOREIGN TABLE, RULE — it does not map, and it belongs behind the storage adapter.
  • If it is a type-system extension — TYPE, DOMAIN, SEQUENCE, COLLATION, CAST, AGGREGATE, OPERATOR — it folds into the JSON Schema inside a Schema.

Postgres exposes roughly twenty-five object kinds; an application developer writes four to six of them. That is the same ratio Mantle ships.

Source