Repository navigation
Add the write tools for change sets and content types - #2514
Merged
Merged
Conversation
This was referenced Oct 18, 2026
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 #2523 ## What The last F-CNT (#2416) slice: the entry tools agents call over MCP (MCP-01, P1), following the write-tool conventions of #2514. - **`EntryTools`** (`FluentCMS.Core.Builtin.Tools`), discovered like the other built-in providers: - Reads: `list_entries`, `get_entry` (latest draft or a named version, with state and pointers), `get_entry_history`, `get_resolved_entry` (entity-field references replaced by published values, plus what could not be resolved) and `get_where_used` (an entry, one entity field with `field`, or a media asset with `asset_id`). - Proposals: `create_entry`, `update_entry` and `delete_entry`. The new version, if any, and its operation commit together into a set named by `change_set_id` or a session's set (`ChangeSetTarget`), as `create_content_type` does, so a refused add stores no version. The operation is an entity operation for an entity type. - **What an update proposes**: it is based on the entry's published version, the version publishing replaces. An entry that was never published gets a create of the new version, so publishing it never fails as stale just because drafts piled up. `delete_entry` proposes unpublishing, and an entry that is not published is refused with the new `entry_not_published`. - **Hints** name the next call: `get_entry` after a version conflict, `get_schema_document` for invalid fields, `list_entries` for a missing entry or a bad reference, and `list_content_types` for an unknown type. - `ToolJson` renders entries, summaries, history, resolved views and usages; states and reasons are snake_case like the rest. - Tests: the CNT row of `McpCoverageTests` lists the tools, `EntryToolsConformanceTests` covers the provider, and the MCP test session (`ConnectedAgent`) moved out of `WriteToolsTests` so both test classes share it, with a narrower scope where a test needs one. ## Why P1 has every capability as a tool before it is a screen. These are the entry half of the reference scenario: an agent writes a product and a page that shows its price, and a person reviews and publishes them. Choices worth a look: - **The propose helper is a copy of `ProposedWrite`'s shape**, kept inside `EntryTools` and not merged with the schema one, because SCH-05 is about to move the change set target into the directories. Entries follow that change when it lands. - **Soft delete and restore stay off the tool list**: they bypass change sets, so they remain a person's action for now. - **A second update of one entry in one set is refused as a duplicate operation**, as a second revision of a content type is today. Amending an operation in place is change set work. - **Entity-specific tools** (facts, validity, blast radius) are left to F-ENT; the ENT row of the coverage test stays open. ## Tests Unit (Core): - `EntryToolsConformanceTests`: names, descriptions, input schemas with examples, unknown arguments refused. - `McpCoverageTests`: the new tools are registered, listed, and none approves or publishes. Integration over MCP, each on SQLite and PostgreSQL: - S2 end to end: an agent creates a product entity and a page that references its price in one set and submits it. The page is in review until a person publishes the set. Then the agent reads the page resolved with the price, finds the page where `product.price` is used, and lists, reads and pages through the history. - An update of a published entity proposes an update from its published version; a stale base version is refused with a hint to read the entry, invalid fields with a hint to read the schema document, and neither stores a version or opens a set. - `delete_entry` refuses an unpublished entry and proposes unpublishing a published one. - An agent scoped to products cannot read or resolve a product page, where used leaves the page out, and `get_where_used` without a target is refused. The full suite ran locally against a `postgres:17` container as well as SQLite. 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
This was referenced Oct 19, 2026
Closed
Merged
pournasserian
added a commit
that referenced
this pull request
Oct 19, 2026
Closes #2534 ## What The F-ENT (#2417) entity tools (MCP-01, P1). Agents read facts, relations, unsourced facts and the blast radius, and propose one fact with its source, or a relation, in the write-tool conventions of #2514. - **`EntityTools`** (`FluentCMS.Core.Builtin.Tools`): - Reads: `get_entity_facts` (source, owner, validity and the unsourced flag, for the latest draft or a version), `get_entity_relations` (both directions), `list_unsourced_facts` (the site or one entity type) and `get_blast_radius` (the published entries a set's entity changes affect, with old and new values per path). - `set_entity_fact` proposes one fact with its value, source, owner and validity, and keeps the other facts. A property left out is stored as null, so a fact without a source is flagged unsourced. - `relate_entities` proposes a relation. It sets a single reference field, or adds to a list of references without duplicates, and keeps the field's metadata. A field that is not a reference field is refused with the new `field_not_relation`. A plain entry is refused with `entry_not_entity`. - Both writes read the version given as `base_version`, change it in a copy, and propose an update from the published version, or a create while the entity was never published, as `update_entry` does. - **`ProposedEntryWrite`**: the propose flow `EntryTools` used, moved into a shared helper with the update rule and the hints, so entry and entity writes commit a version and its operation the same way. - **`ToolArgumentsReader`** gains `RequiredValue`, for an argument that can be any JSON value but null (a fact's value), and `OptionalObject`. - `ToolJson` renders facts, flags, relations and the blast radius. The ENT rows of `McpCoverageTests` list the new tools; stale facts stay open on #2417. ## Why S1's pipeline agent changes one fact per product, with the export as its source. With `update_entry` alone it would have to resend each whole entity. `set_entity_fact` and `relate_entities` change only the fact or relation they name, and go through the same validation, reference checks, fact index and change set as any entry write. The reads make the earlier F-ENT slices (#2528, #2530, #2533) reachable over MCP. Choices worth a look: - **Left-out properties are null, not kept**: a new value usually needs a new source and validity, so keeping the old ones would make a stale source look current. Leaving out a source makes the fact show as unsourced. - **A list relation is appended, never replaced**: removing one is an `update_entry`. - **Stale facts** come with ENT-03, which waits for the job queue (#2531). ## Tests Unit (Core): - `EntityToolsConformanceTests`; `McpCoverageTests` with the new rows. Integration over MCP, each on SQLite and PostgreSQL: - S1: two prices set from the export in one session's set; the blast radius lists exactly the two products' published pages with the old and new price, not the third product's page. The fact reads back sourced. - A fact set without a source reads back flagged and is listed by `list_unsourced_facts`. - `relate_entities` adds a spec and sets a category; `get_entity_relations` shows both outgoing, and the spec shows the product incoming. - A base version that does not exist, a field the type lacks (with a hint to the schema document), a plain entry, a field that is not a reference (with a hint to the content type) and a related entity of the wrong type are all refused, and nothing is stored. The full suite ran locally against a `postgres:17` container as well as SQLite. 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
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 #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.
open_change_set(rationale, sources),submit_change_set(open to checks running) andreopen_change_set(checks failed to open), for the agent's own sets only (Refuse agents' review moves and enforce change set caps #2503).create_content_type(name, slug, definition) andrevise_content_type(slug, base version, definition).change_set_idfromopen_change_set, or asession_idwith a rationale and sources, which opens the session's set on its first call (CHG-02).nextsentence that says what comes next.IChangeSetDirectory.GetSessionSetAsyncreads the caller's open set for a session id.IAgentPermissions.CheckChangeSetSizeAsynctakes the number of operations a write is about to add.ToolArgumentsReaderreads lists of strings, objects, optional ids and required whole numbers.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:
IAgentPermissionswith 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.GetSessionSetAsyncis a small read on the change set directory, the one place that knows a session's open set. The pre-check needs it.Tests
Integration, over HTTP:
category, revises a person'sproductfrom 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.session_idland in one set with the first call's rationale.agent.items_per_change_set_exceededstays in the logversion_conflictwith a hint to read the current version, and the type stays at version 2.The full suite ran locally against a
postgres:17container as well as SQLite, and markdownlint passes. No new package and no new project reference.Checklist
v2