Documents, revisions and publishing
How Meridian stores content as documents with a shared revision chain and one chain per locale, the per-locale workflow states, what publishing checks and does, scheduling, the trash, and revision retention.
Every piece of content is a document of one content type. A document has a draft and a published state per locale, a full revision history, and an owner site. Saving never overwrites: it adds a revision. Publishing never copies: it moves a pointer to a revision.
Documents
| Identity | A UUID that never changes, whatever happens to the title, slug or type’s configuration |
| Type | One content type: a core type, a module’s type or one of yours |
| Owner site | The site the document belongs to. It can be shared to other sites, and has an address on each site whose routes route its type |
| Locales | Each locale the document has is published, drafted, scheduled or archived on its own: Welsh can be in review while English is live |
Templates, the public pages and the content API’s public reads see published revisions only. Editors work on the working revisions.
Two kinds of revision chain
A document’s values are split by how they translate, and each part has its own chain of revisions:
- The shared chain holds what is the same in every locale: fields with
translate: none(a number, a date, a map pin, a select’s value) and the structure of repeater and blocks fields (which items exist, their order, their types and their non-text settings). - One chain per locale holds that locale’s translated values: text, rich text, slugs, and the text inside each repeater item or block, keyed to the item it belongs to.
Reading a document in a locale walks the shared structure and fills each item from the locale’s values, falling back along the locale’s chain where a value is missing.
What this means for editors:
| An editor… | Happens |
|---|---|
| Adds a block in the source locale | It exists in every locale’s draft at once, untranslated. On the public site an untranslated item is left out (or shows the source text, where the field says fallback: inherit) |
| Reorders or removes blocks | The change applies to every locale; a removed block’s translations are kept in history |
| Changes a non-text setting (a layout, a list’s limit) | It changes everywhere, because it is structure |
| Translates | The translator edits only the text; they cannot add, remove or reorder blocks |
Rich text (portable_text) is the exception: each locale holds its own whole rich text value, since a translator must be free to merge and split paragraphs.
When one document genuinely needs a different structure in each locale, engine-dmo content structure --id <id> --field <field> --mode per_locale --reason "..." makes that field per-locale for that document only, and records the reason in the audit log. structure: per_locale on a field does the same for every document of the type. Localisation covers the field settings.
Working and published revisions
Each chain has a working revision (the draft an editor sees) and a published revision (what the public sees). Saving writes a new revision to whichever chains changed and moves their working pointer. Publishing a locale moves that locale’s published pointer to its working revision.
When the shared chain has unpublished changes, the admin shows a shared draft banner naming the locales the change reaches. Publishing a locale offers to publish the shared draft with it; a locale can be published without it, in which case new items in the shared draft are not rendered until the structure is published. Publishing the shared chain needs content.publish on the source locale.
engine-dmo content publish --id <document id> --locale cy # publishes cy and the shared draft
engine-dmo content publish --id <document id> --locale cy --no-shared # publishes cy only
engine-dmo content publish --id <document id> --all-locales # the source first, then every locale whose checks pass Workflow states
A locale of a document is always in one of five states:
| State | Means |
|---|---|
draft | Being written; not public (or public at an earlier published revision, if it was published before) |
in_review | An author has asked for review. Reviewers are notified and it appears in the site’s approvals queue |
scheduled | Will publish at a set moment. A live locale keeps serving its published revision until then |
published | Live |
archived | Taken out of circulation, unpublished and hidden from lists, with its history kept |
Each locale of a document moves between them on its own:
stateDiagram-v2 [*] --> draft: create draft --> in_review: request review in_review --> draft: return with a note draft --> published: publish in_review --> published: publish draft --> scheduled: schedule in_review --> scheduled: schedule published --> scheduled: schedule a new revision scheduled --> published: the moment passes, checks pass scheduled --> draft: cancel, not live scheduled --> published: cancel, live published --> draft: unpublish draft --> archived: archive in_review --> archived: archive scheduled --> archived: archive published --> archived: archive archived --> draft: unarchive
Saving a published locale does not change its state: the new working revision waits as a draft beside the live published revision until you publish again. A scheduled locale whose checks fail at the moment stays scheduled, marked as failed.
| Transition | Who | What happens |
|---|---|---|
| Save | content.edit | A new revision in the chains that changed |
Request review (draft to in_review) | content.edit without content.publish | Notifies everyone who may publish it; the author can keep editing |
Return (in_review to draft) | content.publish | Needs a note, which the author is shown |
| Publish | content.publish | Runs the publish checks, then publishes (below) |
| Schedule | content.publish | Runs the publish checks now, and again at the moment |
| Unpublish | content.publish | The locale is taken down and returns to draft, its address released, and the search index updated |
| Archive / unarchive | content.publish | Archive unpublishes, drops a schedule and returns a pending review; unarchive returns it to draft |
| Trash / restore | content.delete / content.restore | See The trash |
Permissions can be scoped by state, so an author’s grant can cover draft and in_review only. See Users, roles and permissions. There are no custom workflow states or multi-step approval chains.
What publishing does
Publishing a locale is one transaction, followed by background jobs.
The publish checks
Every path that publishes runs the same checks: the admin, the scheduler, engine-dmo content publish, bulk publish, restoring from the trash, the content MCP and member submissions approved automatically. Errors refuse the publish; warnings are reported with it.
- Fields hidden by
visible_whenlose their value. required,required_inandrequired_when.- Each field type’s own validation, and the type’s validation rules, including those marked
on: publish. - Alt text, and the rights on every image used: an image whose rights have expired is an error; one whose rights end while the page is live is a warning naming the date.
- Tags that are not in the vocabulary, a parent that would make a cycle, and
uniquevalues. - Relations to documents that are not live in the locale: a warning naming the fallback locale that stands in, or that the target is left out.
The pointers move
The locale’s published pointer moves to its working revision (and the shared chain’s, when published with it), a publish event is recorded for each chain, the audit log is written, and the state becomes published.
Addresses resolve
The document’s address is resolved on every site it is on. When a live address changes, the old address redirects (301) to the document, unless a rule already answers it. Declining that redirect needs a reason: engine-dmo content publish --no-redirect "reason", which is audited. Documents nested under a moved one get their own redirects.
Jobs follow
The published projection used for listing, filtering and sorting is rewritten, the search index is updated per site and locale, caches are invalidated, and translations of changed fields are marked stale. A failed job never unpublishes; it retries.
The result also names any statutory locale on the document’s sites that is not yet live; that is a prompt, never a block.
Scheduling
A locale can be scheduled to publish at a moment, and a live locale can have an unpublish_at moment. Both are set in the admin beside the publish controls, through the content MCP’s publish tool with publish_at, or in seed data with schedule: and unpublish_at:.
- The publish checks run when you schedule, and again at the moment.
- A scheduler task runs every minute and publishes what is due as the system, naming the person who scheduled it in the audit log.
- If the checks fail at the moment (an image’s rights expired, a related page was unpublished), the locale stays
scheduledand is not retried, the person who scheduled it is notified, and the approvals queue shows it. Nothing half-publishes. - Cancelling a schedule returns a locale that was not live to
draft; a live locale stays published. unpublish_atmust be in the future. When it passes, the locale is unpublished exactly as the admin would.
There is no command-line route to these: engine-dmo content has publish but no schedule, unpublish, archive or review command. From a terminal, schedule through the content MCP’s publish tool with publish_at (The content MCP), or set schedule: on a seed document before seed apply. To take a document down from the command line, content trash --redirect does it, with a redirect.
The scheduler runs on one process at a time, whichever process holds the leader role, so a schedule fires once however many replicas you run.
The trash
Deleting a document moves it to the trash; nothing is hard deleted straight away.
| Command | Does |
|---|---|
engine-dmo content trash --id <id> --redirect parent | Soft delete. When any locale is live, --redirect is required: parent, a path, or none |
engine-dmo content restore --id <id> | Reinstate it: its addresses are re-resolved and what was live is republished through the publish checks. --slug-on-conflict suffix (the default) or fail |
engine-dmo content hold --id <id> | Keep a trashed document until someone decides; --until <date> to purge on a date, --release to restart the retention window |
engine-dmo content purge --id <id> --yes | Hard delete, irreversible. --expired runs the nightly sweep now |
engine-dmo content legal-hold --id <id> --reason "..." | Keep every revision and refuse purging until released (--release, --until) |
engine-dmo content list --trashed | List the trash |
A trashed document is purged content.trash_retention after it was trashed (30d by default), unless it is held.
Revision retention
Revisions are pruned by a daily task, and only when nothing keeps them. A revision is always kept if it was ever live, is pointed to, is the head of its chain, or is the shared pair of a kept locale revision. Beyond that, content.revisions in engine.yaml decides:
content:
trash_retention: 30d
revisions:
prune: true # false: never prune
keep_all: 90d # every revision younger than this
keep_last: 20 # and the newest 20 of each chain
keep_monthly: true # and the newest of each calendar month of each chain, forever | Key | Default | Limits |
|---|---|---|
prune | true | false leaves the task unregistered and makes content prune-revisions refuse |
keep_all | 90d | A retention period such as 90d, 13m or 7y; at least 1d |
keep_last | 20 | At least 1; counted per chain (each locale and the shared chain separately) |
keep_monthly | true |
A type can override any key with its own revisions: block in its schema file, such as revisions: { prune: false } for a type covered by a records schedule. engine-dmo content prune-revisions --dry-run prints what the next run would delete.
Working with documents from the command line
| Command | Does |
|---|---|
content create --type <key> --data '{...}' | Create a document from field values, validated as an admin save (--locale, --site, --publish) |
content update <id> --data '{...}' | Merge values over a locale’s working revision; only the fields given change, and null clears one (--locale, --publish) |
content get <id> | The working and published values by field handle, and the status per locale |
content list --type <key> --status draft | One line per document (--site, --locale, --status, --trashed) |
content clone --id <id> | A copy as a new draft, every locale in draft (--site, --title, --no-tags) |
content export, content import | JSON or CSV of a type’s working values; import creates drafts, never publishes, and --dry-run writes nothing |
Content is only ever written through these commands, the admin, the content MCP, the APIs or a seed; never with SQL. CLI reference lists every flag.
Sites, locales and routes
How one Meridian install serves several sites in several locales, how locales are declared once and enabled per site, how routes give types URLs, and how a request finds its site, locale and document.
Modules and bundles
What the core platform provides, what each Meridian module adds and requires, which modules the engine-cms and engine-dmo bundles compile, and what happens when a project uses a module its binary lacks.