---
title: "ADR-158: The Editor Action Center adds a catalogue, not a runtime"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-158@0.35"
source: "Adr/Adr158EditorActionCenter.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-158: The Editor Action Center adds a catalogue, not a runtime

-   *Status:* Accepted
-   *Date:* 2026-08-11
-   *Amended:* 2026-08-11 by [ADR-162](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-162@0.35) (its rejection of a bulk surface)
-   *Authors:* Netresearch DTT GmbH

## Context

[ADR-152](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-152@0.35) gave every writing tool a human-facing declaration: a
translatable name, a sentence written for a person rather than for a model, an
icon, and the record types the action addresses. It also said, explicitly, that
each thing it did **not** build belongs with the consumer that would read it.

This is that consumer, and until now it did not exist. An editor could not find
an editor action at all. The five writers are reachable only when a model
chooses to call one inside a run, and the only surface that renders the
declarations is the admin Tools module — a management list, not a place to do
work. `nrllm_aitasks` ([ADR-131](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-131@0.35)) is the one module a non-admin
can reach, and it offers prepared *tasks* and the approvals inbox. There is no
catalogue and no per-record entry point.

Everything that makes such a surface safe already exists and is load-bearing: a
declared write implies approval ([ADR-134](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-134@0.35)), the loop suspends
and captures a preview in the run's actor context
([ADR-136](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-136@0.35)), the inbox re-authorises that preview per viewer, the
composite tool gate answers who may call what ([ADR-094](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-094@0.35)), and
the write fence refuses a write on a segment with no persisted run or no lease
([ADR-141](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-141@0.35)).

## Decision

> **The Editor Action Center adds a catalogue and one entry point. It adds no
> execution, no approval path and no authorisation rule.**

Three parts, and nothing else.

**A catalogue driven by the declarations and the real gate.**
`EditorActionCatalogue` reads
`ToolAvailabilityServiceInterface::editorActions()`, narrows by the
declaration's `recordTypes` when a record is carried, and then asks
`ToolCallPolicyInterface::decide()` for every remaining tool. That gate is
five checks evaluated as one AND — registered, globally enabled, permitted for
this user, inside the configuration's allowed tool groups, inside the trust
zone's data-class ceiling. The catalogue re-implements none of them. A
hand-rolled "is it enabled" here would be the fourth copy of a five-part rule
and would be the copy that ages.

The gate answers against a configuration, so an install with no default LLM
configuration offers nothing. Fail-closed and honest: there would be nothing to
run the action on.

**The configuration itself is a permission, and the tool gate does not check
it.** `ToolCallPolicyInterface::decide()` answers "which tools on THIS
configuration"; it never answers "whose configuration is it". The `beGroups`
restriction on a configuration means only those backend groups may use it
([ADR-070](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-070@0.35)), and no later step re-checks it — the run request
carries the configuration straight into the runtime. So the catalogue asks that
question too, once, through
`LlmConfigurationServiceInterface::hasAccess()`: the existing ambient-user
form of the rule, not a fourth copy. An editor outside the default
configuration's groups is therefore offered nothing, and a POST naming an
action directly builds no run.

**One entry point that carries the record.**
`EditorActionItemProvider` adds a single context-menu item on a record,
linking to the catalogue for that record's table and uid. It appears only where
the catalogue is non-empty for that table and that user, so an editor who may
run nothing sees no item rather than an item leading to an empty page. The item
is a link: nothing is started from the menu, and nothing about the record is
read. The declarations' `recordTypes` are matched before anything else, and
that match reads the tool registry rather than the database — so a right-click
on a table no editor action addresses costs no query at all.

**Starting an action is an ordinary agent run.**
`EditorActionCatalogue::runRequestFor()` builds a plain
`AgentRunRequest` — the default configuration, one user message naming the
tool and the record, the caller's `AiActorContext`, and an
`allowedToolNames` of exactly one — and the controller hands it to
`AgentRuntimeInterface::run()`. The declared write then suspends
AWAITING_APPROVAL before it touches anything, and the editor is redirected to
the inbox that already renders the card and its preview. No bulk, no second
executor, no special runtime: the expected outcome of pressing the button is a
pause, not a write.

## Consequences

**The same seam answers both questions.** `groupsFor()` decides what is
rendered and `runRequestFor()` decides what may start, and the second one
re-asks the first one's question rather than trusting the POST. A request naming
a tool the catalogue never offered produces no run. Split across two services,
that second check is the one a future entry point forgets.

**The catalogue never reads a record.** It is handed a table and a uid and
passes them on; it does not resolve a title, an existence or a permission.
Resolving the record would mean authorising the read, and an unauthorised read
would turn a catalogue into a probe for which uids exist. The record IS resolved
and authorised, twice and later: by `ToolPreviewInterface::previewCall()`
when the run suspends and by `mayViewerReadPreview()` when the card renders
([ADR-136](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-136@0.35)). The consequence an editor sees is that the page says
`pages #42` rather than the page's title.

**The grant is \`\`tasks_use\`\`, not a new one.** The module's existing execution
grant already means "this account may have the extension run a model on its
behalf", and it is checked per action on top of the module switch
([ADR-130](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-130@0.35), [ADR-131](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-131@0.35)). A second grant for a
narrower capability inside the same module would be a switch with no distinct
decision behind it — and it would be the weaker of the two protections anyway,
because whether a writing tool may run at all is already an explicit admin act:
every writer ships `isEnabledByDefault() === false`, and a configuration
that restricts tool groups must list `editing`. ADR-152 deferred a
*per-action* grant to its consumer; the consumer's answer is that the axis it
would add is already covered twice.

**The item checks the module, not only the grant.** `nrllm_aitasks` is
registered `access: 'user'`, so the be_groups tick and `tasks_use` are
independent axes ([ADR-130](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-130@0.35), [ADR-131](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-131@0.35)). The item
is a link into that module, so `canHandle()` asks
`ModuleProvider::accessGranted()` as well — through the provider rather
than a copy of the `user` gate, so changing `access` in
`Configuration/Backend/Modules.php` changes this answer with it. Without it a
user holding the grant but not the module would be offered an item leading to a
403, which is the outcome the grant check exists to avoid.

**The offer carries the subject and nothing else.** The prompt names one table
and one uid, and `allowedToolNames` holds exactly the offered tool — there is
no lookup tool beside it. So a declared `recordTypes` entry must be a table
one of the tool's own required arguments can be filled from
([ADR-152](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-152@0.35) states the rule; a unit test enforces it). An
argument that is neither the subject nor derivable from it —
`move_content_element`'s `target_page` — can only come from the editor's
free-text note, so that action's human description asks for it. The safety net
is unchanged either way: a guessed destination arrives as an approval card
showing the page the preview resolved, not as a write.

**Files have no context-menu entry.** In the file list the context menu's
identifier is a FAL combined identifier (`1:/path/file.jpg`), not a uid, while
`set_file_alternative_text` declares `sys_file` and takes a uid. Casting one
to the other yields a plausible wrong number, so the provider handles integer
identifiers only. The action stays visible in the catalogue and is startable
once a resolution step exists.

**Without a record the catalogue is read-only.** An action needs a subject, and
this module deliberately ships no record picker: a picker is a read boundary
([ADR-130](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-130@0.35) withheld the `table` task input type for exactly
that reason), and building one here would put a second, weaker boundary beside
the one the tools enforce themselves.

## Alternatives considered

**A module of its own.** Rejected: same audience, same grant, same inbox as
`nrllm_aitasks`, and [ADR-119](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-119@0.35) already calls the admin tree's
entry count a dumping ground. A second editor-facing module would also need its
own be_groups tick to be reachable at all.

**A bulk surface — one action over many records.** Rejected here, and ADR-152
already said why: the approval unit is a turn, and [ADR-133](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-133@0.35)
refuses a per-call verdict. "Approve 200 writes" is not a decision a card can
carry.

> Amended by [ADR-162](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-162@0.35). That objection is about ONE run holding
> many writes; it says nothing about many runs. N records planned as N ordinary
> runs are N turns, N digests, N verdicts and N fence stamps, so the approval
> unit is untouched. ADR-162 adds the multi-record entry point on that basis and
> still builds no bulk runtime.

**Filtering the catalogue by hand from** `enabledNames()`. Rejected: that
is one of the gate's five checks. It would show an editor actions their
configuration forbids and their trust zone refuses, and the refusal would arrive
as a failed run instead of an absent button.

**Resolving the record title for the catalogue header.** Rejected for now: see
the consequence above. It is a read that must be authorised, and the authorising
code lives in the tools, which cannot be asked without arguments.
