Concepts

Change sets and publishing

How drafts are grouped into change sets, validated, previewed, and published atomically.

Every write in Content is a draft until a change set publishes it. A change set groups one or more staged operations — entry creates, updates, unpublishes, deletes, and asset changes — so they can be validated together, previewed with one URL, and applied to the live site in a single database transaction. Creating or updating content never publishes it implicitly: publishing is a separate operation gated by the content:publish scope, which is distinct from content:write.

The workflow

  1. A human or agent creates or updates entries and uploads assets. Every save records an immutable revision and stages an item on a change set.
  2. POST /v1/projects/:project/change-sets/:changeSet/validate reports schema, reference, and stale-base problems without side effects.
  3. A preview token scoped to the change set renders every affected entry consistently.
  4. POST /v1/projects/:project/change-sets/:changeSet/publish applies all items atomically.
$ myna changes create
$ myna entries update posts/launch --data @post.json
$ myna changes validate chs_01...
$ myna preview create chs_01...
$ myna changes publish chs_01... --confirm-publish
# The API equivalent requires an explicit body: {"confirm": true}

See CLI commands for the full myna changes surface.

Implicit and explicit change sets

You do not have to create a change set up front. When an entry write omits changeSetId, Myna resolves one automatically:

  1. If the entry already belongs to an open or ready change set, the edit joins it.
  2. Otherwise Myna creates an implicit change set titled Working changes.

An explicit change set (POST /v1/projects/:project/change-sets with a title and optional description) works the same way — pass its id as changeSetId on entry writes. Implicit change sets are ordinary change sets: they appear in listings, accept more items, and publish the same way.

An entry can belong to several open change sets at once. Two agents proposing different edits to the same page is the case worth supporting, and refusing the second one is what used to make parallel agents serialize on a collection. Whichever change set publishes first wins; the others are left describing a change to a revision that is no longer current, which the conflicts check reports and rebasing settles.

Read an entry before writing to it and you will see the collision first. A single-entry read carries openChangeSets — every open change set already staging this entry, with the fields each one touches — and contendedFields, the paths more than one of them changes:

$ myna entries get posts/pricing
  open change set chs_01H… — Refresh Q3 pricing (Ana Duarte): title, body
  open change set chs_01J… — Fix the annual discount (Deploy agent): body
  contended by more than one: body

The fields come from the same diffEntryData the conflicts check and the review diff use, so the three cannot disagree about what a change set touches. An unpublish or delete reports no fields and its operation instead: it contends with the entry as a whole rather than with any part of it.

This is the cheap half of the same information. conflicts reports the overlap too, but only once both writers have done the work — at which point the options are a rebase or a discard. Read first and the options are: pick a different field, join the change set already in flight, or say so and stop.

Entry lists do not carry it. It costs an indexed lookup per entry, which is affordable once and not twenty-five times a page. Detect support with the entries.contention capability from GET /v1/meta.

Within a change set, an edit builds on whatever that change set already proposes for the entry. A change set that has not touched the entry yet starts from what is published — never from another change set's unpublished work.

What an unqualified write cannot do is guess. With several change sets in flight and no changeSetId given, the edit is refused with CONFLICT and the candidate ids in the error details, rather than silently joining one of them. Pass changeSetId to say which edit it is. See error codes.

Items and operations

Each change set holds at most one item per resource ((changeSetId, resourceType, resourceId) is unique); staging a new operation for the same resource overwrites the previous item. Items record the intent, the base the edit was built on, and the revision to publish:

Field Meaning
resourceType entry or asset
operation create, update, unpublish, or delete
baseRevisionId The entry's published revision at staging time (null for unpublished entries)
targetRevisionId The draft revision to publish (null for unpublish and delete)

Staged unpublish and delete

POST /v1/projects/:project/entries/:entry/unpublish and DELETE /v1/projects/:project/entries/:entry do not take effect immediately. Each stages an item on a change set (explicit via the changeSetId query parameter, otherwise implicit) and returns { "changeSetId": "...", "staged": "unpublish" | "delete" }. Nothing changes on the live site until that change set publishes:

  • unpublish clears the entry's published pointer but keeps the entry and its last-published revision, so it can be republished later.
  • delete unpublishes and soft-deletes the entry. A deleted entry can be brought back with the restore endpoints described in revisions.

Statuses

Status Meaning
open Accepting items and edits. Default on creation.
ready Marked ready for review via PATCH with {"status": "ready"}. Still accepts edits; a reviewable checkpoint, not a lock.
published Applied atomically. Terminal — a published change set cannot be edited, closed, or republished.
closed Abandoned via the close endpoint. Terminal for editing; a published change set cannot be closed.

GET /v1/projects/:project/change-sets?status=open filters listings by status.

What publish validates

Validation runs on the validate endpoint and again inside the publish transaction. All checks apply to every entry item with a create or update operation:

  1. Schema validation. The target revision's data is validated against the collection's current schema version — not the version the revision was written under. Field-level errors carry paths and messages.
  2. Stale-base detection. If the entry's published revision no longer matches the item's baseRevisionId, someone published the entry after this change was staged. The error carries code STALE_REVISION; rebase the change set. This is reported by the conflicts check rather than schema: a change set that merely lost a publish race is not one holding invalid content.
  3. Reference verification. Every entry reference (ent_ id) in the target data must resolve to a published, non-deleted entry — with two change-set-aware rules:
    • References to unpublished entries are rejected (reference_unpublished) unless the referenced entry is created or updated in the same change set, in which case both publish together.
    • References to entries being unpublished or deleted in the same change set are rejected (reference_removed).

validate returns { "valid": false, "errors": [...] } with resourceType, resourceId, path, message, and code per error. publish fails the whole operation with PUBLISH_VALIDATION_FAILED (HTTP 422) carrying the same error list — one failing item prevents the entire publish.

What publish does

Publishing runs in one transaction that row-locks the change set and every affected entry, re-validates, then:

  • For create/update items: points publishedRevisionId, lastPublishedRevisionId, and draftRevisionId at the target revision, sets publishedAt, and clears any prior unpublishedAt.
  • For unpublish items: clears publishedRevisionId and sets unpublishedAt.
  • For delete items: clears publishedRevisionId and sets deletedAt.
  • For asset items: transitions the asset to published (or deleted for delete operations).
  • Auto-publishes any pending or draft assets referenced (ast_ ids) by the published revisions.
  • Assigns the change set the next sequential releaseNumber for the project, which is what turns it into a release.
  • Emits entry.published, entry.unpublished, entry.deleted, and change_set.published webhook events and records an audit event, all within the same transaction.

The response is { "changeSet": {...}, "publishedEntryIds": [...] }. Publishing an already-published change set or a closed one fails with CONFLICT.

Publishing is reversible: POST /v1/projects/:project/releases/:release/revert drafts a new change set restoring the state that preceded a release. See releases and reverting.

Reference integrity

Before unpublishing or deleting an entry, check its consequences with GET /v1/projects/:project/entries/:entry/references:

  • referencedBy lists live entries whose draft or published revision references this entry (with inDraft/inPublished flags) — publishing a change set that removes a referenced entry fails validation with reference_removed, so these are the entries you would break.
  • references lists the entries this entry's draft points at, each with exists and published state, so unpublished targets are visible before publish-time validation rejects them.

The same information is available as myna entries references <ref> in the CLI, client.entries.references(...) in the SDK, the myna_get_entry_references MCP tool, and a References panel in the dashboard entry editor. Delete behavior is effectively restrict: publishing a change set never silently breaks published references.

Scheduled publishing

A change set can be scheduled to publish at a future time instead of immediately:

  • POST /v1/projects/:project/change-sets/:changeSet/schedule with {"publishAt": "2026-08-01T09:00:00Z"} schedules the whole change set (requires content:publish). Scheduling again replaces the previous schedule; the change set's scheduledAt field exposes the pending time everywhere (API, SDK, CLI, MCP, dashboard).
  • DELETE .../schedule cancels.

When the schedule fires, the publish runs through exactly the same pipeline as a manual publish — validation, approval gates, and check gates included. If any of those fail, the schedule is cleared (no retries) and a change_set.schedule_failed webhook event carries the error code and detail. Successful runs emit the usual change_set.published event; scheduling and cancelling emit change_set.scheduled and change_set.schedule_cancelled. The publish is attributed to the actor who scheduled it.

$ myna changes schedule chs_01... --at 2026-08-01T09:00:00Z
$ myna changes schedule chs_01... --cancel

Scheduling takes content down as readily as it puts content up: a change set whose items are unpublish operations, scheduled for a future time, is how a promotion ends or a time-bound page retires. There is no separate expiry mechanism because there does not need to be one — the same review, the same preview, and the same atomic publish apply.

$ myna entries unpublish promos/summer-sale --change-set chs_01...
$ myna changes schedule chs_01... --at 2026-09-01T00:00:00Z

Rebasing

Because several change sets can hold the same entry, one of them loses the race to publish. Its items then record a baseRevisionId that is no longer the entry's published revision — a stale base — and publish refuses with STALE_REVISION.

Publish refuses rather than merging on the spot, deliberately: a merge changes the content a reviewer approved. So the merge is a separate, explicit act.

$ myna changes rebase chs_01... --dry-run
posts/pricing — 2 field(s) replayed, 1 conflict(s)
FIELD  MINE                THEIRS
title  Pricing and plans   Plans and pricing
Dry run — nothing written.

POST /v1/projects/:project/change-sets/:changeSet/rebase replays this change set's field-level edits on top of what is published now:

  • Fields the other side did not touch are replayed automatically.
  • Fields where both sides landed on the same value are agreement, not conflict — two agents fixing one typo the same way have nothing to resolve.
  • Fields both sides changed differently are conflicts.

Field granularity comes from the same diffEntryData the diff endpoint and the dashboard use, so a merge can never disagree with the diff a reviewer is reading. Nested objects descend, so seo.title and seo.description are independent. Arrays are compared whole: two writers who both reorder a list have conflicted, because there is no way to know what interleaving either of them meant.

By default a conflict refuses the whole rebase (CONFLICT, with every conflicting field listed) rather than half-rebasing and dropping the interesting part. To settle them:

onConflict Effect on conflicted fields
fail (default) Refuse and report every conflict
take-mine Keep this change set's value
take-theirs Accept what was published and drop this change set's edit

{"dryRun": true} reports the same plan without writing. A real rebase writes a new target revision per entry and moves each item's base — which moves the items, so existing approvals go stale and a fresh check run is queued. A rebase therefore needs re-review, on exactly the machinery a manual re-edit already used.

unpublish and delete items carry no data to merge. A base that moved underneath one of those is a plain stale-base error for publish to refuse: taking an entry down does not become ambiguous just because its content changed.

Reviews and approvals

Change sets support a pull-request-style review workflow. Reviews are attributed to any actor — a user, an API key, or an agent — and require the content:review scope to decide.

  • GET /v1/projects/:project/change-sets/:changeSet/reviews lists reviews.
  • POST .../reviews with {"reviewerType": "user", "reviewerId": "usr_..."} assigns a reviewer (their review starts pending); DELETE .../reviews/:reviewer unassigns.
  • POST .../approve and POST .../request-changes record the caller's decision, with an optional {"comment": "..."}. Deciding again replaces the caller's previous decision.

Each review carries a stale flag: an approval is stale when any change-set item changed after the decision was made, so approving stale content requires a fresh look. A changes_requested review blocks publishing (APPROVAL_REQUIRED, HTTP 409) until the reviewer approves or is removed.

Projects can require approvals before publishing: set requiredApprovals (via PATCH /v1/projects/:project or Project settings → General). Publishing then fails with APPROVAL_REQUIRED until that many non-stale approvals exist. Decisions emit change_set.approved / change_set.changes_requested webhook events and audit events.

Reading the diff

GET /v1/projects/:project/change-sets/:changeSet/diff answers what the change set would actually change, per item and per field:

$ myna changes diff chs_01...
update posts/pricing — Clarify the annual discount
FIELD            KIND     BEFORE                    AFTER
title            changed  Pricing                   Pricing and plans
body             changed  We offer three plans…     We offer three plans…
seo.description  added    —                         Compare Myna's plans.

Each item carries its operation, collection, slug, base and target revision ids, the author's changeSummary, and a flat list of { path, before, after, kind } changes. Nested objects descend to the leaf that actually changed, so a one-word edit inside an seo group reads as one changed field rather than a rewritten object; arrays are compared whole, because reordering a list is a change to the list.

An unpublish or delete item has no "after": it removes the entry from what readers see, so every field reads as removed.

Word-level segments

A two-thousand-word body reported whole says only "changed" — as much as you already knew. Prose fields therefore carry an extra segments array: an ordered list of { op, value } runs where op is equal, insert, or delete, so the words that actually moved are the words you read. Concatenating the equal and delete runs reproduces before; the equal and insert runs reproduce after.

Segments appear only where they help: both sides must be readable prose — a markdown or text value, or a richText document, from which the text of the nodes is taken — and the longer side must exceed 160 characters. A title, a number, or a reference id is short enough to read whole and carries no segments. For rich text the segments cover the prose only, not marks or node attributes, so before and after remain the authoritative values.

Detect support with the change-sets.diff.words capability from GET /v1/meta.

This is the same comparison the dashboard renders, so a reviewer reading the UI and an agent reading the endpoint cannot disagree about what changed. Segments are computed from that comparison rather than beside it: they annotate leaves, never add or move them, so rebasing — which merges the same field paths — cannot fall out of step with the diff under review.

Comments

GET/POST /v1/projects/:project/change-sets/:changeSet/comments hold the change set's discussion thread. A comment may be anchored to a specific field by passing resourceType, resourceId, fieldPath (for example fields.title), and optionally the revisionId it was written against. PATCH .../comments/:comment with {"resolved": true|false} resolves or reopens a comment. Comments are attributed to the acting principal and appear in the dashboard's Discussion panel alongside review decisions.

Automated checks

Checks are CI for content: each run produces named, attributed, reproducible results against a fingerprint of the change set's current items.

Checks run automatically. Staging or editing an item queues a run a few seconds later, so a burst of edits produces one run rather than one per save. You do not have to trigger anything, and results are normally waiting by the time you open the change set.

  • GET /v1/projects/:project/change-sets/:changeSet/checks returns the state of every check, including ones that have not produced a verdict yet — those come back with status: "pending" and a null id, runId, and createdAt. A check you cannot see is a gate you cannot satisfy, so the list is complete from the moment the change set exists.
  • Every check carries stale: true when the change set's items changed since the run. A stale result means a fresh run is already queued.
  • POST .../checks/run forces a run immediately, for when you do not want to wait out the debounce.

Built-in checks:

Check What it verifies
schema Target revisions validate against current collection schemas
references No broken, unpublished, or removed entry references
conflicts Whether another release moved an entry under this change set, and whether rebasing is mechanical or a decision
slugs No duplicate slugs within the change set
policy The rules each collection declares for itself — see below. skipped when no collection in the change set declares any

The first four are preconditions for a coherent publish, not editorial taste: content that does not validate, references that do not resolve, a base that has moved, two entries claiming one slug. Refusing those is the same kind of refusal as git rejecting a non-fast-forward push.

Everything editorial is declared. There is no built-in SEO rule and no built-in alt-text rule, because "every project wants this" is a claim about your product that Myna is not in a position to make.

Collection policy

A collection declares what it refuses to publish, in the same file as its fields:

import { collection, field, policy } from "@myna-sh/sdk/schema";

export const releases = collection({
  name: "releases",
  guidance: "Write the change, not the story of the change.",
  policy: [
    policy.length("title", { max: 53, reason: "Rendered as “<title> — Myna changelog”." }),
    policy.length("body", { min: 300, max: 1200, words: true }),
    policy.required(["seoDescription"], { reason: "Search snippets are written, not generated." }),
    policy.bannedTerms(["Contentful", "Sanity"], { fields: ["body"] }),
    policy.altText(),
  ],
  fields: { /* … */ },
});
Rule Refuses
policy.length(field, { min, max, words, reason }) A field outside the bounds. Counts characters, or words with words: true. Prose is measured as the text a reader sees, so a richText body is its words, not its JSON.
policy.required(fields, { reason }) Publishing while a field is empty. Distinct from required on the field itself, which refuses the draft — a draft is allowed to be incomplete.
policy.bannedTerms(terms, { fields, reason }) A term anywhere in the entry, or in named fields only.
policy.altText({ reason }) A referenced image asset with no alt text.

Violations appear as details on the policy check, each carrying its reason. A rule naming a field the collection does not declare is rejected at myna schema push, so a typo cannot leave a rule that reports passed forever.

guidance is advice and policy is enforcement, and both travel with the schema — versioned, reviewed, and deployed with the fields. myna_get_collection_schema returns them together, so an agent reads the rules before it writes rather than discovering them after.

Detect support with the change-sets.checks.policy capability from GET /v1/meta.

Set requireChecksPass on the project to gate publishing: publish then fails with CHECKS_FAILED (HTTP 409) unless the latest run matches the current item fingerprint and has no failing checks. Runs emit the change_set.checks_completed webhook event.

Automatic runs are attributed to system; a run you trigger yourself is attributed to you.

External checks

Myna can only run a check it knows how to compute. A link checker against a staging deploy, a house-style review, someone else's build — those verdicts come from outside, and a project declares where they belong:

In the dashboard, the Checks tab lists the built-in suite, the publish gates (required approvals and "require passing checks"), and lets you declare, edit, and remove custom checks. The same thing from the CLI:

$ myna checks declare links --description "No broken links in the staged copy" --required
$ myna checks list

A declared check is a slot, not a schedule: Myna never runs it. Whatever does — CI, a webhook consumer, an agent — reports the result:

$ myna changes report-check chs_01... links --status failed --message "3 dead links in /pricing"

or POST /v1/projects/:project/change-sets/:changeSet/checks/:name with {"status": "passed" | "failed" | "skipped", "details": [...]}, requiring content:review. Results are recorded against the change set's current item fingerprint exactly as built-in runs are, so editing the change set afterwards marks the verdict stale rather than letting an answer about older content wave newer content through.

Checks declared required block publishing on their own terms, whether or not requireChecksPass is set: publish fails with CHECKS_FAILED until every required check has a fresh passed result. That is the point of the flag — marking a check required is the decision.

Reading GET .../checks returns built-in and external results in one list, each tagged with source: "builtin" | "external". A declared check appears as pending until its reporter posts, so you can see what a change set is waiting on. A reporter may not claim a built-in name, and a result for an undeclared check is refused with NOT_FOUND — otherwise anyone with review access could invent a green tick nobody asked for.

Note that pending is a view state only: it means "no verdict yet", so it is not something a reporter can post. POST .../checks/:name accepts passed, failed, or skipped.