.. SPDX-License-Identifier: CC-BY-4.0 SPDX-FileCopyrightText: Netresearch DTT GmbH .. include:: /Includes.rst.txt .. _adr-274: ====================================================================== ADR-274: The replacing writers replace a reference in the workspace ====================================================================== :Status: Accepted :Date: 2026-11-08 :Amends: :ref:`ADR-264 ` (its refusal of a draft workspace before the card for ``set_page_social_image`` and ``replace_file_reference``, the consequence that both write in live only, and the consequence that ``attach_file_to_record`` asks the inline owners live) and :ref:`ADR-262 ` (its consequence that ``set_file_reference_alternative_text`` asks the inline owners live) :Authors: Netresearch DTT GmbH Context ======= :ref:`ADR-264 ` let the writers that add a file reference write it in the draft workspace a run was started in, and kept the two writers that REPLACE one refusing a draft workspace before the approval card: ``set_page_social_image`` sets ``pages.og_image`` or ``pages.twitter_image``, deleting the image there with ``replace``; ``replace_file_reference`` puts a new reference in the place of one on a content element, or removes it. Each deletes a reference, and in a workspace the delete has to name the right uid, or it discards a draft instead of deleting a record. An editor in the workspace "Entwurf" met that refusal on 2026-11-06. Neither tool writes a file. Both read ``sys_file`` and write ``sys_file_reference`` and the parent's field, and core versions both tables and ``pages`` and ``tt_content``. So nothing of these two calls is beyond TYPO3's workspaces. What core does with the delete, read in ``DataHandler::deleteAction()`` of TYPO3 14.3.10 and 13.4.37 (the dispatch is the same in both): - a uid of a row whose ``t3ver_wsid`` is not 0 — a version, or a row new in the workspace — is discarded (``discard()``): the row is removed. Since 14.3.11 this holds only for a row new in the workspace, a move version and a delete placeholder; a version in the default state of the user's own workspace stands for its live record there, and core deletes the live record, which turns the version into a delete placeholder; - a live uid whose record has a version in the user's workspace in the default state turns that version into a delete placeholder (``t3ver_state`` 2) and deletes the record's translations the same way (``deleteL10nOverlayRecords()``); - a live uid without a version gets a new delete placeholder (``versionizeRecord()`` with ``$delete``), its translations too. Measured on 2026-11-08 against TYPO3 14.3.10 (SQLite), with the tools as below, as an administrator — with the global backend user in live as well — and, for the social image, as an editor who holds PAGE_EDIT and not CONTENT_EDIT on the page: - making the parent's version first versions its references; the field named by the versions' uids and a delete of the replaced reference by its LIVE uid leave the workspace showing exactly the new image at the old one's place, the replaced one marked for deletion there, and live unchanged but for the ``tstamp`` and ``sorting_foreign`` core writes onto the live references when it versions their parent (:ref:`ADR-264 `); - a delete by the uid of the reference's version discards that version: the old image shows again in the workspace beside the new one; - a translated page that follows its parent's image gets a copy of the new image and its copy of the old one marked for deletion, in its own version; the translated overlay of a removed content-element reference is marked for deletion with it, and the translated element's version counts what it still shows; - a reference new in the workspace, one the assistant set there earlier, is discarded by its own uid: it never was live. Decision ======== **Both tools write in the run's draft workspace**, as the attaching writers do (:ref:`ADR-264 `): they refuse a draft workspace only where the run did not record it (:ref:`ADR-260 `), or where an installation took the versioning off a table they write — ``pages`` or ``tt_content``, and ``sys_file_reference`` (``refuseUnversionedTableInDraftWorkspace()``, :ref:`ADR-262 `) — before the card. ``execute()``, the card and the refusal run in the acting user's workspace (:php:`AsksItsRefusalFirstTrait`, :ref:`ADR-266 `). **The parent's version first.** The page or the element gets its version with its own ``version`` command before the write (``versionForTheWrite()``). The datamap then names the parent's field with the rows that hold its references in the workspace — the versions — and the new reference is new in the workspace. **A delete names the uid a reference is addressed under**: its live uid, so core marks it for deletion, or the uid of the row new in the workspace, so core discards a draft that never was live. Never the uid of a version. **Everything around the write reads the workspace**: the page as the workspace shows it with PAGE_EDIT asked of the stored row (``pageForWriteIn()``), the element and its page, the reference, the references of the field and their translations, the page's translations, the read-back of the new row and of the parent version's counter. Another workspace's rows are not there, and a version addressed by its own uid stays refused as not found. Before ``replace_file_reference`` updates an element, core asks the element's STORED row — without an edit lock set on its draft alone, and on the page it sits on live — and the tool asks the same row, as the attaching writers do (:php:`AttachesToAStoredRecordTrait`); the page the element shows on in the workspace, which takes the new reference, is asked as well. The translated elements whose overlays core deletes along are asked by their stored rows too, and the records that hold them as inline children (:ref:`ADR-230 `), as for the element itself. Those owners and their pages are read in the workspace as well — the live row, or the row new there, stored and without the overlay, while the next owner up the chain is found through the owner's row as the workspace shows it. An owner other than a page moved in the workspace is asked on both pages: the one it left, where it stays live until the workspace is published, and the one its move version holds — either one the editor may not edit content on refuses. A page that holds the reference is asked as the page itself, PAGE_EDIT and CONTENT_EDIT on its stored row, not on the pages above it. This holds for ``replace_file_reference``, ``attach_file_to_record`` and ``set_file_reference_alternative_text``, which share the check (:php:`AsksInlineOwnersTrait`). Read live, an owner new in the workspace was not found: the call was refused as an orphan's, and where a second inline relation claimed the same pointer column without ``foreign_table_field``, a live record of that other table with the same uid counted as the holder, so the owner the editor may not change was never asked and the card was shown (#1325). In the same way ``set_page_social_image`` asks PAGE_EDIT of each translation's stored row, which is what core asks when it saves the translations with the page. **A write that fails is taken back**: the version this call made is discarded, and with it everything the call wrote below it. This holds for every failure: a read-back that does not match, an old reference the delete did not remove (``replace_file_reference`` as ``set_page_social_image``), and an exception thrown half-way, which leaves the tool after the take-back — also one thrown by a hook while core makes the version, before the call learns its uid. Where the parent had a version already, or is new in the workspace, a read-back that does not match puts the parent back instead — the new reference discarded by its uid and the field named by the rows it held before — and an old reference the delete did not remove is handled as in live: ``set_page_social_image`` puts the page back, while ``replace_file_reference`` answers that the element carries both and one has to be removed by hand. An exception there takes back the rows the write added by the same reading of before, described next, and keeps the draft that was there. Where the call made the version, the take-back works from a reading of the workspace taken just before that version was made (:php:`CreatesRecordsInTheRunsWorkspaceTrait::discardVersion()`): the rows there of the record and of its translations — versions, delete placeholders, translations new there — and the rows there of their inline children, file references among them, and of the children's own inline children, four levels down: an image of a content element that a news record holds is a draft two levels below the record. The same code serves ``attach_file_to_content_element`` and ``attach_file_to_record``. Each step was measured on TYPO3 14.3.10 and 13.4.37: - Every row there afterwards that was not there before is this call's, and is discarded with core's ``discard`` command on 14.3 — ``delete`` turns a version of the default state into a delete placeholder there since 14.3.11 instead of removing it — and with ``delete`` on 13.4, which has no ``discard`` command and whose ``delete`` still discards: the children first, then the record's version and the drafts core made of its translations in the same run — a translated element's version and the delete placeholder of its overlay reference (``deleteL10nOverlayRecords()``), a translated page's version and its copies of the images. Core's discard of the record's version does not reach the translations' drafts: it looks for translations by the version's uid (``discardLocalizationOverlayRecords()``), and a translation's version points at the live record. - Every row that was there before is kept. Core's discard of the record's version takes the versions of its inline children along (``discardRecordRelations()``), whoever made them; an alternative text an editor saved on one reference of the element is a draft of that reference without a draft of the element, and the discard of the version the call made for its write deleted it. The take-back's DataHandler is told the rows of before, by their own uid and by the live uid they are the version of, and skips them (:php:`ToolDataHandler::keepInTheWorkspace()`). - The discard runs with the parent's table in its datamap, which is never processed. Core discards a file reference only with the right to update one (``DataHandler::hasPermissionToUpdate()``): CONTENT_EDIT, unless a ``pages`` row is in the run, when it is PAGE_EDIT — the right the write was made under. Without it, for an editor who may edit a page's properties and not its content, core discarded the page version and left the new reference and the version of the page's live image in the workspace. - What is left is read from the rows and compared with the reading of before, column by column for the state, the pointers, the file and the texts of each row. Only where they match does the answer say the workspace was left as it was. Otherwise it names each row that differs — made by the call and still there, there before and gone, there before and changed, and which columns changed, without their values, which are an editor's content — and sends the editor to the Workspaces module. A row of before that the failed write CHANGED stays changed. The case met is a translated page whose translation already had a draft of its image copy, made by an editor's save of the translation's title: core's synchronisation turns that draft into a delete placeholder, and the row keeps its uid. Putting it back would take discarding the editor's draft, and the take-back never discards what was there before; the answer names the row and says that publishing the workspace would delete the copy live. **The card and the answer name the workspace.** Both cards carry the line of :ref:`ADR-260 `. Where ``set_page_social_image`` replaces an image in a draft workspace, its warning says the image is removed from the page in the workspace, live keeps it until the workspace is published, and an image that exists there only as a draft is discarded — instead of the recycler, which does not hold workspace rows. Where ``replace_file_reference`` replaces or removes a reference that exists only as a draft there, its card says the reference is discarded with its texts and crop and the recycler does not keep it. Where the page has translations, the card of ``set_page_social_image`` counts those that follow the page's image and get the new file too, and in a draft workspace those that get a draft of their own: core saves every translation with the page there, one that keeps its own image as well (measured on 14.3.10 and 13.4.37); the technical details name them. A translation follows when its ``l10n_state`` for the field is "parent", or "source" with a source translation that follows itself: core synchronises a "source" field from the translation it was translated from, not from the page (``DataMapProcessor::fetchDependencies()``), so one whose source keeps its own image, or that names the page as its source, keeps what it has. In a draft workspace core reads that state from the translation's stored row, the live one where the translation has a draft, so the card does too (measured on 14.3.10). Where an installation sets ``l10n_mode`` exclude on the field, core copies the page's image to every translation whatever its state (``DataMapProcessor::getLocalizationModeExcludeFieldNames()``), and the card counts every translation (measured on 14.3.10, live and in a draft workspace). A card names a content element without a header by its content type and UID (``ApprovalPreviewTranslator::contentElementLine()``, on every card that names a content element): "(empty)" there read as an element without content. The UID sits on the editor's line only for such an element, which has no header to be named by; the technical details keep it as before (:ref:`ADR-213 `). The answer ends with the draft note of :ref:`ADR-262 `. **An element moved in the workspace is refused.** Core's ``move`` command gives the element a move version there (``t3ver_state`` 4) and moves each of its references along with their own move versions. A delete of such a reference by its live uid makes core discard the reference's move (``DataHandler::deleteAction()``, 14.3.10 and 13.4.37: a version that is not in the default state is discarded), and core has no command that deletes a moved reference and keeps the move. Measured with a real ``move``: a replacement left the new reference beside the old one and the old reference's move discarded, and a removal left the element counting one reference while two showed. ``replace_file_reference`` therefore refuses an element that has a move version in the workspace — and a translated element moved on its own, whose overlay of the reference core deletes along, and a reference moved on its own with core's ``move`` command, which leaves its element as it is (measured) — before the approval is asked (``refusalBeforeApproval()``, as :ref:`ADR-266 ` refuses what core does differently in a workspace), for any user, and says to publish or discard the move first. A page moved in the workspace is not affected: its references keep the page as their ``pid``, so only the page gets a move version, and ``set_page_social_image`` writes onto it as onto any draft (measured). **Removing a draft reference is not refused.** :ref:`ADR-266 ` refuses ``delete_record`` of a record new in the workspace, because core discards it beyond the recycler. A reference new in the workspace is the image the workspace itself set — typically by an earlier call of these tools or of the attaching writers — and replacing it again is the editor's ordinary next step; the card says it is discarded. Consequences ============ ✓ An editor in a draft workspace can let the chat set or replace a page's social image and replace or remove an image on a content element; each change waits in the Workspaces module, and live stays as it was. ✓ Every writer that writes pages and content works in the run's draft workspace; only the redirect writers refuse it (``sys_redirect`` is not versioned). ✗ Live is not untouched to the byte: versioning the parent writes ``tstamp`` and renumbers ``sorting_foreign`` on its live references, as an editor's own save in the workspace does (:ref:`ADR-264 `). On TYPO3 13.4, where the page's translation follows its image, core's synchronisation of the translation also moves the translation's LIVE copy of the image from the default-language page's pid to the translation's own uid when it versions the translation; the tools name no pid of that row. 14.3 leaves it (measured in the functional tests of both majors). Which file the copy references, its translation pointer and its order stay. Taking a failed write back does not move it back. ✗ A reference new in the workspace that a call replaces or removes is discarded, not kept in a recycler, with its texts and crop; both cards say so. The removal's write target names that reference, and since its row is gone, the observed outcome of the run (:ref:`ADR-185 `) reads as discarded although nobody reversed it. ✗ ``replace_file_reference`` cannot change the images of an element moved in the workspace until the move is published or discarded. ✓ The records that hold an element or a record as an inline child (:ref:`ADR-230 `) are asked as the workspace shows them, by ``replace_file_reference``, ``attach_file_to_record`` and ``set_file_reference_alternative_text`` alike: an owner that exists in the workspace only is asked like a live one, and a live record of another table with the same uid no longer stands in for it. ``set_file_reference_alternative_text`` still reads the page of the reference's own record live. ✗ A failed write does not always leave the workspace as it was. A draft that was there before and that the write changed is named in the answer and left for the editor, never discarded (see above). The reading of before covers the record, its translations and four levels of their inline children, found by the pointer each child has in the workspace: a child whose draft points at another parent, or an overlay of a reference that sits on an element that is not one of the record's translations, is not in it. Where the write throws, the take-back runs and the exception leaves the tool; what the take-back could not put back is not in the answer then, since there is none. A row another session makes in the same workspace around the same record while the call runs is not in the reading of before, so a take-back in that window counts it as the call's and discards it; TYPO3 itself does not lock a record against two editors either. ✗ The translations were measured on TYPO3 14.3.10 locally and on the 13.4 line in the functional tests CI runs; the dispatch of the delete reads the same in both.