---
title: "ADR-152: An editor action is a declaration, not a second executor"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-152@0.35"
source: "Adr/Adr152EditorActionDeclaration.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-152: An editor action is a declaration, not a second executor

-   *Status:* Accepted
-   *Date:* 2026-08-11
-   *Authors:* Netresearch DTT GmbH

## Context

Five tools write today ([ADR-135](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-135@0.35), [ADR-146](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-146@0.35)).
Each one is a narrow editorial act on exactly one record, executed through the
`DataHandler` under the acting backend user, behind an approval pause and a
write fence. As runtime objects they are complete. As things a HUMAN is offered,
they do not exist at all.

Everything a tool declares about itself is written **for the model**. The name
is a wire identifier (`create_translation_draft`). The description is a
paragraph of English prose telling a language model when to call the tool and
what it will refuse. The group is a bare string. None of it is translatable,
and none of it is what you would put in front of an editor. The admin Tools
module proves the point: it renders `<code>{tool.name}</code>` and the raw
model-facing description, because that is all there is to render.

So "what is an editor action?" is an open question with two very different
answers, and the answer decides how much of the runtime gets built twice.

## Decision

> **An editor action is a produced, narrowly-bounded WRITING TOOL. It is
> declared metadata on top of the existing tool contract, and it executes on
> the existing tool / agent-runtime path.**

There is no `EditorActionInterface::execute()` beside
`ToolInterface::execute()`. Two executors would mean two write paths, two
fences and two audit stories — and the second one would be the one nobody
hardened. Everything that makes a write survivable today is arranged around the
tool path: the fence in `AgentRunExecutor::trace()`
([ADR-141](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-141@0.35)), the effect stamp on the run row
([ADR-111](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-111@0.35)), the implied approval a declared write carries
([ADR-134](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-134@0.35)), the preview produced at suspend
([ADR-136](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-136@0.35)), and the acting-user authorisation each tool performs
itself ([ADR-135](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-135@0.35)). A parallel executor inherits none of that by
construction; it re-implements it, or it goes without.

What is genuinely missing is not execution. It is the second, human-facing half
of the declaration:

-   **`EditorActionInterface` (opt-in)**

    Returns one `EditorAction` value object carrying a translatable label
    key, a short human description key **distinct from the model-facing one**, an
    icon identifier registered in `Configuration/Icons.php`, and the record
    types the action addresses, machine-readably. It is a marker-style optional
    interface exactly like `ToolEffectInterface` and
    `ToolPreviewInterface`: the read-only builtins are untouched, and a tool
    that does not implement it is simply not an editor action.

    One method returning one object, rather than four getters, so the shape can
    grow without every implementor growing a method.

-   **Its consumer ships in the same change**

    `ToolAvailabilityService::editorActions()` collects the declarations,
    and the Tools module renders the icon, the translated name, the human
    sentence and the record types — with the wire name demoted to a technical
    detail rather than removed, because that is the string an admin toggles by.
    A tool without a declaration renders exactly as before.

-   **The declaration is collected by its OWN method, not by `states()`**

    `ToolAvailabilityServiceInterface::enabledNames()` is derived from
    `states()`, and the tool-call gate (`ToolCallPolicy::decide()`)
    reads it on **every** decision. Building the declaration there would run
    foreign code — `EditorAction`'s constructor refuses an empty label key
    or an empty `recordTypes` — inside the runtime gate, so a third-party tool
    shipping a malformed declaration would abort tool calling for the whole run
    instead of rendering one row badly. `editorActions()` is therefore a
    separate method that only the module calls, and it drops a declaration that
    throws (logging it) rather than propagating: the row keeps its wire name, the
    module keeps rendering, the run is untouched. Three tests pin it — the state
    rows carry no declaration, a tool whose declaration throws is still listed by
    `enabledNames()` and still allowed by `decide()`, and the module
    renders it under its wire name beside a sound declaration.

-   **`recordTypes` names the SUBJECT, not the written row**

    `set_file_alternative_text` declares `sys_file`: that is the uid the call
    names and the record an editor selects. The row it writes is that file's
    `sys_file_metadata`. A catalogue answers "what can I do with *this*
    record?", and only the subject answers it.
    `create_translation_draft` declares both `pages` and `tt_content`,
    because which one it addresses is the caller's choice rather than a property
    of the tool.

    The rule is mechanical, and a test enforces it: every declared table must be
    one that a **required** argument of the tool's own spec can be filled from.
    `create_content_element_draft` therefore declares `pages` — its only
    required record identifier is `page` — even though the row it creates is a
    `tt_content` row. Declaring `tt_content` would offer the action on an
    element whose page a caller has no way to learn.

    The rule bounds the subject, not every argument. `move_content_element`
    declares `tt_content` for its `uid` and still requires a
    `target_page` that no subject supplies; its human description says the
    target belongs in the editor's note, and the approval card shows the
    destination the preview resolved.

## The group becomes an enum, and `getGroup()` stays a string

A grouping of actions needs something to render, and a group had no name at all
— only an identifier that happens to be an English word.

`ToolGroup` is an **enum**, not a value object, and it is deliberately
**not** the return type of `ToolInterface::getGroup()`.

-   The set of GROUPS is open. A third-party tool declares its own group — the
    recommended value is the providing extension's key — and both the
    `allowed_tool_groups` item provider and the egress policy already treat an
    unknown group as ordinary. Narrowing `getGroup()` to an enum would close a
    set that must stay open, and would break an `@api` interface to do it.
-   The set of groups THIS REPOSITORY SHIPS is closed, and was written out twice
    with nothing tying the two lists together: once as the per-group egress
    default ([ADR-094](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-094@0.35)) and once in the builtin-group test. An enum
    makes it exhaustive by construction — a case cannot exist without a label —
    and tests now tie the other two lists to it: one fails when a case has no
    egress default, the other when a builtin declares a group that is not a case.
    A third refuses a builtin the group test does not list, because a
    hand-maintained list that may silently omit a tool asserts nothing about it.
-   A value object would be a string wrapper accepting any value. That is exactly
    the openness the bare string already provides, so it would add a type without
    adding a guarantee.

A group outside the enum resolves to `null` and the module renders the raw
identifier. A third-party group stays visible and toggleable; it simply has no
translated name.

While in there: `ToolInterface`'s own docblock listed the taxonomy and
**omitted** `editing` — the group all five writers use, and the one this
record is about. Fixed, and pointed at the enum.

## What this record deliberately does NOT build

-   **`bulkCapability`**

    Not built. The approval unit is a **turn**: one digest, one verdict for all
    pending calls, and [ADR-133](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-133@0.35) refuses a per-call verdict
    outright. The fence stamps ONE pending effect per run row. A bulk flag today
    would be read by nothing — and [ADR-146](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-146@0.35)'s *Revisit when* is
    explicit that batching needs a different answer to "what did the approver
    agree to", not a bigger version of this one. A declaration nothing reads is
    worse than none: it reads as enforcement and buys false trust.

-   **A caller-facing preview service, and a structured before/after diff**

    Not built. Both are real gaps. Today's preview is
    `ToolPreviewInterface::previewCall()` returning free prose, produced
    inside the loop at the moment of suspension, in the run's actor context, and
    re-authorised per viewer before the approval card renders it
    ([ADR-136](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-136@0.35)). A caller-facing service would need a second
    authorisation story, and a structured diff needs a renderer that knows what
    to do with it. Both belong with the UI that needs them, and neither is that
    UI.

-   **A per-action grant**

    Not built. [ADR-130](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-130@0.35) admits a grant only together with its
    consumer, and there is no consumer: enablement already cascades through the
    group gate, the per-tool gate and the per-configuration allow-list, and the
    approver gate is [ADR-133](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-133@0.35)'s.

-   **A sixth writer**

    The catalogue is complete. [ADR-146](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-146@0.35) set the next review at a
    sixth writer; this record adds none.

## Consequences

✓ A writing tool can be named, described, illustrated and placed by record type
without a change to how it runs. The write path, the fence, the approval pause
and the audit are untouched: each of the five writers gained exactly one method
returning a value object — no `execute()`, no guard, no argument, no fence
was touched, and no executor or fence file was edited at all. Beyond the tools,
the runtime files that changed are the collector,
`ToolAvailabilityService`, and the module controller that renders what it
collects. The collector's new method is off the tool-call path:
`enabledNames()` and `ToolCallPolicy::decide()` never construct an
`EditorAction`.

✓ The Tools module stops showing an administrator a wire name and a paragraph
written for a language model where an editorial act was meant.

✓ The curated group taxonomy is enumerated in `ToolGroup`, and a case
cannot exist without a label in both catalogues or without an egress default.
It is not the *only* place the taxonomy is written down: the egress default is
still keyed by string in `ToolDataClassResolver`, and the builtin-group
test still names a group per builtin. Both are now tied to the enum in the
direction that can go wrong — every case has an egress default, and every group
a builtin declares is a case, the latter over every builtin rather than every
listed one, because the list is now closed against the directory. The reverse
is not asserted: an egress default for a group no case names is inert, and a
case without a builtin is what a taxonomy looks like the day before its tools
land.

✕ The declaration is metadata and cannot be enforced. A third-party writing tool
that does not implement the interface is still a write — the runtime's write
axis is `ToolEffect` and nothing about this record changes that. This is
deliberate: making the declaration mandatory would be a breaking change to
`ToolInterface` for a benefit that is presentational — and presentational
it stays, right down to a broken declaration costing its row a decoration and
nothing more.

✕ Three surfaces render a group name and two of them keep rendering the raw
identifier: the `allowed_tool_groups` TCA select and the Playground's grouped
tool checkboxes. The select is a FormEngine item list rather than a template and
its labels are stored operator selections; the Playground list is a picker for
one run rather than the administrative catalogue. The Tools module is the
consumer this record ships; both others are a one-line change whenever they are
next touched.

✕ The label keys live in PHP rather than beside a template `default`, so a
missing key renders as nothing at all. A test resolves every declared key in
both the English and the German catalogue for exactly that reason.

## Revisit when

An Editor Action Center exists — a surface that offers these actions on a
selected record rather than merely listing them. That surface is what will
demand the structured before/after diff and, if it ever offers more than one
record at a time, the answer to "what did the approver agree to" that
`bulkCapability` would need first.

Also revisit if a third party ships a writing tool. The declaration is opt-in
today because five of five writers are ours; the first foreign one is the
evidence for whether "opt-in metadata" or "part of the write contract" is the
right place for it.
