Meridian handbook
Concepts

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

IdentityA UUID that never changes, whatever happens to the title, slug or type’s configuration
TypeOne content type: a core type, a module’s type or one of yours
Owner siteThe site the document belongs to. It can be shared to other sites, and has an address on each site whose routes route its type
LocalesEach 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 localeIt 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 blocksThe 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
TranslatesThe 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:

StateMeans
draftBeing written; not public (or public at an earlier published revision, if it was published before)
in_reviewAn author has asked for review. Reviewers are notified and it appears in the site’s approvals queue
scheduledWill publish at a set moment. A live locale keeps serving its published revision until then
publishedLive
archivedTaken 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.

TransitionWhoWhat happens
Savecontent.editA new revision in the chains that changed
Request review (draft to in_review)content.edit without content.publishNotifies everyone who may publish it; the author can keep editing
Return (in_review to draft)content.publishNeeds a note, which the author is shown
Publishcontent.publishRuns the publish checks, then publishes (below)
Schedulecontent.publishRuns the publish checks now, and again at the moment
Unpublishcontent.publishThe locale is taken down and returns to draft, its address released, and the search index updated
Archive / unarchivecontent.publishArchive unpublishes, drops a schedule and returns a pending review; unarchive returns it to draft
Trash / restorecontent.delete / content.restoreSee 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_when lose their value.
  • required, required_in and required_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 unique values.
  • 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 scheduled and 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_at must 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.

CommandDoes
engine-dmo content trash --id <id> --redirect parentSoft 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> --yesHard 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 --trashedList 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
KeyDefaultLimits
prunetruefalse leaves the task unregistered and makes content prune-revisions refuse
keep_all90dA retention period such as 90d, 13m or 7y; at least 1d
keep_last20At least 1; counted per chain (each locale and the shared chain separately)
keep_monthlytrue

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

CommandDoes
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 draftOne 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 importJSON 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.