Skip to content

Add atomic change set publish - #2482

Merged
pournasserian merged 11 commits into
v2from
2478-atomic-publish
Oct 18, 2026
Merged

pournasserian merged 11 commits into
v2from
2478-atomic-publish

Conversation

@pournasserian

@pournasserian pournasserian commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

Closes #2478

  • IChangeSetDirectory.PublishAsync publishes an approved set in one unit of work: the set moves to published over the revision it read, each content type's published version moves from the operation's base to its proposed version (a delete clears it), and one content_type.published (content_type.unpublished for a delete) per item and change_set.published are appended with the set's id and sources. TransitionAsync to published runs the same publish.
  • An item no longer published at its operation's base version fails the publish with base_version_stale before the unit of work begins, one error per item. A concurrent publish over the same item is caught by the conditional pointer update, which rolls back everything moved before it.
  • PublishAsync runs the agent move check from Refuse agents' review moves and enforce change set caps #2503 once the set is read and before anything is written, so an agent cannot publish through either call (MCP-06). Tests that walk a set to published publish a fresh type each time.
  • Content types get PublishedVersion, moved only by publish. Content type writes and reads are unchanged until SCH-05 (F-SCH). Migrations for SQLite and PostgreSQL.
  • Tests cover publish with a create and an update, a published delete, sets that are not approved, stale items, a failure injected at each audit append of a publish, both races and a caller's outer unit of work. Test plumbing: TestServices.Build takes a callback that runs after Infrastructure, used to wrap the audit log, and the race helper moved into ChangeSetScenario.

Checked locally: dotnet build -warnaserror and dotnet test on SQLite and on PostgreSQL 17.

@pournasserian pournasserian added this to the M1 Governance core milestone Oct 18, 2026
@pournasserian pournasserian added type:feature New capability v2 FluentCMS v2 (v2 branch) area:change-sets Change sets (CHG) labels Oct 18, 2026
pournasserian added a commit that referenced this pull request Oct 18, 2026
Closes #2496

## What

The next F-MCP slice (#2423) after agent authentication (#2485). Core
now refuses what an agent may not do to change sets: agents never
approve or publish (MCP-06, SEC-02), and the change set caps on a
credential fail closed (SEC-04). Each refusal is a security event in the
audit log (SEC-08).

- **Core** (`FluentCMS.Core.Agents`):
- `IAgentPermissions` decides for the current caller, and
`AgentPermissions` implements it.
- People pass every check. An agent is held to its stored credential,
read again for the call: a missing, expired or revoked credential, or a
scope without the site, is refused.
- `IAgentActivity` counts what a credential has done for its caps.
Infrastructure implements it by counting the credential's
`change_set.opened` events.
- **Own sets only**: an agent submits, reopens and adds to change sets
it opened. A person's set, or another agent's, is refused with
`agent_not_change_set_owner`.
- **Transitions**: an agent may submit its set (open to checks running)
and reopen it after failed checks. Every other move, approve, publish,
reject and revert included, is refused with
`agent_transition_not_permitted` and a message that says who makes that
move.
- **Items per change set**: an operation that would take an agent's set
past `ItemsPerChangeSet` is refused with `agent_cap_exceeded`, naming
the cap and its value. This applies to explicit sets and to a session's
set.
- **Change sets per day**: opening a set past `ChangeSetsPerDay` in the
last 24 hours is refused the same way, whether the set is opened
explicitly or by a session's first operation. Joining an open set is not
opening one.
- **Audit**: `agent.transition_refused` and
`agent.items_per_change_set_exceeded` name the change set, and
`agent.change_sets_per_day_exceeded` names the credential. The refused
write itself leaves nothing behind.
- **`ChangeSetDirectory`** gains only the four calls into
`IAgentPermissions` and the target state passed to `MoveAsync`.

## Why

The epic's acceptance needs an agent that tries to approve or publish to
be refused whatever its scope, with the refusal logged, and a call past
a cap to fail closed with a message that names the cap. ADR-0007 puts
both in Core, so a tool and a form can never disagree.

Choices worth a look:

- **What an agent may do, not what it may not**: the check lists the two
moves an agent makes. A state added to the table later stays out of an
agent's reach until someone decides otherwise. This also stops an agent
from marking its own set as checked (checks running to awaiting review),
which would skip the checks.
- **Reject and revert are refused too.** MCP-01 makes review decisions
read-only for agents, and a revert changes what is live, as a publish
does.
- **The items cap uses the set as read for the write**, after the
domain's own checks. The revision that guards the write therefore guards
the cap too: of two concurrent operations that would each be the last
one allowed, one is stored and the other gets `change_set_conflict`.
- **Caps after the request's own checks**: the cap checks run after the
item and its versions are found, so an invalid request gets its own
error and does not log a cap event.
- **Known limits**: the daily count is not atomic. Two opens by one
agent at the same moment can each pass it, and the cap is exceeded by at
most the number of concurrent calls. A refusal event written inside a
caller's unit of work is rolled back with it if the caller does not
commit.
- **"Per day" is the last 24 hours**, counted from the audit log, so no
time zone decides when a day starts and a burst at midnight cannot
double the cap. The count uses SQL, as `AuditReader` does, because EF
Core does not compare a `DateTimeOffset` on SQLite.
- **Refusal events commit on their own**, or with the caller's unit of
work when there is one. An expected failure still leaves the caller's
unit of work able to commit.
- **Credential failures write no event.** Authentication (#2487) already
refuses an expired or revoked key at the transport. This covers a
credential that changed during the call, and an agent initiator without
a stored credential.
- **Tests**: the `ChangeSetScenario` agent now has a stored credential
with room to spare. The existing tests make the checks' and reviewer's
moves as a person through `AsPersonAsync`. Their assertions are
unchanged.
- **Atomic publish (#2478, PR #2482)** routes publishing through
`PublishAsync`. Whichever of the two PRs merges second adds the same
check there. I've told the session that owns #2482.
- **Not in this PR**: type, field and operation scopes on content types,
entries and change set items. Also left out: calls per period (needs the
tool call log, #2488), LLM spend (F-CHK), the desk view of security
events, and routing's auto-publish, which runs as the system.

## Tests

Integration, each on SQLite and PostgreSQL:

- For every transition in the table except submit and reopen, an agent's
attempt is refused with the code and a message naming the credential.
The state, revision and rejection reason are unchanged, and the only new
event is `agent.transition_refused` with the agent's credential.
- An agent submits its set and reopens it after failed checks, and both
events name its credential.
- An agent cannot submit or add to a person's set, and the set is
unchanged.
- With a cap of two items, the third operation is refused naming the
cap, both on a set and through a session, and the refusal is logged. A
person can still add the item.
- Of two concurrent operations that would each be the second of two
allowed, one is stored.
- With a cap of one change set a day, an explicit open and a new
session's first operation are refused and logged, while joining the open
session set works. A person is not limited. After 24 hours the agent
opens a set again.
- A scope without the site, an expired credential, a revoked one and an
unknown one are each refused with their code, and nothing is written.

The full suite ran locally against a `postgres:17` container as well as
SQLite. No new package and no new project reference.

## Checklist

- [ ] Linked to an issue a maintainer has confirmed
- [x] Targets `v2`
- [x] Tests or docs updated where behaviour changed
- [x] No tool-generated co-author trailers or footers
# Conflicts:
#	src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs
#	tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs
@pournasserian
pournasserian merged commit e855d00 into v2 Oct 18, 2026
2 checks passed
@pournasserian
pournasserian deleted the 2478-atomic-publish branch October 18, 2026 23:11
@pournasserian pournasserian mentioned this pull request Oct 18, 2026
4 tasks done
pournasserian added a commit that referenced this pull request Oct 18, 2026
Closes #2510

## What

The fifth slice of F-CHG (#2418): one revert action (CHG-06, D22), built
on atomic publish (#2482). Reverting a published set opens a new set
that puts every item back at the version it had before the original.
Publishing that set restores the live content and moves the original to
reverted, in one unit of work.

- **Core** (`FluentCMS.Core.ChangeSets`):
- `ChangeOperation.Inverse()`: a create becomes a delete of the created
version, an update becomes an update from its proposed version back to
its base, and a delete becomes a create of the deleted version. An
update back to an earlier version exists only this way;
`ChangeOperation.Create` still asks for a later one.
- `ChangeSet.Revert(rationale, initiator)` opens a set with the inverse
of every operation in the original's order. It names the original in
`RevertsChangeSetId` instead of citing sources. Only a published set can
be reverted (`transition_not_allowed`), and a revert set takes no
further operations (`revert_set_fixed`).
- **Directory**:
- `RevertAsync(changeSetId, rationale)` stores the revert set and its
operations with `change_set.opened` and one `change_set.operation_added`
each, whose previous version is the row of the version the original
published. Before the unit of work it refuses an unknown set, a set that
is not published, a blank rationale, and an item that moved on since
(`base_version_stale`, as the revert's publish would).
- `PublishAsync` of a revert set also moves the original from published
to reverted over its revision, and appends `change_set.reverted` after
`change_set.published`. If the original is no longer published, nothing
is written.
- `TransitionAsync` to reverted is refused with
`transition_not_allowed`, after #2503's agent check, so an agent's
attempt is still logged.
- **Agents** cannot revert: `RevertAsync` runs the move check for
reverted from #2503 (MCP-06). People are not capped, so the change set
caps do not apply.
- **Infrastructure**: `ChangeSets.RevertsChangeSetId`, nullable and
indexed, with a foreign key to the reverted set. Migrations for SQLite
and PostgreSQL.

## Why

ADR-0005 says a revert restores the previous version pointers, so a
revert proposes the earlier, stored version instead of writing a copy.
Live content then reads exactly as it did before the original, and the
diff, the stale check and publish all work on it unchanged.

Choices worth a look:

- **A revert is a normal set.** It goes through checks and review like
any other (S3 talks about it publishing), and nothing moves until it is
published. Fixing its operations keeps "restores exactly what the
original changed" true while it waits.
- **The stale check runs when the revert opens.** A revert of a set
whose item has moved on could never publish, so it is refused at once
with the same message publish would give.
- **Two reverts of one set**: the second one's publish fails, because
the items have moved on and the original is no longer published, so the
original is reverted once.
- **The original moves with the revert's publish, not when the revert
opens.** Until then the original is still what is live.
- **Not in this PR**: fact propagation (F-PRP, M3), the correction as
rule feedback (RUL-08), the desk's revert action, the MCP tool, webhooks
on revert, and other item kinds, which publish with their epics.

## Tests

Unit (Core):

- The inverse of a create, an update and a delete, and the inverse of
the inverse.
- An update back to an earlier version is refused by `Create`.
- A revert holds the inverse operations in order, names the original,
has no sources, and leaves the original as it was.
- Only a published set can be reverted. A revert needs a rationale and
takes no further operations.

Integration, each on SQLite and PostgreSQL:

- Reverting a published set with a create, an update and a delete stores
the inverse operations, with each event naming the row it is based on.
The diff shows the update going back.
- Publishing the revert puts every content type back at its earlier
published version and reverts the original. The events are one per item,
then `change_set.published` and `change_set.reverted`.
- Reverting the revert publishes the original's versions again.
- An unknown set, an open set, a set whose item moved on and a blank
rationale are refused without writing a set or an event. An operation
added to a revert set and `TransitionAsync` to reverted are refused.
- An agent's revert is refused and logged as `agent.transition_refused`.
- Of two reverts of one set, only the first publishes.
- A failure injected at each of the five audit appends of a revert's
publish leaves the items, the revert and the original as they were.

`dotnet build -warnaserror` and the full suite pass locally on SQLite
and against a `postgres:17` container. No new package and no new project
reference.

## Checklist

- [x] Linked to an issue a maintainer has confirmed
- [x] Targets `v2`
- [x] Tests or docs updated where behaviour changed
- [x] No tool-generated co-author trailers or footers
pournasserian added a commit that referenced this pull request Oct 18, 2026
Closes #2509

## What

The write tools of F-MCP (#2423) for content types and change sets, on
the conventions from #2508. An agent proposes schema changes and submits
them, and a person reviews and publishes them.

- **Change sets**: `open_change_set` (rationale, sources),
`submit_change_set` (open to checks running) and `reopen_change_set`
(checks failed to open), for the agent's own sets only (#2503).
- **Content types**: `create_content_type` (name, slug, definition) and
`revise_content_type` (slug, base version, definition).
- Each stores the new version, unpublished (#2482), and adds the
matching schema operation to a change set in one unit of work, so either
both are written or neither is.
- The set is a `change_set_id` from `open_change_set`, or a `session_id`
with a rationale and sources, which opens the session's set on its first
call (CHG-02).
- Each write result carries the content type, the change set and a
`next` sentence that says what comes next.
- **Core**:
- `IChangeSetDirectory.GetSessionSetAsync` reads the caller's open set
for a session id.
- `IAgentPermissions.CheckChangeSetSizeAsync` takes the number of
operations a write is about to add.
- `ToolArgumentsReader` reads lists of strings, objects, optional ids
and required whole numbers.
- **CONTRIBUTING.md**: the read and write tools and what is live when.

## Why

MCP-01 asks for tools for every capability, and the governance model
needs everything an agent writes to wait in a change set for a person.
With #2482, a stored content type version is not published until its set
is. With this PR, every version an agent stores is also in one of its
change sets, and no tool approves, rejects or publishes (MCP-06).

Choices worth a look:

- **One unit of work for both writes.** Each directory finds its
expected failures before its own unit of work, so the tool's outer one
undoes the version when the change set refuses the operation, for
example because of the item's version, a cap or ownership.
- **Caps are checked before the write too.** A refused cap inside the
outer unit of work would roll back its own security event (SEC-08, the
limit noted in #2503). The tool therefore first reads the target set and
asks `IAgentPermissions` with the operation it is about to add, or asks
for a new set when the session has none. That refusal commits its event,
and nothing is stored. The set checks again on the add, which settles a
race.
- **`GetSessionSetAsync` is a small read on the change set directory**,
the one place that knows a session's open set. The pre-check needs it.
- **No operation scope yet**: an agent with any key can propose. Type,
field and operation scopes are the credentials lane's.
- **Create and revise of one type in one set** is refused by the set
(one operation per item), with the set's own message.
- **Submit and reopen are separate tools**, so each description says
exactly when to call it.
- **Not in this PR**: approve, reject, publish and revert (never for
agents), bulk operations with progress and the MCP-01 coverage test
(next slice), and entry and entity writes (their lanes).

## Tests

Integration, over HTTP:

- On SQLite and PostgreSQL, an agent opens a set, creates `category`,
revises a person's `product` from version 1 and submits the set. The set
holds both operations in order, is in checks running and names the
agent, and the tool call log has all four calls, succeeded.
- On SQLite and PostgreSQL, two creates with one `session_id` land in
one set with the first call's rationale.
- On SQLite and PostgreSQL, these store no version, while the agent's
own valid create is stored:
- a definition with an invalid slug, an unknown field type and a bad
field name returns every error at once
- a create past a cap of one item is refused, and
`agent.items_per_change_set_exceeded` stays in the log
- a create into a person's set and a submit of it are refused as not the
agent's own
  - a create without a target names the missing argument
- A revision from a stale base version returns `version_conflict` with a
hint to read the current version, and the type stays at version 2.
- The conformance tests now cover the write tools: names, described
schemas with valid examples, and unknown arguments refused before any
service is read. Unit tests cover the new reader methods.

The full suite ran locally against a `postgres:17` container as well as
SQLite, and markdownlint passes. No new package and no new project
reference.

## Checklist

- [ ] Linked to an issue a maintainer has confirmed
- [x] Targets `v2`
- [x] Tests or docs updated where behaviour changed
- [x] No tool-generated co-author trailers or footers
pournasserian added a commit that referenced this pull request Oct 18, 2026
Closes #2515

## What

The next F-CNT (#2416) slice: change sets hold operations on entries and
entities, and diff, publish and revert them like schema operations
(CHG-01, CHG-03, CHG-05, CHG-06). Builds on atomic publish (#2482) and
revert (#2511). This is the first thing that moves
`Entries.PublishedVersion`.

- **Item kinds**: an `entry` operation must point at an entry of a plain
content type, and an `entity` operation at an entry of an entity type,
in each case of a type the agent's scope allows for the operation
(MCP-05). The wrong kind fails with the new `item_kind_mismatch`. A
missing or soft-deleted entry fails with `item_not_found`, and a missing
version with `version_not_found`. `change_set.operation_added` carries
the base version's row as previous version, as for schema items. Other
kinds (media, rules) are still `item_kind_unavailable`.
- **Diff** (`ItemDiff.ForEntry` in Core): two versions of an entry,
field by field, with `ValueDiff.Compare`. On an entity a field's value
is its whole fact, so a new source shows as a change. Fields follow the
definition the shown version was written against, so the order is the
same on SQLite and PostgreSQL even though `jsonb` reorders keys.
`DiffAsync` reads every version the entry operations name, with their
definitions, in one query.
- **Publish**: each entry's published version moves from its operation's
base version to the proposed one with a conditional update, alongside
schema items in the same unit of work. Stale bases fail with
`base_version_stale` before anything moves, and a soft-deleted entry
fails with `item_not_found`. Events: `entry.published` /
`entry.unpublished`, or `entity.*`.
- **Delete means unpublish**: a published delete clears the published
version and keeps the entry, its draft and its history. Revert of a
create unpublishes, and revert of a delete publishes the base version
again, through the same publish.
- **One history per entity**: `EntryDirectory` now names entries of
entity types `entity` in its events (`entity.created`, `entity.updated`
and so on), so an entity's own events and the change set events about it
share a subject.

## Why

Entries were stored and versioned (#2491) but nothing could put them
live. Publishing through change sets keeps CHG-05's all-or-nothing rule
for mixed sets of schema, entry and entity items (CHG-09), and revert
comes for free from `ChangeOperation.Inverse`.

Choices worth a look:

- **Separate kinds for entries and entities** let the desk and blast
radius (ENT-04) tell content changes from fact changes, without a lookup
per item.
- **A published delete unpublishes rather than soft-deletes**, so its
revert only has to move a pointer back. Soft delete stays a direct
action with its retention window (#2502). The entry states slice will
call an unpublished entry archived.
- **Writes do not join change sets themselves yet.** A write stores a
draft version and an operation points at it, as content types did before
SCH-05. Entry writes will follow the change set target that #2514
introduced, together with the entry tools.
- **Changes to the F-CHG lane's file are kept in separate methods**
(`ResolveEntryAsync`, `EntryDiffsAsync`, `PublishedEntriesAsync`,
`PublishableEntry`), so the SCH-05 work merges easily.

## Tests

Unit (Core):
- `ItemDiff.ForEntry`: changed fields in definition order, with a
removed field the type no longer has last; formatting-only differences
ignored; facts compared whole; create and delete; refusal of non-entry
operations and of fields that do not match the operation's versions.

Integration, each on SQLite and PostgreSQL:
- An agent cannot add an operation on an entry of a type outside its
scope.
- Entry and entity operations stored with their kinds and events; the
wrong kind both ways, a missing entry, a soft-deleted entry and a
missing version each fail and write nothing; an update's event carries
its base version's row.
- The diff of an entry update and an entity create.
- Publishing moves both pointers with one event per item; a resolved
view follows the published price, not a new draft; an entity's own and
change set events share its history.
- A stale base and a soft-deleted entry fail the publish and move
nothing.
- A published delete unpublishes and keeps the entry; its revert
publishes it again and marks the delete reverted.
- Reverting an update puts the earlier version back, and its diff shows
the change backwards.
- A failure injected at each audit append of a publish leaves no entry
published.

The full suite ran locally against a `postgres:17` container as well as
SQLite, after merging the latest v2. No migration, no new package and no
new project reference.

## Checklist

- [x] Linked to an issue a maintainer has confirmed
- [x] Targets `v2`
- [x] Tests or docs updated where behaviour changed
- [x] No tool-generated co-author trailers or footers
pournasserian added a commit that referenced this pull request Oct 19, 2026
Closes #2513

## What

The SCH-05 slice of F-SCH (#2415), on top of change set publish (#2482)
and revert (#2511). Every content type write is now a change set
operation. A create or a revision stores its new version and records the
operation in the change set it names, in one unit of work. Nothing a
write does is live until that set publishes.

- **Core** (`FluentCMS.Core.ChangeSets`):
- `ChangeSetTarget`: the set a write joins, either an existing set by id
or a session id with the rationale and sources of the set the session's
first write opens (CHG-02).
- `ChangeSet.Record(operation)` adds the operation, or extends the one
the set already holds for the item. The write must be based on the
version that operation proposes, and the operation then proposes the
write's version. A create stays a create and an update keeps its base,
so a set still has one operation per item (CHG-03) and its diff runs
from the base to the newest version.
- **Core** (`FluentCMS.Core.Schema`):
- `IContentTypeDirectory.CreateAsync` and `ReviseAsync` take a
`ChangeSetTarget`. A create records a create proposing version 1, and a
revision records an update from its base to the new version.
- A revision's base is the latest version or the published one. In a set
that already holds the type it is the version that set proposes, so the
set can keep revising its own draft after another set drafts the type.
Basing it on the published version leaves an unpublished draft behind,
such as a rejected one. The referenced-field rule (SCH-04) then also
checks the fields of every draft an open set proposes, so such a
revision cannot drop a field another draft references.
- A revision of a type no set has published, or one a published delete
took offline (a rejected or reverted create), records a create, since
there is nothing live to update. `ChangeSet.Record` takes whether the
item is published, and an agent needs the create scope for such a
revision (MCP-05).
- A draft is a base only while a set that can still publish proposes it.
A revision of a rejected or reverted draft is refused at once with
`version_conflict` and points to the published version, instead of
failing later at publish. While an open set proposes a draft, the latest
or an older one, a revision keeps its referenced fields and its entity
flag (SCH-03). A draft whose set went stale still counts until a person
rejects it; #2588 tracks that.
- References between types are checked against what each type is heading
to: every draft an open set proposes, or its published version while
there is none. A rejected draft neither drops a live reference nor
offers a field to reference, and a field a pending draft removes is no
target. A field another type references stays while any of that type's
live versions or open drafts references it.
- `GetPublishedAsync(slug)` reads the live version, or fails with
`content_type_not_published`. `ContentTypeSummary` carries
`PublishedVersion`.
- **Infrastructure**:
- `ChangeSetDirectory` gains an internal join for writes owned by
another directory. `PrepareAsync` finds the expected failures before the
caller's unit of work begins: unknown set, set not open, blank
rationale, agent ownership and the items cap. Inside it,
`BeginWriteAsync` opens the set or moves its revision, and `RecordAsync`
records the operation after the item's own rows and event. An agent's
daily cap is counted in `BeginWriteAsync` with its credential held, as
`OpenAsync` does since #2580. It is therefore the one refusal that rolls
a caller's unit of work back, and #2513's acceptance now says so.
- `content_type.created` and `content_type.revised` now carry the set's
id, and come after `change_set.opened` when the write opens its
session's set. An extended operation appends
`change_set.operation_updated`, the name #2575 uses for the same move of
an entry operation.
  - Both directories share one `ChangeSetDirectory` per scope.
- **MCP write tools** (#2514): `create_content_type` and
`revise_content_type` pass their target to the directory, which now
records the operation itself. `ProposedWrite` no longer opens its own
unit of work or checks the set ahead of the write; the directory's
checks run before anything is written, so a refused cap still keeps its
security event. The answer names the kind the set recorded, so a
revision of a type with nothing live reads as a create. In
`WriteToolsTests` the agent's revision of the person's unpublished
product is now expected as `Create null→2` instead of `Update 1→2`,
which is the behaviour change agents see. The entry tools (#2525) keep
the tool-level pre-check and add, since entry writes do not join change
sets in their directory yet.
- No migration: both pointers already exist.

## Why

The decision, as written in #2513: `Version` is the latest version,
which authoring builds on, and `PublishedVersion` is what is live. The
authoring reads stay on the latest version: `GetAsync`, `ListAsync`, the
schema document and entry validation. A set can then build on its own
drafts, and entries can be written against the schema change they need
in the same set (CHG-09). Delivery reads the published version when
F-API arrives.

Choices worth a look:

- **The operation is written by the change set directory, inside the
content type directory's unit of work.** Version and operation commit
together, and the existing "expected failures before the unit of work"
rule still holds for both.
- **Extending instead of refusing a second write.** An agent iterating
on a type within a session would otherwise need a new set for every
revision.
- **A unique index at commit** means the slug or version race it always
meant. When the write opened a session's set, it may also be a
concurrent first write of that session, so that case answers with
`change_set_conflict`.
- **Two revisions racing into the same set**: the loser now fails with
`change_set_conflict`, since the set's revision moved first, rather than
`version_conflict`. The race tests give each writer its own set to keep
checking the version race.
- **Reference checks at write time are best effort.** Sets publish in
any order, and a revert can take a field away again, so a reference can
still dangle across sets. #2604 checks references at publish, which is
where the order is known. It also covers dropping a field together with
the last reference to it in one set, which the write-time check still
refuses.
- **Breaking signature**: every caller of `CreateAsync` and
`ReviseAsync` passes a target. In this PR that is the MCP schema tools
and tests: the schema, entry and MCP scenarios write their content model
in a `content-model` session set, as a person, so agent caps and the
change set reads in tests are not affected.
- **Answering from the set the write joined**: the schema tools read the
session's set again after the write, which can race with a submit; #2615
passes the set out of the write.
- **Not in this PR**: entries as change set operations, and refusing at
publish an entry whose content type version is not published (F-CNT).
Also left out: deleting a content type, the MCP schema tools, the desk's
schema review and delivery reading the published schema.

## Tests

Unit (Core):

- A later write extends the item's operation, keeping its kind and base,
for a create and an update.
- A later write not based on the proposed version, a delete over an
update, and an update over a delete are refused and change nothing.
- A write is recorded only while the set is open.

Integration, each on SQLite and PostgreSQL:

- A create is an operation of its set, with `content_type.created`
naming the set. The type lists with no published version and
`GetPublishedAsync` refuses it until the set publishes.
- A revision in the same set extends the operation (create from nothing
to version 2). Its events name the version it was based on, and the diff
lists both fields as added.
- A revision of a published type into a new set is an update from the
published version. It is live only after that set publishes, and the
previous version stays readable.
- After a rejected draft, a revision based on the published version goes
through, while one based on the rejected draft fails with
`version_conflict`.
- After a rejected create, and after a published create was reverted,
the next revision is recorded as a create, and publishing it makes the
type live again.
- A revision from the published version cannot drop a field that another
draft in an open set references.
- Writes through a session share the set the first one opens.
- An unknown set, a set that is not open and a blank session rationale
are refused inside a caller's unit of work, which then commits.
- Inside a caller's unit of work, an agent cannot write into another
agent's set, and a write past its items cap is refused. Neither stores a
content type or change set event, the cap refusal keeps its security
event, and the caller's unit of work commits.
- A failure injected at either audit append of a revision, after the
pointer and the set's revision reached the database, leaves neither the
version nor the operation. A caller's unit of work rolled back after the
revision's rows were saved leaves neither either.
- A rejected draft is refused as a base, while a draft an open set
proposes can be built on and publishes after it.
- An agent without the create scope cannot revise a type with nothing
live.
- A revision from the published version keeps an open draft's entity
flag, and may drop it once that draft's set is rejected.
- A rejected draft of a page does not let its brand drop a field the
live page references, and a field only a rejected draft has cannot be
referenced.
- A field that a pending draft removes cannot be referenced, from the
draft's own set or another one.
- A field that an older open draft references cannot be removed, even
when a later draft of that type is the latest.
- A set keeps revising its own draft after another set drafted the type,
and a revision of it from the latest version is refused with
`version_conflict`.
- A revision into a rejected set that holds the type is refused with
`change_set_not_open`.
- A revision keeps the entity flag of an older open draft when the
latest draft was rejected.
- A content type write that would open a set past an agent's daily cap
is refused, stores no set or type, and keeps one security event.
- A session's first write appends the set's opening before the content
type's own event.

`dotnet build -warnaserror` and the full suite pass locally on SQLite
and against a `postgres:17` container. No new package and no new project
reference.

## Checklist

- [x] Linked to an issue a maintainer has confirmed
- [x] Targets `v2`
- [x] Tests or docs updated where behaviour changed
- [x] No tool-generated co-author trailers or footers
pournasserian added a commit that referenced this pull request Oct 19, 2026
)

Closes #2616

## What

A follow-up of F-CHG (#2418), from the post-merge review of publish
(#2482) and revert (#2511).

- **Core** (`ChangeSetTransitions`): the table gains approved →
rejected.
  - A rejection still needs a reason (CHG-08).
- Only people make this move. Agents are refused it, as any move other
than submit and reopen is since #2503 (MCP-06). No engine owns it in
`AgentPermissions.SystemMoves` (ADR-0012).
- **Entries**: a rejected set holds nothing in review (CNT-01), so the
entries of an approved set that a person rejects leave review with no
further change.
- **Publish** moves its items sorted by item kind and id, then appends
their events in the set's order. Two sets that publish the same items
take their row locks in the same order: the later one waits, finds its
base gone and fails with `base_version_stale`.
- **Deadlocks**: a PostgreSQL deadlock (`40P01`) or serialization
failure (`40001`) during a publish, or at the commit of any change set
write, is answered with `change_set_conflict` instead of an exception,
as a unique violation already is
(`DatabaseErrors.IsConcurrencyFailure`).
- **Desk**: the set view offers Reject on an approved set too, next to
Publish. Its hint says to reject it with a reason if it can no longer
publish.

## Why

An approved set whose publish has gone stale could never leave approved.
For example, two reverts of one set are both approved and the first
publishes. The second then sat approved forever, and its entries stayed
in review. Rebasing a stale set is out of scope: a person rejects it and
the author opens a new set.

Publish moved items in the order the set lists them. On PostgreSQL, two
sets that list the same items in opposite orders deadlocked, and one
call threw.

Choices worth a look:

- **Lock order instead of retry.** Sorting the pointer moves removes the
cycle. The deadlock mapping stays as a safety net for writers outside
publish.
- **The mapping has no test of its own.** With the sort in place, two
publishes no longer deadlock, and no other writer here takes two item
rows. To check the race test, I removed the sort and the mapping on
purpose: it failed on PostgreSQL with `40P01: deadlock detected`, and
with both back it passes.
- **Events keep the set's order**, so the audit log reads as the set was
written, whatever order the rows were locked in.
- **The desk change is two conditions and one hint.** Without it, a
person had no way to reach the new move from the admin.

## Tests

Unit:

- The transition table lists approved → rejected, and every other
transition stays as it was.
- `Reject` from approved succeeds with a reason, and stays refused where
the table has no rejection.
- The check engine cannot reject an approved set.
- The set view offers Publish and Reject for an approved set.

Integration, each on SQLite and PostgreSQL:

- Two reverts R1 and R2 of one published set are both approved, and R1
publishes. R2's publish then fails with `base_version_stale`, and its
entry stays in review. A blank reason is refused. With a reason, R2 is
rejected, the entry leaves review, and the original set stays reverted
once.
- Two approved sets update the same two content types from the same
base, listed in opposite orders, and publish at the same time, five
rounds in a row. Each round, one wins, the other fails with
`base_version_stale`, and neither call throws.
- The table-driven tests cover the new transition for agents (refused
and logged) and for people (stored with its event).

`dotnet build -warnaserror` and the full suite pass locally on SQLite
and against a `postgres:17` container. No new package and no new project
reference.

## Checklist

- [x] Linked to an issue a maintainer has confirmed
- [x] Targets `v2`
- [x] Tests or docs updated where behaviour changed
- [x] No tool-generated co-author trailers or footers
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:change-sets Change sets (CHG) type:feature New capability v2 FluentCMS v2 (v2 branch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Atomic publish: version pointers and provenance in one unit of work

1 participant