---
title: "ADR-195: The ninth writer sets a page's social image, and stays out of the first's allow-list"
manual: "TYPO3 LLM Extension"
version: "main"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-195@main"
source: "Adr/Adr195TheNinthWriterSetsAPageSocialImage.rst"
rendered: "2026-09-24T12:49:43+00:00"
---

# ADR-195: The ninth writer sets a page's social image, and stays out of the first's allow-list {#adr-195}

-   *Status:* Accepted
-   *Date:* 2026-09-22
-   *Amends:* [ADR-135](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-135@main) (two of its three excluded FAL fields get a writer of their own)
-   *Authors:* Netresearch DTT GmbH

## Context {#adr-195-context}

[ADR-135](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-135@main) kept `og_image`, `twitter_image` and `media` out
of `update_page_metadata` because they are FAL relations —
`sys_file_reference` rows written through the DataHandler's relation
handling, a different risk class than setting a scalar. That reasoning stands.
It also left the social preview image of a page with no writer at all: an
assistant asked to prepare a page for sharing can set its Open Graph title and
description and must then tell the editor to pick the image by hand.

The two columns are EXT:seo's. In `typo3/cms-seo` 14.3.7
(`Configuration/TCA/Overrides/pages.php`) each is `'type' => 'file'` with
`'allowed' => 'common-image-types'`, `'exclude' => true` and
`allowLanguageSynchronization`; the same declaration is in every 13.4 and 14
copy read for this record (13.4.21 to 14.3.7). Without the extension the
columns do not exist.

The obvious moves are to widen `update_page_metadata` or to widen
`attach_file_to_content_element` (the seventh writer, `#865`; it has no
record of its own, and [ADR-184](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-184@main) describes it as the tool that
appends a file reference to a content element's file fields). This record
says why neither is taken, and what the ninth writing tool does instead.

## Decision {#adr-195-decision}

`set_page_social_image` is the ninth writing tool, on the terms of the
previous eight: disabled by default, in the `editing` group, an explicit
`ToolEffect`, a human approval before every call
([ADR-134](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-134@main)), a preview at suspend ([ADR-136](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-136@main))
that is a pure function of the arguments and the current state
([ADR-184](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-184@main)), a write through the `DataHandler` under the
acting user's permissions ([ADR-083](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-083@main)), a read-after-write
verification, an editor-action declaration ([ADR-152](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-152@main)) on
`pages`, and a refusal vocabulary that never confirms a uid exists. It uses
the [ADR-146](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-146@main) `plan()` shape.

It takes a page uid, a `field` that is exactly `og_image` or
`twitter_image`, and a `sys_file` uid, and it creates exactly one
`sys_file_reference` on that page and field. It never uploads, moves or
renames a file.

## A ninth tool rather than a wider first or seventh {#adr-195-narrow}

`update_page_metadata` sets scalars; every entry in its allow-list is an
`input` or `text` column and its whole refusal vocabulary is about field
names and lengths. A relation in that list would give one tool two datamap
shapes and two read-backs, and the approval card of "set the description" would
have to be read for whether it also deletes a reference. The ADR-135 exclusion
is kept, and its sentence is narrowed to say where the two image fields went.

`attach_file_to_content_element` is about content elements by name, by
arguments (`element`, `field`) and by its editor-action declaration on
`tt_content`. Appending is its contract: it adds a file to a list and never
removes one. A social preview is not a list, and an appending writer on a page
field would hand the "which image wins" question to whoever renders the page.

So the two writers that touch `sys_file_reference` are disjoint by the
parent table, as the two `sys_file_metadata` writers are disjoint by field
([ADR-192](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-192@main)), and the newer one names the older on the wire, so
a model told "not here" is not left to guess where. `media` stays without a
writer: it is a list by design and nothing renders it as a single image.

## One reference per field is this tool's rule, not the TCA's {#adr-195-one-reference}

The brief for this tool assumed that `typo3/cms-seo` declares `maxitems` on
the two columns. It does not, in any version read: the DataHandler accepts a
list of references there, and EXT:seo's `MetaTagGenerator` renders every
`og_image` reference as its own `og:image` (the Open Graph manager allows
multiple occurrences) while `twitter:image` is rendered once.

The tool nevertheless keeps each field at **at most one live default-language
reference**, and that is a decision recorded here rather than a constraint read
from the TCA. A social preview is one image, the Open Graph protocol gives the
first of several `og:image` tags preference, and a card that says "adds a
second image" asks the approver to know which of two a platform will pick. The
tool therefore does not read `maxitems` at all: a check that can never
change what happens when the field always ends at exactly one reference would
be a declaration nothing reads. An installation that declares `maxitems`
itself is satisfied by construction for any value of one or more.

## `replace` is explicit and destructive, and says so {#adr-195-replace}

When the field already holds a reference the call is refused and the refusal
names the file referenced now. `"replace": true` is the only way past it,
and it **deletes** that reference through the DataHandler — recoverably
(`deleted = 1`) and in `sys_log` — in the same run that creates the new
one. This mirrors `create_translation_draft`'s `overwrite`
([ADR-146](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-146@main)): the destructive step is a word the approver reads
on the card, never a default, and the preview gives it a line of its own.

The alternative — replacing silently, because "set the image" plainly means
the new one should show — was rejected for the reason the refusal vocabulary
exists: a call that discards something must say what it discards, and a model
that did not know an image was there must hear that one is.

The effect is `ToolEffect::NON_IDEMPOTENT_WRITE`. Without `replace` a
second run refuses, so a reaped run that already succeeded would report
failure for a write that happened; with it, a second run discards a reference
an editor may have set between the two attempts. Neither is a repeat an
at-least-once runtime may perform on its own.

## The page row travels with the reference, in one DataHandler run {#adr-195-one-run}

Three facts about core decided the datamap's shape, and all three were read
in `typo3/cms-core` 14.3.7 rather than assumed.

**The permission the DataHandler asks depends on what else is in the
datamap.** `DataHandler::hasPermissionToInsert()`,
`DataHandler::hasPermissionToUpdate()` and
`DataHandler::deleteRecord()` check a `sys_file_reference` row against
`PAGE_EDIT` when the datamap also carries a `pages` row, and against
`CONTENT_EDIT` otherwise. `PAGE_EDIT` is the right an editor of page
properties holds and the one `update_page_metadata` authorises against;
`CONTENT_EDIT` is about the page's content. The tool therefore checks
`PAGE_EDIT` itself, writes the page row and the reference row in one
datamap, deletes the replaced reference through the cmdmap of the **same**
run, and — when the DataHandler refuses part of the write — takes the orphan
back in one run that again carries the page row. Written as separate runs, the
delete would need a right the user who just passed the tool's own check does
not necessarily hold, and the orphan would stay. The functional test grants
the editors `PAGE_EDIT` without `CONTENT_EDIT` for exactly that reason.

**The field-level grant is asked before the write.** Both columns are
`exclude` fields. For a user without the `non_exclude_fields` grant the
DataHandler creates the reference row and drops the page's side of the
relation in silence, with an empty `errorLog` — a reference EXT:seo never
renders, because `MetaTagGenerator` reads the page's counter before it
looks for rows. The grant is asked through the same method the DataHandler
asks it with (`BackendUserAuthentication::check('non_exclude_fields', …)`,
as [ADR-192](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-192@main) does), and the whole call is refused before
anything is written. The read-back stays as the backstop, and when it fails
it **takes the write back**. The row on the named page, field and file and a
page counter of one are checked before the replaced references are deleted,
so the page can be put back as the call found it: the new reference is
deleted and the previous ones, still live, are written back into the page's
field. That the field holds exactly one live default-language reference is
checked after the cmdmap, where whatever else is live is a previous reference
the delete did not remove; the new one is taken back the same way, and of the
list written back the DataHandler relates only the rows still live (measured
on the fixture with a partly removed list). The first version reported the
mismatch and left the new row in place, arguing that a repair would have to
guess which of two rows to keep — it does not, because the plan names the
rows that were there.

**What the put-back guarantees, and on which page.** The default-language
page: after the put-back the tool reads it again and reports from what it
holds, not from what the DataHandler said — the new reference and the copies
core minted of it are no longer live, and the page counts exactly the
default-language references that are. Where that is not the case the message
says what is still there, never "left as it was". The translation is not
verified. Writing the field back makes core's `DataMapProcessor`
synchronise every parent-following translation, and where the translation no
longer holds a copy of a survivor — an administrator's dropped run deletes
those copies; an editor's cannot, because the delete is checked against
`CONTENT_EDIT` without the page in that run's datamap — core issues a
`localize` command for it. That command needs the translation's language on
the page's site and refuses for a deleted record, and when it refuses core
throws out of the datamap (`RuntimeException` 1486233164; the first version
of the put-back let it escape with the new reference still live). Each half
of the put-back therefore runs under its own guard, the delete runs whatever
the datamap did, and whatever core threw or logged is named in the message.
Measured on the fixture: an editor's failed replace on a translated page
leaves both pages as they were; an administrator's re-mints the translation's
copies where the site declares the language, and where it does not the
translation is left counting references it no longer holds, which the message
reports and the tool does not repair.

**The counter's limit is this tool's steady state, not an edge.** The tool
leaves every field it sets at exactly one reference, so every replace on a
field it set before has a previous count of one, and a page side dropped on
such a replace leaves the counter at 1 — the read-back cannot tell that stale
count from the new one. On a translated page the second signal catches it: a
parent-following translation gets a copy of the new reference in the same
run, moved onto the translation's uid when the translation's page row is
written, so a copy still on the default-language page means the translation's
side was dropped too — the cmdmap would then delete the copy the translation
still holds and leave it counting an image it does not have. The read-back
treats such a copy as a mismatch and both pages go back to what they held. On
a page without translations no copy is minted, and there a dropped side at a
previous count of one stays undetectable: the run ends with the new row as
the single live, counted reference, which is the state asked for. The only
stricter signal would be the DataHandler's own history of the page row, which
the tool does not read.

Whether a column is subject to that grant, and whether it is dropped for a
different reason, is decided with the DataHandler's own predicates
(`DataHandler::fillInFieldArray()`, `typo3/cms-core` 14.3.7 lines
1118–1123; `DataHandler::getExcludeListArray()` in 13.4.21 is the same
pair). Core reads the flag as `(bool)($config['exclude'] ?? false)`
(`AbstractFieldType::supportsAccessControl()`), so an installation's
`'exclude' => 1` puts the column under the grant, and it skips a column
whose `displayCond` is exactly the string `HIDE_FOR_NON_ADMINS` for every
non-admin, grant or no grant — also in silence. The first version of the tool
tested `=== true` and did not know the second shape; a review measured both
against the running DataHandler and found the reference row created and the
page's side dropped, with the pre-check passed. Both predicates are now
mirrored, and the second refusal names the display condition rather than a
grant the editor holds. The TCA is read from `$GLOBALS['TCA']` as the
sibling writers read it ([ADR-192](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-192@main)): core compiles its schema
from that array, so both sides see the same shape without a second dependency
on the schema factory.

**A translation that follows its parent gets its own copy, on its own uid.**
Both columns declare `allowLanguageSynchronization`, and a translated page in
the `parent` state is synchronised by core's `DataMapProcessor`: the
write mints a localized reference with `uid_foreign` set to the translated
page's uid and `l10n_parent` set to the new row, and the cmdmap delete of the
replaced default-language row deletes its localization with it. Measured, not
read: the probe that established it is the reason the tool's queries pin
`uid_foreign` to the page and its read-back accepts exactly one row. The tool
writes default-language pages only and refuses a translation, naming its
default-language page — whether a translation follows its parent or carries
its own image is a page-properties decision the editor makes, not the tool.

**A translation is saved with the page, in whatever language it is in.**
`DataMapProcessor::finishTranslationItem()` (`typo3/cms-core` 14.3.7
line 417; 13.4.26 line 375) puts every live translation of the page into the
datamap with its `l10n_state` — hidden ones too, and whether or not the
field being written is in the `parent` state — and
`DataHandler::process_datamap()` then checks that record against the
acting user's `allowed_languages` (14.3.7 line 898 through
`checkRecordEditAccess()`; 13.4.26 lines 895–896 through
`recordEditAccessInternals()`) and refuses it: "Language was not allowed".
The default-language row itself was writable, so an editor allowed the
default language alone is refused on every page that has a translation —
after the reference row is written, at the cost of a discard and two error
rows in `sys_log`, and with a preview that showed nothing wrong, because the
preview never reaches the DataHandler. Measured on the fixture: the plain and
the replace call both came back with that message and nothing written.

The decision is to refuse **before** the write, in `plan()`, when the
page has a live translation in a language the acting user may not edit. The
refusal names the translation, its language and the synchronisation that is
the cause, and the approval card shows it because `plan()` is the
preview's too. Two alternatives were rejected. Accepting the DataHandler's
per-record independence and reporting the default-language write as done would
leave the translation without its synchronised copy of the reference, behind
a parent it is declared to follow. Restricting the pre-check to translations
whose field is in the `parent` state would refuse less, but core does not
make that distinction — the `l10n_state` record is written for every
translation — so the pre-check would let through exactly the calls the
DataHandler still refuses.

**A translation carries its own permission bits, and the same run asks
them.** The DataHandler checks `PAGE_EDIT` on every translation it saves
with the page (`DataHandler::hasPermissionToUpdate()` for `pages`,
14.3.7 line 7470; `checkRecordUpdateAccess()` in 13.4.26 asks the same
bit) against the translation's own `perms_*` columns. Those can differ from
the parent's: core creates a translation as a new page row
(`DataHandler::localizePage()`, 14.3.7 lines 5034–5063), which takes the
new-page permission defaults, and nothing synchronises `perms_*` from the
parent afterwards — `pages` declares no TCA column for them. An editor who
holds `PAGE_EDIT` on the page but not on its translation was therefore
refused after the reference row existed, and the put-back's own datamap made
core add the translation again, so the message carried the same complaint
twice. A review measured it on the fixture; the functional test pins it with
the translation's group and everybody bits at `PAGE_SHOW`, because the
fixture grants everybody `ALL` and `calcPerms()` ORs the two. The
pre-check above asks
`doesUserHaveAccess()` with `PAGE_EDIT` on each translation — the call
`plan()` already makes for the page — and refuses naming the translation,
with the language check first where both would fire.

## Without EXT:seo {#adr-195-availability}

The repository has no per-tool availability hook — `ToolInterface` has no
conditional member and `ToolAvailabilityServiceInterface` reports enable
state — so a tool whose columns are absent cannot withdraw itself from the
catalogue. It does what `create_content_element_draft` does with content
types: the `field` enum on the wire follows the live TCA, falls back to both
names rather than to an empty enum a model cannot satisfy, and a call names
EXT:seo in its refusal so the model stops retrying a field this installation
does not have.

## Consequences {#adr-195-consequences}

✓ Nine editorial writes are available where eight were, and a page's social
preview image is writable at all.

✓ No writer on `sys_file_reference` shares a parent table with another, and
no field of `pages` has two writers, so every approval card answers for
exactly one tool.

✓ The ADR-135 exclusion keeps its reasoning; its sentence now says where two
of the three fields went.

✕ Preparing a page for sharing costs one approval per image plus one for the
texts, because the texts are `update_page_metadata`'s. That is the trade
[ADR-180](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-180@main) and [ADR-192](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-192@main) made — one card, one
thing.

✕ A model that wants a second `og:image` — which the protocol permits and
EXT:seo would render — cannot get one from this tool.

✕ A non-admin who may edit the page but holds no `non_exclude_fields` grant
for the column is refused outright rather than told which administrator to
ask; the refusal names the grant, and that is as far as a tool can go.

✕ An editor whose `allowed_languages` leaves out a language the page is
translated into, or who holds `PAGE_EDIT` on the page but not on one of its
translations, cannot set the image on that page, although the
default-language row is theirs to edit: core saves the translation with the
page. The refusal names the translation, and the language where that is the
cause; an editor allowed that language and that translation, or an
administrator, can.

## Revisit when {#adr-195-revisit}

A writer for `media`, or for any page relation that IS a list, is proposed:
then the appending contract of `attach_file_to_content_element` is the shape
to generalise, and the single-image rule above must not be copied.

Also revisit if `typo3/cms-seo` ever declares `maxitems` on these columns:
the rule this record chose would then be the TCA's, and the tool should read
it rather than restate it.
