# 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](/platform/scopes), which is distinct from `content:write`. ## The workflow 1. A human or agent creates or updates entries and uploads [assets](/concepts/assets). Every save records an immutable [revision](/concepts/revisions) 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](/concepts/previews) scoped to the change set renders every affected entry consistently. 4. `POST /v1/projects/:project/change-sets/:changeSet/publish` applies all items atomically. ```bash $ 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](/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](#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: ```bash $ 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`](/management-api/overview). 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](/management-api/errors). ## 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](/concepts/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](/concepts/collections) — 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](#rebasing) 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](/concepts/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](/concepts/releases). - Emits `entry.published`, `entry.unpublished`, `entry.deleted`, and `change_set.published` [webhook events](/webhooks/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](/concepts/releases). ## 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 ` 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. ```bash $ 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. ```bash $ 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. ```bash $ 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](#reading-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](/webhooks/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: ```bash $ 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`](/management-api/overview). 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](#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](#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: ```ts 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 “ — 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`](/management-api/overview). 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: ```bash $ 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: ```bash $ 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`.