ADR-263: The read tools read the run's workspace 

Status

Accepted

Date

2026-11-05

Amends

ADR-260 (its consequence "the other read tools still read live only")

Authors

Netresearch DTT GmbH

Context 

ADR-260 let a run started in a draft workspace write into it, and made get_page_content read the page as that workspace shows it. Every other tool that reads a workspace-aware table (ctrl.versioningWS) still read live rows only, so the model searched, listed and paged through text the editor no longer saw: read_records, search_records, get_pagetree, the file tools on sys_file_metadata and sys_file_reference, and highlight_record.

Two tools did not keep the rule of commit 61ab69eb3 (another workspace's drafts are never read) at all, and a third read live where the editor sees the draft:

  • get_record_history read sys_history by the uid the model named. A version row's uid, or the uid of a record new in another workspace, returned that workspace's changes, values included. Core's history module filters the entries by workspace (RecordHistory::findEventsForRecord(), TYPO3 14.3.10 and 13.4.37) and resolves the live uid to the version in the user's workspace (RecordHistory::resolveElement()).
  • get_tsconfig accepted any page uid, so a version row's uid fed that version's TSconfig into the rootline. And the shared PageTsConfigReader overlaid the rootline with the acting user's workspace even for a run that had not recorded one.
  • read_fal_asset_meta read live only. Core's MetaDataRepository::findByFileUid() adds a WorkspaceRestriction for the context's workspace (14.3.10 and 13.4.37), which drops version rows, so in the backend it returns live values too; the workspace overlay of file metadata happens only for a frontend request (FileMetadataOverlayAspect::languageAndWorkspaceOverlay() returns early outside one, 14.3.10 and 13.4.37). sys_file_metadata is versioned all the same, and the backend edit form and the workspace preview show the draft.

The overlay get_page_content uses — fetch, then BackendUtility::workspaceOL() each row in PHP — works for one page with a bounded number of elements. It does not work for a search or a paged list: a SQL LIKE over live rows cannot find a term that only a version row holds, and a LIMIT/OFFSET applied before the overlay counts rows the workspace deleted. Core's WorkspaceRestriction with a workspace leaves the version rows out (it keeps live rows, records new in the workspace and move pointers) and expects the caller to overlay after the query.

Decision 

One row per record, chosen in SQL. Since TYPO3 11 a workspace version is a complete row: a change, a move (the version carries the new pid) and a deletion (t3ver_state 2) are each one row whose t3ver_oid names the live record, and a record new in the workspace is one row with t3ver_oid 0. The rows a user in workspace W sees are therefore a row predicate (ReadsTheRunsWorkspaceTrait::shownInWorkspace()):

  • a live row (t3ver_wsid 0, t3ver_oid 0) that has no version in W, checked by a NOT EXISTS subquery that carries only the deleted restriction — a version hidden in W still hides the live row — and
  • a row of W itself, version or new record, that is not a deletion.

Every filter, LIKE, enable-field restriction, count, limit and offset is then applied to what the user sees, in the database. A term only the version holds finds the record; a term the workspace replaced, hid or deleted does not; rows of another workspace never match.

Page access is the live page's. For a non-admin, whether a page may be shown is asked of its live row, as core's page tree asks it; a version keeps the permissions it was created with. read_records and search_records ask it per row through the live uid, get_pagetree too in a draft workspace.

Live uids. A row is reported under t3ver_oid where that is set, its own uid otherwise — the uid the writers address. A uid filter matches the record or its version, never a version row by its own uid. A paged query orders by that live uid.

Whose workspace. The acting user's, and only where the run's actor recorded the same one (as get_page_content and the writers of ADR-260 decide it). A run without a recorded workspace, and every run in live, keeps the previous query unchanged.

The tools. read_records, search_records, get_pagetree, read_fal_asset_meta, search_fal_files, get_fal_references and highlight_record read the rows the predicate selects. get_tsconfig accepts a page only where it is a live page, or one the run's workspace shows, and reads the rootline in the run's workspace (PageTsConfigReaderInterface::forPage() takes it; the writers keep passing none, which reads the user's). get_record_history refuses, for every user, a uid that names a version row or a record of another workspace; on a workspace-aware table it filters the entries by sys_history.workspace as core's history module does (0, or 0 and the run's draft workspace). In a draft workspace it reads the entries of the live uid and of the record's version there together, where core's module replaces the live uid with the version's (resolveElement()).

Not in scope. get_page_content keeps its overlay (it reads one page and has its own tests). site_rag_query answers from the published site: its indexed_search and ke_search backends index the live frontend, and its database backend (DatabaseSearchBackend) reads pages and tt_content with WorkspaceRestriction(0). resolve_url resolves a URL through the site router, which routes live pages. The tools on sys_file, sys_redirect and the other tables without versioningWS read no versions. get_typoscript and check_typoscript read sys_template, which has no versioningWS; the rootline they build for a page is core's RootlineUtility in the request's workspace (see Consequences). RecordTextReader and BatchScopeReader serve the field fill and the editor-action batches, not the chat's read tools.

Consequences 

✓ In a draft workspace the model lists, searches and pages through the records the editor sees there, under the uids the writers take.

✓ A version row's uid and another workspace's draft are refused by get_record_history and get_tsconfig, also for admins and also in live.

✗ get_record_history in live now leaves out entries sys_history stores with a draft workspace, as core's history module does.

✗ get_typoscript and check_typoscript (admin only) build the rootline with core's RootlineUtility, which reads pages in the request's workspace, not the run's, and accept a version row's uid as the page. What they return is TypoScript from sys_template; a page's draft only changes which conditions match.

✗ A record deleted in the draft workspace is left out, as the workspace preview leaves it out; the backend list modules show it marked as deleted. The records on a page deleted there get no version of their own, so read_records and search_records still return them, as core's backend search does; get_pagetree no longer shows the page.

✗ The NOT EXISTS subquery runs per candidate row; it uses the t3ver_oid index core defines on every workspace-aware table.