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
devverb 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, andcheckfails until you do. Runengine-dmo checkafter 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 nextschema 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.
| Rule | Why | What breaks otherwise |
|---|---|---|
| Never invent an id | check accepts any well-formed id, so a made-up one is not caught | Two projects or two elements can collide, and data binds to the wrong element |
| Never copy an id from another element or project | An id belongs to one element | check 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 ids | They mint ids that cannot collide | |
Rename with dev field rename, dev site rename, dev tier rename and the other rename verbs | The id stays, the old field handle stays as an alias, an old site’s URLs redirect | Deleting and re-adding loses every stored value under the old id |
| Never change an existing id | The id is the identity | The 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:
| Mark | Meaning | What you do |
|---|---|---|
+ | Add | Apply |
~ | Change | Apply |
> | Rename: same id, new handle | Apply; values are kept |
- | Remove | Check nothing still needs it |
! | Replace: an existing handle has a new id | Almost 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 access | Stop 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 seedor 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 applyis not an upsert. It refuses a database that already holds documents or media. In development,engine-dmo seed reset --confirmempties content, media and analytics first; it is refused in production. Never runseed resetagainst 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’send_...in aformfield, a field’sfld_...) comes fromconfig/and is the same in every database, so a seed may name one; copy it fromengine-dmo describe project. Database ids (UUIDs such as01a19a3d-...) 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 generatemakes, 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.
| Rule | Enforced by | What happens |
|---|---|---|
| Print only variables the context has | check, 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 context | check | {% 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 |safe | check and the compiler | Refused 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 %} | check | Refused for the same reason |
No |truncatechars_html or |truncatewords_html | Nobody | They 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 |json | check | a 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) | check | a 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> element | check | Refused: put values in data-* attributes or classes |
| Quote every attribute value that holds a variable | check | Refused |
| No inline executable scripts | The site’s content security policy | When 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 warns | A missing key renders as the key itself; a locale without it falls back down its chain |
| Links from helpers | Convention, and |url | Use 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, partly | check 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 declarations | Convention | Draw 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 only | render | check does not catch this: the page answers 500 with is not declared index: true. Render every page that queries |
| A query names one type | render | query('walk', 'page') and query(['walk', 'page']) return nothing and log a database error. Run one query per type |
Only one priority=true image per page | check | Two compete for bandwidth |
| Print an image’s credit | check warns | Use 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
langanddiron<html>fromlocale.codeandlocale.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>witharia-labelfromt()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 imagesalt=""; - uses no positive
tabindex, no duplicateid, and noaria-labelledbyorforpointing 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:
| File | Written by | Regenerate with |
|---|---|---|
AGENTS.md | init, every dev verb | engine-dmo guide --out AGENTS.md |
.claude/skills/meridian-project/SKILL.md | init, every dev verb | engine-dmo guide --skill --out .claude/skills/meridian-project/SKILL.md |
.claude/skills/meridian/SKILL.md | init, every dev verb | engine-dmo docs skill --out .claude/skills/meridian/SKILL.md |
.meridian/ | init, config schema --write, dev up | It is local state; it is in .gitignore |
id: values | verbs, check --fix | |
storage/ | the media pipeline | Local media; never commit it |
public/build/ | the frontend build | engine-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-dbmust pass before a deploy. It is the only guard for the mail rules: an engine started in production on thelogdriver does not refuse to start. It requiresserver.security.signing_key(32 characters or more, from${SIGNING_KEY}),server.base_url,server.trusted_proxieswhen a real IP header is used, a real mail driver andmail.from, and refusesauth.staff.local.require_mfa: false.- The
devverbs,engine-dmo mcpanddocs serveare refused in production, andseed resettoo. - Never run
serve --devin 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.
For agents
Start here if you are a coding agent building or changing a Meridian website. What Meridian is, the golden rules, how to read the rest of the handbook from the binary, and the change, check, render loop.
Workflow: from a brief to a running site
The order of work for a coding agent turning a written brief into a working, accessible, deployable Meridian site, step by step, with the exact command for each step and how to verify it before moving on.