Skip to content

Put media in change sets and add the media tools - #2546

Merged
pournasserian merged 16 commits into
v2from
2544-media-change-sets
Oct 19, 2026
Merged

pournasserian merged 16 commits into
v2from
2544-media-change-sets

Conversation

@pournasserian

@pournasserian pournasserian commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

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

  • 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 19, 2026
@pournasserian pournasserian added type:feature New capability v2 FluentCMS v2 (v2 branch) area:media Media library (MED) labels Oct 19, 2026
# Conflicts:
#	CONTRIBUTING.md
#	src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs
#	src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs
# Conflicts:
#	tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs
# Conflicts:
#	tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs
pournasserian added a commit that referenced this pull request Oct 19, 2026
Closes #2595

## What

The media library screen of F-MED (#2427), MED-01's UI: upload, browse,
organize and where used. It runs on the merged `IMediaLibrary` (#2543)
and `IWhereUsed.ForMediaAsync` (#2494), and uses the desk's layout and
conventions (#2422).

- **Host** (`Media/MediaEndpoints.cs`, next to the audit export
endpoint):
- `POST /media/upload` takes a multipart form with the anti-forgery
token. It calls `UploadAsync` and redirects to the new asset with
`?uploaded=true`.
- A refused upload redirects back to `/media` with the error codes and
the typed fields, as the audit export does.
- The request size limit is `Media__MaxBytes` plus 1 MB for the form, so
the library's own limit decides first.
- A form with only a file binds no fields, and the endpoint treats that
as empty metadata.
- `GET /media/{id}/file?version=n` streams the bytes to a signed-in
person. Images are inline for the previews, and every other type is an
attachment. Each response has `X-Content-Type-Options: nosniff` and
`Cache-Control: private, no-store`. The library already refuses types a
browser runs as a document (`MediaTypes`).
- **Admin**:
- `MediaUploadForm`, `MediaMetadataForm` and their shared
`MediaMetadataFields`. Tags are typed comma-separated, and a blank alt
text or caption means none.
  - **`/media`**:
    - Folder links with subfolders, and tag links.
- An upload card that shows each refused field's problem next to it and
keeps what was typed.
- The assets newest first. Each row has a decorative image preview, the
file name linked to the asset, type, folder, tags, size, Published, and
No alt text for an image without it (MED-02). The rows rove like the
desk's.
  - **`/media/{id}`**:
- The preview with the asset's alt text, its version and whether it is
published, and a download link.
- A details form that saves the next version from the version the page
showed. A form from an older version is refused with the current details
shown.
- Where used: each entry version that references the asset, with its
paths.
  - A Media link in the header.
- **Accessibility**: the axe WCAG 2.1 AA check from #2582 now covers
`/media` and an asset page in both directions and themes.

## Why

- **Endpoints for files, pages for everything else.** A Razor page
cannot read a multipart upload or answer with bytes, so the host takes
those two requests, as it does for the audit export. The pages stay
static and post ordinary forms.
- **Upload calls the library directly, for now.** #2546 puts uploads and
metadata changes into change sets with the uploader as initiator. Until
it lands, the screen does what the merged library offers. When it lands,
the endpoint's `UploadAsync` and the page's `UpdateAsync` pass the
person's change set target, like the other writes.
- **Previews are the image itself, scaled by CSS.** Server-side
thumbnails are image processing, which F-MED leaves out of M1.
- **No paging yet.** `IMediaLibrary.ListAsync` returns every match. The
table is fine at M1's scale, and paging belongs in the library's query
once one is needed.
- **Not in this PR**: change set operations for uploads (#2546), soft
delete and restore (#2553), and attaching an asset to a field from the
library (generated forms, F-FRM #2426).

## Tests

bUnit (`MediaViewsTests`):
- The library lists newest first, with previews only for images,
decorative preview alt text, No alt text and Published badges, folder
and tag links, the current filter and roving rows. A folder in the
address becomes the query.
- The upload form posts multipart to the endpoint and accepts the
allowed types. A refused upload shows its problems next to the fields
and keeps the typed values.
- The asset page shows the preview with its alt text, the details form
at the latest version and where it is used. An image without alt text
says so.
- Tags split on commas, a refused upload returns its codes and fields,
and blank texts mean none. Logical utilities only.

Integration, host:
- An upload lands on the asset. Its bytes come back with the declared
type, inline and `nosniff`, and an unknown asset's file is 404.
- A bad folder, bytes that do not match the declared type, and a post
with no file are each sent back with their codes and shown next to their
fields, and nothing is stored.
- The library filters by folder, subfolders and tag, newest first, and
marks the one image without alt text.
- Saving details stores version 2, a form from version 1 is refused with
the current details, and an invalid tag is reported.
- Where used lists the product page that references the asset, and an
unused asset says so.
- Anonymous requests to the pages and the file go to sign-in, and an
upload without the anti-forgery token answers 400 and stores nothing.

Browser: the axe check passes on `/media` and an asset page.

`dotnet build -warnaserror` and the full suite with the browser tests
pass locally against a `postgres:17` container as well as SQLite. 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
# Conflicts:
#	CONTRIBUTING.md
#	src/FluentCMS.Core/Builtin/Tools/ToolJson.cs
#	src/FluentCMS.Core/Media/IMediaLibrary.cs
#	src/FluentCMS.Core/Tools/ToolArgumentsReader.cs
#	src/FluentCMS.Infrastructure/Media/MediaAssetRows.cs
#	src/FluentCMS.Infrastructure/Media/MediaLibrary.cs
#	tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs
#	tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs
#	tests/FluentCMS.Tests/Tools/McpCoverageTests.cs
#	tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs
@pournasserian
pournasserian merged commit eda58a1 into v2 Oct 19, 2026
2 checks passed
@pournasserian
pournasserian deleted the 2544-media-change-sets branch October 19, 2026 11:20
pournasserian added a commit that referenced this pull request Oct 26, 2026
Closes #2548

Built on media in change sets (#2546) and the check engine on change
sets, both merged.

## What

This is the next F-MED (#2427) slice. The built-in `altText` check
(#2535) now also checks media fields, so S2's image without alt text
fails the check with a finding that names the field (CHK-01, MED-02).

- **`AltTextCheckType`**: when the subject has a content type, each
media field, and each item of a list of media, is checked against the
version of its image that readers will see:
- when the subject's change set holds the image, the live version once
that set publishes what it proposes;
  - otherwise, the live version now.

An image with no live version, one that only a change set proposes,
fails with its own finding outside that set. Whatever version another
set publishes later, nothing checks this content against it.

An image whose version has no alt text is a finding. The finding points
at the field (`/hero`, `/gallery/1`, or `/photo/value` on an entity),
names the file, and says to set alt text with `update_media`. The
asset's id is the excerpt. Media that is not an image, such as a PDF,
needs no alt text.
- **`IMediaLibrary.LiveVersionAsync(id, publishing)`**: the version
readers see. That is the newest version a person wrote in the library
after the published one, since library writes take effect at once;
otherwise it is the published version. `publishing` names a version a
set will publish, to ask what readers see once it has. A version that
any change set has proposed counts only once its set publishes it.
- **`ProposedMediaVersions`** (new table, with migrations for SQLite and
PostgreSQL): every media version a change set proposes, kept when the
set later moves on to a newer version. This is how the library tells a
person's write from a set's. The version rows cannot record it
themselves, since a trigger keeps them unchanged. The change set
directory adds a row whenever it records a media operation or extends
one. The migration fills the table from the operations stored today, and
its `Down` drops the table.
- **`CheckSubject.SetOperations`** (optional): the operations of the set
the content goes live with, so a check can see what that set proposes.
`ChangeSetChecks` passes the set's operations when it runs its checks,
so the set is read once per run, not once per item.
- **`check_draft`** takes an optional `change_set_id`. An agent can then
check a draft against the media versions its set proposes, such as alt
text it just added with `update_media`. It refuses a set the agent may
not read and one that is published, rejected or reverted.
- **`ChangeSet`**: a newer version of an asset the set already proposes
moves its operation on, as for entries. So `update_media` after
`upload_media` in one set, which is S2, extends the upload instead of
being refused as a duplicate. A media duplicate now says which version
to update from, as an entry's does.
- **`MediaFields.Holds`** decides what counts as a media field.
`MediaTools` and the check share it.

## Why

The check already covered images in text, and the comment in it left
assets of the library to F-MED. What goes live is what counts:
- A published image without alt text fails even while a newer version
with alt text waits in another set, or in a rejected one. That holds
even when the set has moved on to a later version since. It passes once
the page goes live in the set that adds the alt text.
- A person who uploads or describes an image in the admin library
changes it at once, without a change set. So their alt text counts on
every page right away, including pages in a set that holds the image,
and a later version a set proposes does not hide it.

An asset the library does not hold is no finding. Entry writes already
refuse such references, and check examples name made-up ids.

Choices worth a look:

- **The check needs `ProposedMediaVersions` to read the version that
goes live.** A person's library write is live at once, while a set's
version is live only when the set publishes it. The operation rows keep
only the last version a set proposes. Without the table, a version the
set moved past would pass for a library write, as the fourth review
showed. Nothing else reads the table.
- **A table of proposed versions, not a column on the version rows.**
Versions are immutable by trigger, and when the library writes a version
it does not yet know whether a set will propose it. The set directory
does know, at the moment it records the operation.
- **The migration's backfill knows only the versions sets propose now.**
A version a set moved past before this migration is unknown, so it reads
as a library write. v2 has no release yet, so this affects development
data only.

## Tests

Integration tests, each on SQLite and PostgreSQL:

- A page whose hero and second gallery item name an image without alt
text fails with findings at `/hero` and `/gallery/1`, naming the file.
The gallery item with alt text passes.
- A published image without alt text fails on an entity at
`/photo/value`. Once a version with alt text is published, the page and
the entity pass. An unknown asset passes.
- Version 1 of an image is published without alt text, and version 2
with alt text waits in a set. A page fails with no set and with another
set, and passes with the set that holds version 2.
- An image that only change sets propose, whose latest version has alt
text, fails with no set and with another set. It passes with the set
that holds it, and fails again once that set is rejected.
- A person's library writes count at once. An upload without alt text
fails with the alt-text finding. Once the person describes it in the
library, the page passes with no set and with another set. A later
version without alt text that a set proposes fails only with that set.
Content outside the set still passes while the set is pending, and after
it is rejected.
- A library write older than the published version no longer counts.
- A PDF in a media field passes.
- Over MCP, versions a set moved past:
- An agent describes a published image without alt text and then
captions it in the same set. It also uploads a new image with alt text
and captions it there.
- Outside the set, the published image still fails with the alt-text
finding, and the new one fails as not published. Both hold while the set
is pending and after it is rejected.
  - Inside the set, the published image passes.
- Over MCP, a person describes in the library an image that an agent's
set uploaded and attached. `check_draft` with the set turns from fail to
pass. The set's own checks pass when it is submitted and published.
Outside the set afterwards, the page passes on the person's version,
while the published version stays 1.
- Over MCP, S2 in one set: an agent uploads an image without alt text
and attaches it in one session. `check_draft` fails at `/hero`.
`update_media` adds alt text in the same session and extends the upload
to version 2. `check_draft` then passes, and so does the set's own
alt-text check when it is submitted.
- Over MCP, an entity: a product's media fact fails at `/photo/value`
and passes once `update_media` describes the photo in the same set.
- Over MCP, `check_draft` refuses an unknown change set and a rejected
one.
- Over MCP, with a published image:
  1. A published image is attached to a page in the agent's set.
  2. `check_draft` with that `change_set_id` fails at `/hero`.
  3. `update_media` adds alt text in the same set.
  4. `check_draft` now passes.
  5. Submitting the set runs its checks, and alt-text passes there too.

Unit tests:

- A media operation, like an entry's, moves on to a newer version from
the same base. A stale media operation is a duplicate that names the
version to update from.
- The check never throws, whatever a media field holds (null, numbers,
non-ids, objects, mixed lists, on pages and entity facts), and whatever
the set names: no set, an upload, or a delete followed by a create of
the same asset.

The existing unit tests of the check, conformance included, pass
unchanged, and so do the `check_draft` tests. The migration tests apply
and revert the new migration on both providers. The full suite ran
locally against a `postgres:17` container as well as SQLite. There is no
new package or project reference.

## Review

The media stack review found a High problem: the check read the asset's
latest version rather than the one that goes live. That is fixed above.
Its Low findings are fixed too: only images need alt text, and the
acceptance is tested through `update_media`.

A second review blocked again, for two reasons:
- **High**: S2 could not finish in one set, because `update_media` after
`upload_media` was refused as a duplicate.
- **Medium**: an image never published and outside the set was still
read at its latest version.

Both are fixed above, together with its Low findings: `check_draft`
validates `change_set_id`, the entity half is tested through
`update_media`, and a stale comment is gone.

A third review blocked on a **High** problem: an image a person uploads
or describes in the admin library never goes through a change set. So it
was never published, and content using it could never pass. The check
now reads the live version, as described above. Its Low findings are
fixed too:
- the media duplicate message;
- the set is read once per run;
- a stale test comment is gone;
- there is a never-throws test for media values;
- the scope text of #2548 is updated.

A fourth review blocked on two problems in that fix:
- **High**: a version a set had moved past within the same set was taken
for a library write. Its alt text then counted outside the set, while
pending and after rejection. `ProposedMediaVersions` now keeps those
versions.
- **Medium**: inside its own set, the check ignored a person's later alt
text in the library. The check now asks for the live version once the
set publishes.

A fifth review gave MERGE, with three Low findings:
- a reverted upload stays live through a later library write: #2663;
- a library save can carry a rejected set's metadata live, which is
existing behaviour of the library view: #2664;
- undoing and redoing the migration forgets the versions sets had moved
past. That is the backfill limit described above, and v2 has no release
yet.

## 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 26, 2026
Closes #2553

Built on media storage (#2543), media in change sets (#2546) and the alt
text check (#2549), all merged.

## What

The last slice of F-MED (#2427): assets are soft deleted and can be
restored within the retention window, as entries are (#2502, CNT-06,
REL-03).

- **Core** (`IMediaLibrary`):
  - `DeleteAsync(id, baseVersion)` and `RestoreAsync(id)`.
- `ListDeletedAsync()`, which returns each `DeletedAsset` with when it
was deleted and until when it can be restored, most recent first.
  - New codes `asset_not_deleted` and `retention_expired`.
- **Gone from every read**: a deleted asset is not found by get,
versions, the live version, open or metadata updates, and it leaves
lists and folders. An entry write that references it fails with
`reference_not_found`. A change set operation pointing at it cannot be
added, and a set that already holds one fails to publish until the asset
is restored, as for deleted entries.
- **Restore** clears the deletion while `DeletedAt +
Content__DeletedRetention` is still in the future. Versions, bytes and
the published pointer are never touched, so the asset comes back exactly
as it was.
- **Permissions**: delete and restore take the delete operation of an
agent's scope (MCP-05).
- **Infrastructure**:
  - New column `MediaAssets.DeletedAt`.
- Delete and restore are conditional updates, so concurrent calls get
`version_conflict` or `asset_not_deleted` instead of both succeeding.
- Events `media.deleted` (with the latest version's row as previous
version) and `media.restored`, appended in the same unit of work.
  - Migrations for SQLite and PostgreSQL.

## Why

CNT-06 and REL-03 require that nothing is hard-deleted inside the
window. Media follows the entry design so that a person restores both
the same way.

Choices worth a look:

- **No purge, and no tool**, as for entries. A purge needs a decision on
what it removes, then a job. Deleting stays a person's action.
`delete_entry` and media delete operations in change sets only
unpublish.
- **The `where_used` tool still lists the pages that show a deleted
asset**, so an agent can say what the delete affects. The admin asset
page shows a deleted asset as not found, like every other read.
- **The media library screen is not in this epic's PRs**: it depends on
the desk's layout (F-DSK).

## Tests

Integration tests, each on SQLite and PostgreSQL:

- **Deleted asset is gone**: it is not found by get, version, open or
update. It leaves the list and the folders. A product whose hero names
it fails with `reference_not_found`. `media.deleted` names it and its
version row.
- **Restore inside the window**: a restore one minute before the window
closes brings back version 2 with its alt text, published version 1 and
its bytes. It empties the deleted list and appends `media.restored`.
- **Restore after the window**: at the end of the window, restore fails
with `retention_expired` and appends nothing.
- **Failures**: each of these fails with its own code:
  - a stale base version;
  - restoring an asset that is not deleted;
  - unknown assets;
  - a second delete.
- **Deleted list**: it is ordered by deletion time and says until when
each asset can be restored.
- **Approved set**: a set whose media create was approved fails to
publish once the asset is deleted, and a new set cannot add it. After a
restore, the set publishes.
- **Agent scope**: an agent without the delete operation is refused
delete and restore, and one with it can delete.

The full suite ran locally against a `postgres:17` container as well as
SQLite. The migration was regenerated on top of `ProposedMediaVersions`
(#2549). The migration tests apply and revert it on SQLite and
PostgreSQL. No new package and no new project reference.

## Review

An independent review gave MERGE, with Low findings:
- content that shows a deleted image passes the alt text check and can
publish, as content that references a deleted entry can today: #2665;
- media permissions let an engine write assets, where content
permissions refuse one; this already exists on v2: #2666;
- the live version of a deleted asset was untested; the deleted-asset
test now covers it, and this description is updated.

## 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:media Media library (MED) type:feature New capability v2 FluentCMS v2 (v2 branch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Put media in change sets and add the media tools

1 participant