Meridian handbook
For agents

Rules

Every rule a coding agent must follow in a Meridian project, why it exists and exactly what breaks when it is ignored, covering configuration, ids, secrets, schema changes, content, templates, accessibility, generated files and production.

These rules come from the engine itself: most are enforced by engine-dmo check, schema apply or the renderer, and the rest protect data the engine cannot protect for you. Each says what to do, why, and what happens otherwise.

Configuration

Change configuration through files and engine commands

Everything a site is (types, fields, sites, locales, routes, endpoints, navigation slots, roles, tiers, portal screens) lives in YAML under config/. The database holds a reconciled copy that engine-dmo schema apply writes. Content lives in the database and is written by the admin, engine-dmo content, engine-dmo seed or the APIs.

  • Do use a dev verb where one exists: it edits the file keeping its comments, writes the new element’s id and checks the configuration, and writes nothing when the configuration itself would be invalid. It does not wait for templates: a route or endpoint it adds names templates you must then write, and check fails until you do. Run engine-dmo check after every verb.
  • Do edit YAML by hand when no verb fits, then run engine-dmo check --fix.
  • Never write SQL, connect to the database with psql, or change a table. The engine keeps revisions, search, projections, caches and audit records consistent only through its own writes. A row you insert is invisible to them, and a schema change you make by hand is undone or refused by the next schema apply.

Unknown keys are errors

Every configuration file is decoded strictly. A misspelt key is an error with a suggestion (unknown field "pagniate", did you mean "paginate"?), not a silent no-op. Look the key up with engine-dmo config schema --file <kind> rather than guessing.

Secrets are only ever ${VAR}

Configuration is committed to version control. A password, API key, token or signing key is written as a bare ${VAR} and the value comes from the environment:

mail:
  smtp:
    password: ${SMTP_PASSWORD}

A literal in a secret path fails check with MRD-1004 (“is a secret and must be an environment interpolation, not a literal”). If you ever wrote a real secret into a file, tell the person: it must be rotated, because it is in the history. A non-secret value may carry a default, ${LISTEN_ADDR:-127.0.0.1:8080}; a secret cannot (check refuses ${SIGNING_KEY:-} with MRD-1004). The engine never reads a .env file: the variables must be in the process environment, so export them in the shell, the CI job or the deployment. Keep any .env you use with your own tooling out of version control (the starter’s .gitignore lists it).

Interpolation fills strings only

${VAR} works in string values. A boolean or number (require_mfa, port) cannot come from the environment; write it in the file.

Stable ids

Every configuration element (type, field, option, site, locale, route, endpoint, navigation slot, landing, tier, portal screen and the rest) carries an id: beside its handle, such as typ_bm5h7rk9j6 or fld_azkam2rfyt. The database stores everything by id, so a renamed handle keeps its data.

RuleWhyWhat breaks otherwise
Never invent an idcheck accepts any well-formed id, so a made-up one is not caughtTwo projects or two elements can collide, and data binds to the wrong element
Never copy an id from another element or projectAn id belongs to one elementcheck reports id fld_... is written on two elements; if it reaches a database, schema plan marks the element ! (replace)
Let a verb or check --fix write idsThey mint ids that cannot collide
Rename with dev field rename, dev site rename, dev tier rename and the other rename verbsThe id stays, the old field handle stays as an alias, an old site’s URLs redirectDeleting and re-adding loses every stored value under the old id
Never change an existing idThe id is the identityThe plan replaces the element; its values are left behind

When you copy a block of YAML to make a new element, delete the copy’s id: line and run engine-dmo check --fix. Field names id, type, uri, url, status, locale, tags, seo, noindex, author, organisation, createdAt, updatedAt, publishedAt, redirectFrom and translations are reserved by the engine and refused. See Stable ids.

Schema changes

Plan before apply

engine-dmo schema plan is read-only and exits 2 when changes are pending. Read every line before engine-dmo schema apply:

MarkMeaningWhat you do
+AddApply
~ChangeApply
>Rename: same id, new handleApply; values are kept
-RemoveCheck nothing still needs it
!Replace: an existing handle has a new idAlmost always a copied id or a hand-edited one. Put the stored id back. Never pass --replace unless the person asked for a new element
[DESTRUCTIVE: ...]Loses data or accessStop and ask the person

Destructive plans are a person’s decision

schema apply refuses a destructive plan (a removed field that documents hold values in, a grant whose removal takes a permission from active users) unless it is approved with an approval file or --allow-destructive. A verb with --apply exits 2 and writes the configuration but leaves such a plan unapplied. Do not approve it yourself: tell the person what the plan removes and let them run engine-dmo schema apply --allow-destructive. Removed values are kept under _orphaned in each revision, but anything that read them stops working.

The plan never runs in production by itself

dev up and --apply apply only non-destructive plans, and only outside production. In production the deploy runs migrate up then schema apply as explicit steps (Deploying).

Content

  • Write content with engine-dmo content create|update|publish, engine-dmo seed or the admin, never with SQL. Every write goes through the field validation, the revision history and the publish checks an editor’s save does.
  • seed apply is not an upsert. It refuses a database that already holds documents or media. In development, engine-dmo seed reset --confirm empties content, media and analytics first; it is refused in production. Never run seed reset against a database with content the person cares about without asking.
  • Seed files never contain database ids. Documents, assets and navigation name each other by seed key ({ id: banbury, type: area }, { asset: towpath }, page/home). A configuration element’s id (an endpoint’s end_... in a form field, a field’s fld_...) comes from config/ and is the same in every database, so a seed may name one; copy it from engine-dmo describe project. Database ids (UUIDs such as 01a19a3d-...) never go in a seed.
  • Write realistic content. Real-sounding titles, summaries within the field’s limits, alt text that describes the image. Never generate a photograph of a real place and present it as real: use the person’s own photographs or the placeholders seed generate makes, and say which is which.

Templates

Templates are Pongo2 (Django-style) and autoescaped. engine-dmo check lints every template against the project’s schema and the template contract; the renderer enforces the same rules again.

RuleEnforced byWhat happens
Print only variables the context hascheck, partly"x" is not in the template context; entry has no field "subtitle", with a suggestion. Fields are checked on entry, the type’s own key and the context’s named values only. A field on a loop variable ({% for w in items %}{{ w.photo }}), on a related document or on a component’s typed prop is not checked and prints nothing when misspelt: render the page and read it
An included partial reads only the page’s contextcheck{% include "x.html" with name=value %} fails with "name" is not in the template context. Make a template that takes values a component (engine-dmo new component) or a macro
No |safecheck and the compilerRefused unless templates.allow_unescaped: true in site.yaml. Values the engine produces as markup (|ptext, |markdown, head(), img()) print as they are; |raw accepts only those typed safe values
No {% autoescape %}checkRefused for the same reason
No |truncatechars_html or |truncatewords_htmlNobodyThey print their input as unescaped markup, even with allow_unescaped off, and check does not flag them. Use |truncatechars or |truncatewords
A variable inside <script> goes through |jsonchecka variable inside <script> must go through |json
A variable in href, src, action and other URL attributes goes through |url (or comes from a link helper)checka variable inside href must go through |url. |url refuses javascript: and data:
No variable in an inline event handler, a style attribute or a <style> elementcheckRefused: put values in data-* attributes or classes
Quote every attribute value that holds a variablecheckRefused
No inline executable scriptsThe site’s content security policyWhen server.security.csp is set, script-src may not include 'unsafe-inline', so an inline script never runs. Put scripts in files under public/ or the frontend build. JSON-LD (type="application/ld+json") is data and is fine
Every visible string through t('key')check warnsA missing key renders as the key itself; a locale without it falls back down its chain
Links from helpersConvention, and |urlUse path('/'), path('walk'), entry.uri|url, item.url|url from navigation. A document’s uri is its path on the current origin, the one to use for links inside the site; its url is the absolute address on the site’s configured domain, for a canonical, a share link or an email. Both pass |url. A hand-written path breaks under a locale prefix or a renamed route. The one exception is the claim form, /members/claim/{{ entry.organisation.id }}, which has no helper (Claims)
The layout prints head() and jsonld()check, partlycheck warns about a head with no head(), and about no jsonld() only when a type has a structured_data block. Neither warning fails it. Without jsonld() the site has no structured data and every check passes: grep a render for application/ld+json
Forms from declarationsConventionDraw forms with a form field’s render(), enquiryForm() or subscribeForm(), and print csrfInput() in any form you write yourself
Queries filter and sort on index: true fields onlyrendercheck does not catch this: the page answers 500 with is not declared index: true. Render every page that queries
A query names one typerenderquery('walk', 'page') and query(['walk', 'page']) return nothing and log a database error. Run one query per type
Only one priority=true image per pagecheckTwo compete for bandwidth
Print an image’s creditcheck warnsUse figure(), which prints it, or print .credit beside img() and picture()

See The template language, Links and URLs and Forms in templates.

Accessibility

Public sites must meet WCAG 2.2 AA. The engine checks what it can; you are responsible for the rest. Every page you write:

  • has lang and dir on <html> from locale.code and locale.direction;
  • has exactly one h1, and headings in order below it;
  • has a skip link to <main id="main"> as the first focusable element;
  • names every landmark <nav> with aria-label from t() when there is more than one;
  • gives every form control a visible <label for> (a placeholder is not a label), and every error a link from an error summary;
  • gives every informative image alt text (in the media library or alt=), and decorative images alt="";
  • uses no positive tabindex, no duplicate id, and no aria-labelledby or for pointing at an id that is not on the page;
  • marks the current navigation item with aria-current="page";
  • never conveys meaning by colour alone, and keeps text contrast at 4.5:1 (your CSS; the engine cannot check it).

engine-dmo check warns about several of these in templates, engine-dmo render --json lists findings in a11y, and engine-dmo a11y audit --all renders every page and fails on them. Passing the automated checks is evidence, not conformance: tell the person a manual check with a keyboard and a screen reader is still needed. See Accessibility.

Generated files

Do not edit these by hand. The engine rewrites them, and your changes are lost or contradict the configuration:

FileWritten byRegenerate with
AGENTS.mdinit, every dev verbengine-dmo guide --out AGENTS.md
.claude/skills/meridian-project/SKILL.mdinit, every dev verbengine-dmo guide --skill --out .claude/skills/meridian-project/SKILL.md
.claude/skills/meridian/SKILL.mdinit, every dev verbengine-dmo docs skill --out .claude/skills/meridian/SKILL.md
.meridian/init, config schema --write, dev upIt is local state; it is in .gitignore
id: valuesverbs, check --fix
storage/the media pipelineLocal media; never commit it
public/build/the frontend buildengine-dmo build

Put project-specific notes for future agents in your own file (for example NOTES.md), not in AGENTS.md.

Production

  • ENVIRONMENT=production engine-dmo check --no-db must pass before a deploy. It is the only guard for the mail rules: an engine started in production on the log driver does not refuse to start. It requires server.security.signing_key (32 characters or more, from ${SIGNING_KEY}), server.base_url, server.trusted_proxies when a real IP header is used, a real mail driver and mail.from, and refuses auth.staff.local.require_mfa: false.
  • The dev verbs, engine-dmo mcp and docs serve are refused in production, and seed reset too.
  • Never run serve --dev in production.
  • Never put production credentials in the project; the deployment’s environment holds them.

See Configuration and secrets.

Honesty

  • Use only what the engine has. If the brief asks for something the engine does not do, say so and propose what it can do. Do not imitate a missing feature with client-side script or hard-coded markup.
  • Report what you verified and what you could not (a page you could not render, a step that needs a person, such as approving a destructive plan or setting a secret).
  • Never claim a page is accessible because the automated checks pass.