Repository navigation
Record content type writes as change set operations - #2520
Merged
Merged
Conversation
# Conflicts: # src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs
# Conflicts: # src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs
# Conflicts: # tests/FluentCMS.IntegrationTests/Mcp/WriteToolsTests.cs
This was referenced Oct 19, 2026
4 tasks done
# Conflicts: # tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs
# Conflicts: # src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs # src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs # src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs
# Conflicts: # tests/FluentCMS.IntegrationTests/Browser/AccessibilityTests.cs
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
3 tasks done
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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.FluentCMS.Core.Schema):IContentTypeDirectory.CreateAsyncandReviseAsynctake aChangeSetTarget. A create records a create proposing version 1, and a revision records an update from its base to the new version.ChangeSet.Recordtakes whether the item is published, and an agent needs the create scope for such a revision (MCP-05).version_conflictand 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.GetPublishedAsync(slug)reads the live version, or fails withcontent_type_not_published.ContentTypeSummarycarriesPublishedVersion.ChangeSetDirectorygains an internal join for writes owned by another directory.PrepareAsyncfinds 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,BeginWriteAsyncopens the set or moves its revision, andRecordAsyncrecords the operation after the item's own rows and event. An agent's daily cap is counted inBeginWriteAsyncwith its credential held, asOpenAsyncdoes 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.createdandcontent_type.revisednow carry the set's id, and come afterchange_set.openedwhen the write opens its session's set. An extended operation appendschange_set.operation_updated, the name Run the checks on every submitted change set #2575 uses for the same move of an entry operation.ChangeSetDirectoryper scope.create_content_typeandrevise_content_typepass their target to the directory, which now records the operation itself.ProposedWriteno 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. InWriteToolsTeststhe agent's revision of the person's unpublished product is now expected asCreate null→2instead ofUpdate 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.Why
The decision, as written in #2513:
Versionis the latest version, which authoring builds on, andPublishedVersionis 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:
change_set_conflict.change_set_conflict, since the set's revision moved first, rather thanversion_conflict. The race tests give each writer its own set to keep checking the version race.CreateAsyncandReviseAsyncpasses a target. In this PR that is the MCP schema tools and tests: the schema, entry and MCP scenarios write their content model in acontent-modelsession set, as a person, so agent caps and the change set reads in tests are not affected.Tests
Unit (Core):
Integration, each on SQLite and PostgreSQL:
content_type.creatednaming the set. The type lists with no published version andGetPublishedAsyncrefuses it until the set publishes.version_conflict.version_conflict.change_set_not_open.dotnet build -warnaserrorand the full suite pass locally on SQLite and against apostgres:17container. No new package and no new project reference.Checklist
v2