ADR-197: A generic record creator, only where no narrow writer exists
- Status
-
Accepted (its line against updating and deleting is moved for pages and content elements — see ADR-198)
- Date
-
2026-09-21
- Amends
-
ADR-135 (its refusal of a generic writer, for the CREATE case under the conditions below), ADR-180 (the rejected alternative, re-evaluated as a builtin rather than a remote tool)
- Amended
-
2026-09-23 by ADR-198 (narrow writers now update, publish, delete, copy and move existing pages and content elements; this fallback still only creates)
- Authors
-
Netresearch DTT GmbH
Context
ADR-135 refused a generic `update_: its blast radius is the whole TCA, the arguments are model-ADR-180 <adr-180>` re-evaluated a generic
Write tool of a third-party MCP server and rejected it as well: one
tool over the whole TCA, writing into a workspace of its own choosing,
admin-only as a remote tool.
A live editorial run on the Netresearch demo (NEXT-158) hit the limit of that model from the other side. The assistant recognised EXT:news and its table and could not create a news record, because no writer exists for it and none will exist for every extension an installation carries. NEXT-160 asked what it would take to serve every content type and extension of the demo.
The position this record works from: an extension that wants its records
written by the assistant brings its own writer. The tool contract is public
(Tool carries #), and a
writer shipped by EXT:news, or by a bridge extension on its behalf, is reviewed
where that extension's TCA is known. This record does not replace that. It
covers the gap while such a writer does not exist.
Two facts changed since ADR-135 that its argument rested on:
- The controls it wanted are in place and shared: the approval pause before every declared write (ADR-134), the acting-user authorisation every writer performs itself (ADR-083), the read-back that names what the DataHandler dropped, the hidden draft, and the preview that doubles as the reservation compared on resume (ADR-184).
- The DataHandler enforces the acting user's rights for every table:
tables_, page permissions on themodify pid,non_,exclude_ fields authon selects, language access. A generic tool does not widen what the user may write; it widens what the model may ask for.Mode
Decision
create_ is a builtin writer that creates ONE hidden record in a
TCA table for which no narrow writer exists. It is a fallback, not a
replacement, and every one of the following conditions is part of the
decision. Removing one reopens ADR-135's argument.
- Table allow-list by exclusion, reviewed once. The table must be in the
loaded TCA, must not be
pagesortt_(they have their writers), must not be one of the tables no tool may read (content Tableand the prefixesRead Access Service:: SENSITIVE_ TABLES tx_,nrllm tx_), must not benrvault adminorOnly hideorTable readin itsOnly ctrl, must not be asys_*table, and must not be a table another registered tool creates records in. A creator declares those tables throughRecord; the fallback iterates the tools registered through theCreator Interface:: get Created Tables () nr_tag at call time — the setllm. tool Toolindexes, an extension's own creator included — and steps back from a table exactly when such a declaration lists it, naming that tool. A declaration that throws refuses the call and names the tool (its class, where its spec cannot be read either): it may be the one covering the table. The editor-action record types are not the declaration: they name the SUBJECT an action's arguments identify (ADR-152), so a content-element creator declaresRegistry pagesthere, and an updater of a table declares that table without creating in it. The builtin creators implement the interface —create_(content_ element_ draft tt_),content create_(page_ draft pages),create_(both) — andtranslation_ draft pagesandtt_are refused by name in the fallback as well. A tool an MCP provider supplies never implements the interface and is not consulted. An installation narrows further through the extension configurationcontent tools.; it cannot widen, and a configuration that is present but cannot be read — not a comma-separated string, or a path to it that is not a list of settings — refuses every table. Only an absent setting excludes nothing.create Record Draft. denied Tables - The acting user's rights, checked before the write and reported after
it.
tables_for the table; the content-edit permission (modify Permission::) on the page or folder theCONTENT_ EDIT pidnames, which is whatDataasks for every table butHandler:: has Permission To Insert () pages— a record at the root level (pid0) is not created, and a page outside the user's web mounts yields no permission (Backend);User Authentication:: calc Perms () non_for every column the call sets whoseexclude_ fields excludeflag is truthy, the hidden column included, because the DataHandler drops such a column in silence;checkfor the default language; the live workspace only, throughLanguage Access Writes. What TYPO3 still rewrites or drops in silence — a hook of the installation, a grant the pre-check does not model — is read back column by column, and so are the two values the record carries without an argument naming them: the default language the tool forces and the record type it resolved (condition 3). The record type is not refused for a missing grant, because no argument names it: the tool writes it explicitly only where the acting user may write the type column — an admin, or the column is notThrough Data Handler Trait excludeor is granted innon_, and on a select withexclude_ fields authMode Backendpasses for the value — so the record carries the type its fields were checked against. Otherwise the column stays out of the datamap, since the DataHandler would drop it in silence (User Authentication:: check Auth Mode () Data,Handler:: fill In Field Array () Data), and the read-back expects what the DataHandler stores itself: the resolved type whereHandler:: check Value For Select () TCAdefaultsor the TCA default gave it, whichDataapplies to a new record either way; nothing where it is core's fallback, which leaves the column at its database default. On a mismatch the record is deleted again, the columns are named and the refusal says the value was dropped or rewritten by TYPO3, as the creating sibling writers do with a record they cannot vouch for. A rich-text column, whose stored form the RTE rewrites, is checked for presence only.Handler:: new Field Array () - Scalar columns only. A column can be set when its TCA type is
input,text,number,email,color,datetime,check,radioorselect— a select or radio only with staticitems, single-valued, withoutforeign_,table itemsorProc Func MM; a datetime only in thedatetimeordatetimesecformat stored as a timestamp, because the DataHandler normalisesdate,timeandtimesecon the way in and stores a nativedbcolumn in a form the read-back cannot compare. Everything else —Type inline,file,group,flex,category,folder,image,Manipulation slug,link,password,uuid,json,passthrough,user,none— is not an argument. Aslugthe TCA generates from other fields is left to the DataHandler. A column not in the record type'sshowitem, palettes expanded, is refused, and so is a column FormEngine renders read-only on the page, which the DataHandler stores all the same. That is one flag per column, resolved as FormEngine resolves it: the page TSconfigTCEFORM.<table>.<column>.(or itsconfig. read Only types.<type>.form) where it is set and the column's type is one whoseconfig. readthe form lets page TSconfig override — every scalar type butOnly radio(Form) — else the record type's merged TCAEngine Utility:: override Field Conf () read. A page rule ofOnly 0therefore lifts a TCAread; it is read once theOnly pidis authorised. The record type is resolved the way the DataHandler gives a new record its type (Data): the value the call gives for theHandler:: apply Defaults For Field Array () ctrl.column; elsetype TCAdefaults.<table>.<column>from the page TSconfig of thepid, which the DataHandler merges over the acting user's; else the user TSconfig's; else the column's TCA default; else core's own fallback (0, then1). ATCAdefaultsvalue that names no declared record type is refused, since the DataHandler would store it as it is and the form show the fallback type. Because the page's TSconfig can decide the type, every check that depends on it runs after the page is authorised, so a user without access learns nothing of what the page configures. A table whose record type lives in a related record (actrl.of the formtype field:) is refused. Values are checked by type the way ADR-194 checks a select: against the items; within the TCAfield max, or 255 characters for aninputoremailand 20000 for atextwhere none is declared; a number as integer or decimal withinrange; a check as 0/1; a datetime as a UNIX timestamp or an ISO 8601 date-time withinrange, handed on as the timestamp; an email as a valid address; a color as a hexadecimal value. The DataHandler would clamp a value outsiderangein silence; the tool refuses it, so the approver never reads a value the record will not carry. Every check reads the column's configuration for the chosen record type — the base column with the type'scolumnsmerged over it, as core builds a sub-schema field and the DataHandler validates against it — so aOverrides required, amax, the items orenableof one type only are honoured. A value the DataHandler would normalise, which the read-back would then call wrong, is refused before the write for the same reason: a column whoseRichtext evalholds a token the DataHandler acts on (aninput'supper,lower,nospace,alpha,num,alphanum,alphanum_,x is_,in domainname,md5,uniqueorunique, and on TYPO3 13In Pid year, which its DataHandler casts to an integer and 14's no longer knows; anemail'suniqueorunique; for anIn Pid inputor atext, a token an extension registered inSC_). A token the DataHandler does not know it ignores, and so does the tool — a legacyOPTIONS. tce. formevals requiredleft in acolumnseval, whichOverrides Tcamoves out of the base column only, is not a refusal; an eight-digit colour on a column withoutMigration opacity, which is cut to seven characters; a non-emptyinputortextbelow itsmin, which is stored empty (rich text is exempt, as in core); a decimal with more than two decimal places, which the DataHandler stores throughnumber_whatever the column — 1.234 would become 1.23 — so the read-back compares a decimal as that two-place string, exactly; a number the range check would clamp, which compares the value as stored rounded up againstformat ($value, 2) upperand rounded down againstlower— 4.2 in 0.5..4.5 becomes 4.5. - Column deny-list regardless of type.
uid,pid(an argument of its own, never a field), thectrlcolumns for delete, versioning, sorting, timestamps and cruser, the enable columns (hiddenis forced to 1 and cannot be set;starttime,endtime,fe_refused), the language columns (the record is created in the default language, see condition 7; the language field, the parent and the source pointers are refused),group editlock, and every column whose name starts withperms_,TSconfigort3ver_. - Hidden, once, behind the approval, with a readable preview. The record
is created with its
enablecolumns.column set; a table without one is refused, because nothing this extension writes is visible before a human unhides it (ADR-135). The tool declaresdisabled NON_so the pause applies. The preview names each field by its column and the record type's TCA label — a showitemIDEMPOTENT_ WRITE field;Labelfirst, else thecolumnslabel, else the column's own, asOverrides Tcabuilds it — resolved in English. A page TSconfig label override (Schema Builder TCEFORM.<table>.<column>., which FormEngine applies inlabel Tca) is not applied: the card must not depend on the page or on the viewer's language. The preview shows one line per field, with a timestamp as an ISO 8601 date-time in UTC and a select or radio value with its item's English label — the approver readsColumns Process Field Labels published_rather than a raw timestamp. English, never the viewer's language: the lines are compared byte for byte when the run resumes (ADR-184), and a resume can run in another request, worker or language. The preview is a function of the arguments, the current TCA, the page's TSconfig and the title of the page theat (Published at): "2026- 09- 21T08: 00: 00+00: 00" pidnames — the inputs the sibling writers' previews read, in the fixed English their previews use. - Required columns are required here. A column the TCA marks
requiredfor the chosen record type must be present in the call and non-empty — the DataHandler drops an empty required value in silence, as ADR-135 records — and the refusal names it. The tool does not invent values. - Default language only. The record is created with its language field
at 0 where the table has one; there is no language argument. A record in
another language is a translation of an existing record, which needs a
parent and a tool of its own —
create_exists for pages and content elements and for nothing else — and a standalone record in a non-default language is what ADR-193 found to mix a page. The condition that record reads istranslation_ draft tt_'s; no equivalent exists for an arbitrary table, and the fallback does not invent one.content checkis still asserted against the acting user, as ADR-135 does for the file writers.Language Access (0) - What the page's form does not offer is not written. Page TSconfig
TCEFORMis enforced by FormEngine alone; the DataHandler writes a column the form hides and a value it does not list without a word. After thepidis authorised the tool reads the page's TSconfig the way FormEngine does (Backend) and refuses a columnUtility:: get Pages TSconfig () TCEFORM.<table>.<column>.hides — any truthy value,disabled trueas well as1, asSinglereads it — a select value outside itsField Container keepor inside itsItems remove, a record type the type field does not offer there, whether the call names it or it is resolved (condition 3), and a columnItems TCEFORM.<table>.<column>.renders read-only (config. read Only Form), which the DataHandler stores all the same; the same rule set toEngine Utility:: override Field Conf () 0lifts a TCAread, so such a column is accepted, as the form accepts it. AOnly types.<type>.block overrides the column's own rule for that record type, asPagemerges it. The refusal names the rule. Radio items are not filtered, and a radio'sTs Config Merged readis the TCA's alone, because FormEngine applies neither rule to one — its override matrix has no entry forOnly radio.
What the fallback does not do, and why: it does not update or delete (the
safety line of ADR-135 and ADR-180 stands for those; a wrong CREATE leaves a
hidden record to delete, a wrong UPDATE overwrites work); it does not create
child records or file references (one call, one record, ADR-180's multi-record
review is still open); it does not publish. ADR-198 later
moved that line for pages and tt_, through narrow writers of
their own; this fallback still only creates.
Relation to the narrow writers and to extension-shipped writers
The narrow writers stay first. Where a creator exists for a table, the fallback refuses that table and names the creator. Where an extension registers a creator for its table, the same rule applies from the day it is installed: the declarations are read at call time, so the fallback withdraws without a release of this extension.
An extension declares the tables it creates records in through
Record, next to Tool: its
get lists the tables the tool's rows land in. It is an
@api extension point (ADR-127), separate from the editor
action on purpose — an editor action answers "what can I do with this
record?" and names its subject, a creator declaration answers "where does a
new row land?".
The fallback declares no editor action at all: a declaration must name at
least one record type (Editor refuses an empty list), and the
fallback has no subject record an editor would select. So it is not offered
from a record's context menu, only through the assistant.
Consequences
✓ Every extension table with scalar fields can be written by the assistant on the day the extension is installed, under the user's own rights, hidden and behind an approval.
✓ The exclusions are one list in one class and are reviewed once, which is the review model of ADR-135 applied to the boundary rather than to each field.
✕ The preview shows arguments the tool cannot interpret beyond the TCA label. An approver of a news record sees the fields, not what the record means to the site.
✕ A table whose meaning lies in relations (categories, media, references) comes out incomplete from this tool. That is intended: the incomplete draft is hidden, and the relations are the case for a narrow writer.
✕ tables_ and the page permission decide who may use the fallback;
an editor allowed to create records in a table through the backend form may
now do so through the assistant too. That is the rights model, not an
extension of it.
✕ Default language only. A translation of a record in a table the fallback serves is a backend job.
Revisit when
An extension ships a creator for a table the fallback served, implements
Record, and the withdrawal does not happen as
described. Or a table with scalar fields only
turns out to carry meaning the DataHandler cannot guard — then it goes on the
deny-list, and this record gets the reason. Or a translation is wanted for a
table the fallback serves — the language then needs a rule of its own, as
ADR-193 gave tt_, and this record gets it.