Skip to content

Record content type writes as change set operations - #2520

Merged
pournasserian merged 42 commits into
v2from
2513-schema-change-sets
Oct 19, 2026
Merged

pournasserian merged 42 commits into
v2from
2513-schema-change-sets

Conversation

@pournasserian

@pournasserian pournasserian commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

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; Do not build content type revisions on drafts of stale sets #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 Hold the agent's credential while counting its caps, and keep refusal events #2580. It is therefore the one refusal that rolls a caller's unit of work back, and Schema changes as change set operations: content type writes wait for publish #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 Run the checks on every submitted change set #2575 uses for the same move of an entry operation.
    • Both directories share one ChangeSetDirectory per scope.
  • MCP write tools (Add the write tools for change sets and content types #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 (Add 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. Check schema references when a change set publishes #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; Answer a schema write with the change set it joined #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

  • Linked to an issue a maintainer has confirmed
  • Targets v2
  • Tests or docs updated where behaviour changed
  • No tool-generated co-author trailers or footers

@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:schema Content types and entity types (SCH) labels Oct 18, 2026
# Conflicts:
#	src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs
#	src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs
#	src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs
@pournasserian
pournasserian merged commit 700ee56 into v2 Oct 19, 2026
2 checks passed
@pournasserian
pournasserian deleted the 2513-schema-change-sets branch October 19, 2026 10:19
pournasserian added a commit that referenced this pull request Oct 19, 2026
Closes #2544

Built on the media assets of #2543 and the schema change sets of #2520,
both merged.

## What

The next F-MED (#2427) slice: media in change sets, and the tools agents
use to upload, describe, read, list and attach media (MED-03, CHG-01).

- **Change sets** (`ChangeSetDirectory`):
- A `media` operation points at an asset of the site and at stored
versions of it. Its `change_set.operation_added` event carries its base
version's row, as entries' do.
- `ItemDiff.ForMedia` compares metadata as fields (file name, folder,
tags, alt text, caption) and the bytes as properties (content type,
length, SHA-256), so a metadata change shows no property.
- Publish moves `MediaAssets.PublishedVersion` with the same stale-base
checks as entries; a delete unpublishes. Events: `media.published` and
`media.unpublished`. Revert goes through the same publish.
- **Agent permissions**: `IAgentPermissions.CheckMediaAsync(operation)`.
Media is not a content type, so a scope allows it by its sites and
operations alone, whatever types it lists (MCP-05). Change sets check it
when a media operation is added.
- **`MediaTools`** (`FluentCMS.Core.Builtin.Tools`):
- `upload_media`: base64 bytes, content type and metadata. It stores
version 1 and adds the create to a set in the same call. Bytes that end
up in no committed asset are deleted again.
- `update_media`: new metadata from a base version; omitted properties
keep their values.
- `get_media` and `list_media`: by folder, optionally with subfolders,
and by tag.
- `attach_media`: sets a single media field, or adds to a list of media,
in the entry's next version, and proposes it like `update_entry`. On an
entity it writes the fact's value and keeps its metadata.
- **Supporting changes**: `MediaAssetVersionRow.ToVersion()` is shared
by the library and change sets. `ToolSchemas.Boolean` and
`ToolArgumentsReader.OptionalFlag` are new, and `ToolJson` renders
media. The MED rows of `McpCoverageTests` list the tools.

## Why

CHG-01 puts every write into a change set, and F-MED's acceptance has
uploads and metadata changes in the uploader's set with the uploader as
initiator. Media versions already carry base and proposed numbers
(#2542), so they publish and revert like entries. That lets one set hold
a page and the image it now shows, and publish them atomically (CHG-05).

Choices worth a look:

- **Scope by operation, not type**: a credential that lists content
types can still upload media when its operations allow it, since an
image belongs to no type. Narrowing media further would need a scope
field for it.
- **An upload proposes a create, and so does a metadata update before
the first publish**, as for entries. A second write to one asset in one
set is refused as a duplicate, as for entries and content types.
- **Bytes are cleaned up when the set refuses the operation**: the
storage is not transactional, so the tool deletes what it uploaded when
its unit of work does not commit.

## Tests

Integration, each on SQLite and PostgreSQL:
- A media create diffs as three properties and three fields and
publishes with `media.published`. An alt text update diffs as one added
field and no property, publishes, and its revert puts version 1 back and
marks it reverted. A delete unpublishes with `media.unpublished`.
- Two sets creating one asset: a failure at each audit append of the
first publish moves nothing, it then publishes, and the second is stale.
- An unknown asset and a missing version fail. An agent without the
create operation is refused a media create, and one scoped to `product`
with create can add it.
- Over MCP:
- S2's media part: an agent scoped to `product-page` uploads an image
without alt text and attaches it to a published page in one session's
set. The set holds the media create and the entry update with the agent
as initiator, and a person publishes both.
  - `list_media` finds the asset and says it has no alt text.
- `update_media` adds alt text, keeps the other metadata, and proposes
the whole asset because it was never published.
- Refused: an upload without the create operation, bad base64 together
with a bad file name, attaching to a field that is not media, and
attaching an asset that does not exist. None of them stores anything.
- The "kind not available yet" case in `ChangeSetDirectoryTests` now
uses a rule operation.

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

## Review

The media stack was reviewed before merging. Its findings for this PR
are fixed:
- `upload_media` refuses an upload over `Media__MaxBytes` from the
length of its base64, before decoding any of it, on HTTP and stdio
alike. `IMediaLibrary.MaxBytes` gives the limit.
- CONTRIBUTING says that MCP over HTTP holds an upload to about 22 MB,
because of the server's 30 MB request limit.
- The tool list conflict with `check_draft` is resolved.
- The test's content type is now created through
`TestChangeSetTargets.ContentModel`.

On v2, `MediaTools` uses the shared `ToolSchemas.Flag` and
`ToolArgumentsReader.Flag`, and passes the asset to
`ChangeSetTarget.CheckAsync`, so an update to an asset the set already
holds takes no extra room under the cap. New test: an upload over the
limit and a file unlike its declared type are refused through the tool
and store nothing.

## 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 #2634

## What

The first slice of F-RUL (#2419), RUL-01 to RUL-03: the rule book, with
rules stored as versioned content and published through change sets. It
builds on the change set writes of #2520 (SCH-05).

- **Core** (`FluentCMS.Core.Rules`):
- `RuleDefinition` holds four things, stored as one JSON document
(ADR-0005), with `Validate` saying what to change:
- a kind: voice and style, factual, structural, or permission and
routing
    - the rule's text
    - a severity, block or warn
- a scope of content types, fields and locales; the locale slot is kept
and nothing reads it in M1
- `Rule`: a slug, a latest version and an active version. Its state is
draft, active or retired, and only publishing moves it.
  - `IRuleDirectory`:
- reads: the list, the latest version, the active version, and any
version
- writes, each into a `ChangeSetTarget`: create, revise (from the
latest, the active, or the version the set already proposes) and retire
- `ItemDiff.ForRule`: the kind, text, severity and each scope list,
before and after.
- **Infrastructure**:
- `Rules` and `RuleVersions` tables, with migrations for SQLite and
PostgreSQL; the definition is `jsonb` on PostgreSQL.
- `RuleDirectory` writes as `ContentTypeDirectory` does: every expected
failure is found before the unit of work, and the version, its
`rule.created` or `rule.revised` event and the change set operation
commit together.
    - A second revision in the same set extends the rule's operation.
    - A retirement is a delete operation from the active version.
  - `ChangeSetDirectory` handles rule items:
- resolving an operation, and refusing one from an agent or an engine
- publishing: the active version and the state move together, and
`rule.published` or `rule.unpublished` is logged
    - reverting, through the inverse operations
    - the diff
- CONTRIBUTING.md: the rule book.

## Why

RUL-01 makes rules content: written, versioned, reviewed and published
through change sets like everything else. The next slices propose checks
from a rule and read the rule book for agents, and both need a rule to
have versions and an active one.

Choices worth a look:

- **State follows publishing.** A rule is a draft until a published set
makes a version active, and retired once a published set retires it, as
content types and entries are. Reverting a retirement makes it active
again, at the version it had, and reverting the set that created it
makes it the draft it was.
- **Only a person writes rules.** The epic says any administrator writes
and activates rules in M1. An agent reads the rule book in a later
slice. An agent or engine is refused in the directory, and also when it
puts a rule operation in a set directly.
- **Severity reuses `CheckSeverity`**, since the checks proposed from a
rule take its severity.
- **No tools or screens yet.** The rule book tool and resource come with
RUL-06, and the desk shows rules with RUL-05.
- **Not in this PR**: proposing checks (RUL-04), enforced and advisory
labels and the agent read (RUL-05, RUL-06), flagging instruction-like
rule text (RUL-07), and proposing a rule from the desk (RUL-08).

## Tests

Unit:

- A definition round-trips through its stored document.
- Each kind has a stored name.
- A new rule is a draft at version 1.
- A rule that cannot be held lists each problem.
- A revision builds on the latest or the active version.
- A stored document that is not a rule says why.

Integration, each on SQLite and PostgreSQL:

- A rule is a draft until its set publishes it, then active at version
1. Its operation and events are recorded, and publishing logs
`rule.published`.
- Two revisions in one set extend its operation to version 3, and
version 1 stays active until the set publishes. The diff shows the text,
severity and scope changes.
- A retirement retires the rule once published; retiring a retired rule
is refused; reverting the retirement makes it active again.
- Reverting the set that created a rule makes it a draft again, not
retired.
- An invalid rule (kind, text and severity), a taken slug, an unknown
rule and a stale base version are refused, and nothing is stored. A
stored document with an unknown severity is refused when read.
- An agent and the check engine are refused every rule write, and an
agent's rule operation in its own set is refused.

The full suite ran locally against a `postgres:17` container as well as
SQLite, and markdownlint passes. No new package. This PR adds no
`IContentTypeDirectory.CreateAsync` or `ReviseAsync` call.

## Checklist

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

Labels

area:schema Content types and entity types (SCH) type:feature New capability v2 FluentCMS v2 (v2 branch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Schema changes as change set operations: content type writes wait for publish

1 participant