---
title: "ADR-197: A generic record creator, only where no narrow writer exists"
manual: "TYPO3 LLM Extension"
version: "0.36"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-197@0.36"
source: "Adr/Adr197AGenericCreatorWhereNoNarrowWriterExists.rst"
rendered: "2026-09-23T23:37:39+00:00"
---

# ADR-197: A generic record creator, only where no narrow writer exists {#adr-197}

-   *Status:* Accepted (its line against updating and deleting is moved for pages and content elements — see [ADR-198](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-198@0.36))
-   *Date:* 2026-09-21
-   *Amends:* [ADR-135](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-135@0.36) (its refusal of a generic writer, for the CREATE case under the conditions below), [ADR-180](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-180@0.36) (the rejected alternative, re-evaluated as a builtin rather than a remote tool)
-   *Amended:* 2026-09-23 by [ADR-198](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-198@0.36) (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-197-context}

[ADR-135](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-135@0.36) refused a generic \``update_record(table, uid,
fields)``: its blast radius is the whole TCA, the arguments are model-chosen,
and a model is steerable by injected prose, so "the model would not do that"
is not a control. Every writer since then is narrow, and each one is a review
of one allow-list. :ref:`ADR-180 \<adr-180>\` re-evaluated a generic
`WriteTable` 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
(`ToolInterface` carries `#[AutoconfigureTag('nr_llm.tool')]`), 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_modify`, page permissions on the `pid`, `non_exclude_fields`,
    `authMode` on selects, language access. A generic tool does not widen
    what the user may write; it widens what the model may ask for.

## Decision {#adr-197-decision}

`create_record_draft` 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.

1.  **Table allow-list by exclusion, reviewed once.** The table must be in the
    loaded TCA, must not be `pages` or `tt_content` (they have their
    writers), must not be one of the tables no tool may read
    (`TableReadAccessService::SENSITIVE_TABLES` and the prefixes
    `tx_nrllm`, `tx_nrvault`), must not be `adminOnly` or `hideTable` or
    `readOnly` in its `ctrl`, must not be a `sys_*` table, and must not be
    a table another registered tool creates records in. A creator declares
    those tables through `RecordCreatorInterface::getCreatedTables()`;
    the fallback iterates the tools registered through the `nr_llm.tool` tag
    at call time — the set `ToolRegistry` indexes, 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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-152@0.36)), so a content-element creator declares
    `pages` there, 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_translation_draft` (both) — and `pages` and
    `tt_content` 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 configuration
    `tools.createRecordDraft.deniedTables`; 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.
1.  **The acting user's rights, checked before the write and reported after
    it.** `tables_modify` for the table; the content-edit permission
    (`Permission::CONTENT_EDIT`) on the page or folder the `pid` names,
    which is what `DataHandler::hasPermissionToInsert()` asks for every
    table but `pages` — a record at the root level (`pid` 0\) is not
    created, and a page outside the user's web mounts yields no permission
    (`BackendUserAuthentication::calcPerms()`); `non_exclude_fields`
    for every column the call sets whose `exclude` flag is truthy, the
    hidden column included, because the DataHandler drops such a column in
    silence;
    `checkLanguageAccess` for the default language; the live workspace only,
    through `WritesThroughDataHandlerTrait`. 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 not `exclude` or is granted in
    `non_exclude_fields`, and on a select with `authMode`
    `BackendUserAuthentication::checkAuthMode()` passes 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 (`DataHandler::fillInFieldArray()`,
    `DataHandler::checkValueForSelect()`), and the read-back expects what
    the DataHandler stores itself: the resolved type where `TCAdefaults` or
    the TCA default gave it, which `DataHandler::newFieldArray()` applies
    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.
1.  **Scalar columns only.** A column can be set when its TCA type is
    `input`, `text`, `number`, `email`, `color`, `datetime`,
    `check`, `radio` or `select` — a select or radio only with static
    `items`, single-valued, without `foreign_table`, `itemsProcFunc` or
    `MM`; a datetime only in the `datetime` or `datetimesec` format
    stored as a timestamp, because the DataHandler normalises `date`,
    `time` and `timesec` on the way in and stores a native `dbType`
    column in a form the read-back cannot compare. Everything else —
    `inline`, `file`, `group`, `flex`, `category`, `folder`,
    `imageManipulation`, `slug`, `link`, `password`, `uuid`,
    `json`, `passthrough`, `user`, `none` — is not an argument. A
    `slug` the TCA generates from other fields is left to the DataHandler. A
    column not in the record type's `showitem`, 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 TSconfig
    `TCEFORM.<table>.<column>.config.readOnly` (or its
    `types.<type>.config.` form) where it is set and the column's type is one
    whose `readOnly` the form lets page TSconfig override — every scalar type
    but `radio` (`FormEngineUtility::overrideFieldConf()`) — else the
    record type's merged TCA `readOnly`. A page rule of `0` therefore lifts
    a TCA `readOnly`; it is read once the `pid` is authorised. The record
    type is resolved the way the DataHandler gives a new record its type
    (`DataHandler::applyDefaultsForFieldArray()`): the value the call
    gives for the `ctrl.type` column; else `TCAdefaults.<table>.<column>`
    from the page TSconfig of the `pid`, 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`, then `1`). A `TCAdefaults` value 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 (a
    `ctrl.type` of the form `field:field`) is refused. Values
    are checked by type the way [ADR-194](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-194@0.36) checks a select:
    against the items; within the TCA `max`, or 255 characters for an
    `input` or `email` and 20000 for a `text` where none is declared; a
    number as integer or decimal within `range`; a check as 0/1; a datetime
    as a UNIX timestamp or an ISO 8601 date-time within `range`, handed on
    as the timestamp; an email as a valid address; a color as a hexadecimal
    value. The DataHandler would clamp a value outside `range` in
    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's
    `columnsOverrides` merged over it, as core builds a sub-schema field and
    the DataHandler validates against it — so a `required`, a `max`, the
    items or `enableRichtext` of 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 whose `eval`
    holds a token the DataHandler acts on (an `input`'s `upper`,
    `lower`, `nospace`, `alpha`, `num`, `alphanum`, `alphanum_x`,
    `is_in`, `domainname`, `md5`, `unique` or `uniqueInPid`, and on
    TYPO3 13 `year`, which its DataHandler casts to an integer and 14's no
    longer knows; an
    `email`'s `unique` or `uniqueInPid`; for an `input` or a `text`,
    a token an extension registered in `SC_OPTIONS.tce.formevals`). A token
    the DataHandler does not know it ignores, and so does the tool — a legacy
    `required` left in a `columnsOverrides` eval, which
    `TcaMigration` moves out of the base column only, is not a refusal; an
    eight-digit colour on a column without `opacity`, which is cut to seven
    characters; a non-empty `input` or `text` below its `min`, which is
    stored empty (rich text is exempt, as in core); a decimal with more than
    two decimal places, which the DataHandler stores through
    `number_format($value, 2)` 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 against `upper` and rounded down against `lower` — 4.2 in
    0.5..4.5 becomes 4.5.
1.  **Column deny-list regardless of type.** `uid`, `pid` (an argument of
    its own, never a field), the `ctrl` columns for delete, versioning,
    sorting, timestamps and cruser, the enable columns (`hidden` is forced to
    1 and cannot be set; `starttime`, `endtime`, `fe_group` 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), `editlock`, and every column whose name starts with
    `perms_`, `TSconfig` or `t3ver_`.
1.  **Hidden, once, behind the approval, with a readable preview.** The record
    is created with its `enablecolumns.disabled` column set; a table without
    one is refused, because nothing this extension writes is visible before a
    human unhides it ([ADR-135](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-135@0.36)). The tool declares
    `NON_IDEMPOTENT_WRITE` so the pause applies. The preview names each
    field by its column and the record type's TCA label — a showitem
    `field;Label` first, else the `columnsOverrides` label, else the
    column's own, as `TcaSchemaBuilder` builds it — resolved in
    English. A page TSconfig label override
    (`TCEFORM.<table>.<column>.label`, which FormEngine applies in
    `TcaColumnsProcessFieldLabels`) 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 reads
    `published_at (Published at): "2026-09-21T08:00:00+00:00"` 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 the `pid` names — the inputs the sibling writers' previews read,
    in the fixed English their previews use.
1.  **Required columns are required here.** A column the TCA marks
    `required` for 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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-135@0.36) records — and the refusal names it. The tool
    does not invent values.
1.  **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_translation_draft` exists for
    pages and content elements and for nothing else — and a standalone record
    in a non-default language is what [ADR-193](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-193@0.36) found to mix a
    page. The condition that record reads is `tt_content`'s; no equivalent
    exists for an arbitrary table, and the fallback does not invent one.
    `checkLanguageAccess(0)` is still asserted against the acting user, as
    [ADR-135](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-135@0.36) does for the file writers.
1.  **What the page's form does not offer is not written.** Page TSconfig
    `TCEFORM` is enforced by FormEngine alone; the DataHandler writes a
    column the form hides and a value it does not list without a word. After
    the `pid` is authorised the tool reads the page's TSconfig the way
    FormEngine does (`BackendUtility::getPagesTSconfig()`) and refuses a
    column `TCEFORM.<table>.<column>.disabled` hides — any truthy value,
    `true` as well as `1`, as `SingleFieldContainer` reads it — a
    select value outside
    its `keepItems` or inside its `removeItems`, a record type the
    type field does not offer there, whether the call names it or it is
    resolved (condition 3), and a column `TCEFORM.<table>.<column>.config.readOnly`
    renders read-only (`FormEngineUtility::overrideFieldConf()`), which
    the DataHandler stores all the same; the same rule set to `0` lifts a
    TCA `readOnly`, so such a column is accepted, as the form accepts it. A
    `types.<type>.` block overrides the column's own rule for that record
    type, as `PageTsConfigMerged` merges it. The refusal names the rule.
    Radio items are not filtered, and a radio's `readOnly` is the TCA's
    alone, because FormEngine applies neither rule to one — its override
    matrix has no entry for `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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-198@0.36) later
moved that line for `pages` and `tt_content`, through narrow writers of
their own; this fallback still only creates.

## Relation to the narrow writers and to extension-shipped writers {#adr-197-relation}

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
`RecordCreatorInterface`, next to `ToolInterface`: its
`getCreatedTables()` lists the tables the tool's rows land in. It is an
`@api` extension point ([ADR-127](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-127@0.36)), 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 (`EditorAction` 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 {#adr-197-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_modify` 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 {#adr-197-revisit}

An extension ships a creator for a table the fallback served, implements
`RecordCreatorInterface`, 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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-193@0.36) gave `tt_content`, and this record gets it.
