Skip to content

Add publishing transitions, versions and reference integrity - #2756

Merged
pournasserian merged 10 commits into
mainfrom
2695-publishing
Nov 3, 2026
Merged

pournasserian merged 10 commits into
mainfrom
2695-publishing

Conversation

@pournasserian

@pournasserian pournasserian commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

Closes #2695

Adds publish, unpublish, discard, version reads, version restore and diff to IContentService and IContentService<T>, on EntryStore, with reference integrity at publish and version pruning. Each transition is a write like SaveAsync: the permission, the entry and its row rule, then in one transaction the field limits, hooks, validation, the write and the outbox rows, with the sink and the response after the commit. No system migration: nothing in the system model changed.

Dependencies: none.

Acceptance

Under Q19, everything here is SQLite only until #2711.

  • Each transition in the spec section 4.5 table has tests for state, rows, version and event. SQLite only. PublishingTransitionTests covers save draft, publish, unpublish, discard and restore version, and PublishingEventTests covers their events. Trash and restore from trash came with Add the content service with optimistic concurrency #2747, and here they are checked against published rows. Purge is Trash with retention and purge job #2696. Schedule is Scheduled publish and unpublish per locale #2699.
  • Publishing an entry that references an unpublished entry warns by default and blocks when the type says so. SQLite only. ReferenceIntegrityTests.
  • Diff of two versions with changed blocks and rich text reports paths correctly. SQLite only. VersionDiffTests: sections[1].heading, body.content[1].content[0].text.
  • Pruning keeps every version that was published. SQLite only. Including an entry with more published versions than its cap.

Verification

On Windows: dotnet build -c Release -warnaserror, dotnet test -c Release (2362 tests, all passed) and dotnet pack -c Release (exit 0). markdownlint-cli2 on the spec found nothing. The review's probes were rerun against the fixes and are not in the suite; what they check is.

I also disabled 60 guards one at a time and ran the publishing tests, in three passes. 57 made a test fail. One survivor showed a duplicated check, which I removed. The other two each needed one more case, which I added: a locale guard that a later check masks unless a target has rows in both a covered and an uncovered locale, and trash counting as existing, which only matters to a principal that cannot read the entry. Both new tests were confirmed to catch their mutation. The first pass was not rerun after the duplicate check was removed. The event mapping was not mutated: without it, the transition throws when the outbox row is staged. The script is not in the repository.

Low-privilege behaviour

Tests are in PublishingAccessTests, ReferenceIntegrityTests and NestedTargetTests, from the low-privilege side.

  • Version reads and diffs filter by the reader's limits today, not at save time: no hidden value at any depth (for the system principal too), no field its grants leave out, no key or block the schema has lost, no relation target or rich text link it may not read. A change only in what it may not read is no difference.
  • Who reads versions. Draft read plus the Management scope. A draft or preview token is refused: a preview key shows the draft as it is, not everything it has been. An unknown type and an unreadable one give the engine's one error, so type names cannot be tried.
  • Discard and restore are refused for a non-system principal that can write a field it cannot read, whatever that field holds, with one answer. Their status and version would otherwise check a saved guess against the value. A writer with no read at all is refused for the same reason.
  • Version restore needs update, not restore, because it writes a draft. Non-system principals get only what they could send themselves: a field that is unwritable, unreadable, hidden or read-only keeps the value the draft holds, at any depth, and so do relation targets they cannot read. Targets they cannot read do not come back from the snapshot. The system principal restores everything. The row rule is checked again after the write, and a restore that leaves it rolls back.
  • Discard needs update. A principal that may write every field gets the published values back whole. One with a field or locale limit changes only what it may write, keeps hidden and read-only values at every depth, and the entry stays Modified.
  • Publish with a limited grant. A field-limited grant publishes only if the draft differs from the published state within those fields. A field it can write but not read is refused whatever it holds, so a saved guess cannot be checked against the published value. A locale-limited grant cannot move shared fields under another published locale. All of these refuse with ContentAccessDeniedException and name no field.
  • Unpublish with a field-limited grant is refused, because the whole entry goes. Its status update is a compare and swap.
  • Publish errors on an unreadable field become one generic error with no path, because a rule's message can quote the value. They always block, unlike the save path, which compares with the stored entry.
  • Audit and authorization. Audit columns come from AuditPrincipal.Id, authorization from Principal.

Reference integrity is not an oracle

A write takes any id without looking, so anything publish says about a target would let a principal ask about any entry. For a principal other than the system principal:

Target Warn Block
Published, and it may read it nothing publishes
Unpublished, and it may read it FCMS2403 with the id FCMS2403 on the field
Trashed, missing, other site or type; refused by the row rule, asked of the locale that serves the target; or with rows only in locales its grant does not cover FCMS2404, no id, all identical FCMS2404 on the field
Of a type it may not read, or of no type at all nothing always one refusal, no path
In a field it can write but not read, which is where it chooses the id nothing always one refusal, no path
In a field it can neither read nor write, or hidden: it did not choose the id nothing a refusal with no path, only if the target really is a problem

The system principal gets the id on FCMS2404. A target counts as published if it has a published row in the entry's locale or its fallback chain. A self-reference is exempt. A cycle stays blocked: the way out is to publish one side without the reference and set it afterwards, which #2703 should note.

The decision to review: under Block, a principal that cannot read the target's type can never publish that reference, even when the target is fine. A scheduled publish runs as the system principal and is not affected. The alternative, deciding on the target's real state, tells the principal whether any id it names exists.

Partial writes and events

AHookThatThrowsLeavesNothingBehindAndTellsNoOne runs each before and after hook that can fail on publish and unpublish, and AfterUpdate on discard and restore. It compares every row of the entry (document, versions, both states, references) and checks the sink heard nothing.

EntryEvents.From maps Published, Unpublished, DraftDiscarded and VersionRestored. All four add to the same pending list as SaveAsync inside InTransactionAsync, so they stage outbox rows in the transaction and dispatch after the commit. PublishingEventTests has one test per transition for each of: committed (handler told, row staged, same event id), rolled back (neither), and in a transaction the service did not begin (row goes with it, no in-process handler told).

A BeforeValidate or BeforePublish hook that saves the draft through the service, in any locale, is refused with FCMS2405 and rolls back. Otherwise the shared row it moved would go out unvalidated.

The decision on #2740's question

Spec 4.5 records it, and the query engine does it. On every read, a relation target the principal may not read by type or row rule, in another site, of another type than the field's, or with rows only in locales its grants do not cover, is null or left out of a list. A rule is asked only of the locale being read, so where that locale has no rule, only rule-free grants count as covering a locale. A principal's own entry that exists only in a rule-limited locale is therefore left out when read through another locale, though a direct read in that locale shows it; this fails closed. A published read also leaves out a target that is trashed or has no published row in any locale. A target published only in another locale stays { id, type }. A trashed target of a readable type stays a stub on a draft read, as existing draft behaviour.

These rules hold for top-level relations, relations inside blocks and components, rich text entry links, and relations in the data of an inline block. A link keeps its text and loses its target, and a link to an unknown type counts as one to an unreadable type. The extractor now reports inline blocks, and EntryStore resolves their fields from the schema, so cms_reference and integrity see those relations too. An inline block of an unregistered type is left alone. AnUnpublishedOrTrashedTargetStaysAReference from #2740 became AnUnpublishedOrTrashedTargetIsNotReturnedByAPublishedRead.

A consequence the review did not list: once a read drops nested targets, a low-privilege save of what it read would have wiped them. So EntryComposer.CarryProtected carries them, as it does hidden values, skipping any already in the list, and they do not count when blocks are paired. Top-level relations already behaved this way.

Keeping is not an existence test. This reverses #2747 for this case, on the orchestrator's decision. A non-system principal keeps every target its read left out, with no test of whether it exists, so a later warning says the same whatever the id is. The exception is a principal whose read grant on that type has no row rule and no locale limit, because a read tells it anyway: it loses a target that does not exist in that type and site, trashed counting as existing, which is how a ghost gets cleared. The system principal keeps nothing it did not send. This covers top-level relations, blocks and rich text.

Beyond the specification

Decided here, and worth a reviewer's eye:

  • IContentService rather than IPublishingService. Spec 4.4 and Add the content service with optimistic concurrency #2747 put these on the content service, so I removed IPublishingService from the spec's other two mentions.
  • expectedVersion is optional on publish, discard and restore, so a caller can publish only what it reviewed. A stale one is a VersionConflictException. Added to 4.4.
  • GetVersionAsync is new, because the spec's list returns no values. PublishResult<T>, PublishWarning, VersionChange and PublishPolicyAttribute are new SDK types; ITypeBuilder.PublishPolicy and SchemaType.UnpublishedReferences/MaxVersions are new members. FluentCMS.Testing's scoped services forward the new methods.
  • Schema JSON carries the policy only when it is not the default, so no existing model changes its key.
  • A version is marked published only by a publish, by setting status_after on its row, which avoids a migration. It is the one column of a version that changes. A discard or restore row records Modified even when the entry's status is Published, so it cannot pass the cap.
  • What a before hook changes on publish becomes a new draft version, then publishes. A publish makes no slug. A hook is held to the publish grant's field limits and the row rule. published_version names what a locale last published; its shared fields can have moved since through another locale's publish, so what it serves can differ from that snapshot. Spec 4.5 says so.
  • Hooks. Publish runs BeforeValidate, BeforePublish, AfterPublish. Unpublish runs its pair; what they change is ignored. Discard and restore are saves, so they run BeforeValidate, BeforeUpdate, AfterUpdate under draft rules, not the restore hooks, which belong to the trash.
  • Pruning happens when a version is written, in the same transaction, rather than in the job spec 4.12 lists.
  • Status is recomputed for other published locales whenever the shared row moves, in either direction. A restore that equals the published state gives Published.
  • Discarding a draft equal to the published state writes nothing and tells no one, but puts a Modified status right.
  • An empty unique value. The published unique index has room for one '', so a second entry with an empty slug is refused on publish with FCMS2111 instead of failing at the index.
  • FieldValue.IsEmpty treats a rich text document with no visible content as empty: RichText.Empty, no blocks, several empty paragraphs, whitespace only. An image or a rule is content. This also applies to Condition.IsEmpty.

Known gaps

  • History and the row rule. Version access is decided on the entry's current row, because history is part of the entry. A reader refused while the entry was outside its rule can read those versions once it moves inside. Recorded in spec 4.5, listed here so it can be reopened.
  • Translators and shared changes. While a shared field has an unpublished change and another locale is published, a locale-limited publisher is refused until someone with wider reach publishes or discards it.
  • Hidden shared fields still move when a locale-limited grant publishes, because no grant covers them.
  • A shared move tells one locale. Publishing one locale changes what other published locales serve, but only that locale's event is raised. Eviction by entry:{id} covers it.
  • An edited rich text document loses links the principal could not see. Nodes have no ids to pair an edit by, so links stay only when the document comes back as it was read. Blocks keep their targets when edited, pairing as Add the content service with optimistic concurrency #2747 describes.
  • A limited principal cannot clear a ghost id. Under Block the entry stays refused until a principal with an open read of that type clears it.
  • The data of an inline block has no field rules. It is not validated against its block on write, and a field marked hidden there is not removed on read. Only relations in it are filtered, hidden or not. This predates this PR and needs its own issue.
  • A filter on a relation still matches a target left out, including notNull. Spec 4.5 records this.
  • A target since trashed drops out of a version read and a restore for non-system principals, which fails closed. A draft read would still show it.
  • A write still tells an unknown type from a denied one (ValidationException and ContentAccessDeniedException), as Add the content service with optimistic concurrency #2747 does. Only version reads were changed.
  • Repeat publish of an unchanged draft moves publishedAt and raises the event again.
  • Diff compares lists by position, since blocks have no ids, so inserting at the top reports every later block as changed.
  • A version from an older schema fails draft validation on restore if it holds an option that has since gone. A lost field or block is dropped.
  • Beyond SQLite (Validate and complete the PostgreSQL and SQL Server providers #2711): MarkPublishedAsync, MarkUnpublishedAsync and the version swap are compare and swap, but uniqueness between check and commit still relies on BEGIN IMMEDIATE, as Add the content service with optimistic concurrency #2747 notes. A second empty value would also behave differently under SQL Server's unique rules.

What later issues must do

@pournasserian
pournasserian merged commit 21b1554 into main Nov 3, 2026
5 checks passed
@pournasserian
pournasserian deleted the 2695-publishing branch November 3, 2026 13:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Publishing state machine: states, transitions, versions, diff, restore and reference integrity

1 participant