Skip to content

Check alt text of the assets media fields name - #2549

Merged
pournasserian merged 28 commits into
v2from
2548-media-alt-text
Oct 26, 2026
Merged

pournasserian merged 28 commits into
v2from
2548-media-alt-text

Conversation

@pournasserian

@pournasserian pournasserian commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

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 Check alt text of media fields #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:

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
@pournasserian pournasserian mentioned this pull request Oct 19, 2026
4 tasks done
# 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
# 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
# Conflicts:
#	src/FluentCMS.Core/Builtin/Tools/MediaTools.cs
#	tests/FluentCMS.IntegrationTests/Mcp/MediaToolsTests.cs
@pournasserian
pournasserian merged commit 77f7476 into v2 Oct 26, 2026
2 checks passed
@pournasserian
pournasserian deleted the 2548-media-alt-text branch October 26, 2026 01:02
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.

Check alt text of media fields

1 participant