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 (its rejection of a bulk surface)

Authors

Netresearch DTT GmbH

Context 

ADR-152 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) 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), the loop suspends and captures a preview in the run's actor context (ADR-136), the inbox re-authorises that preview per viewer, the composite tool gate answers who may call what (ADR-094), and the write fence refuses a write on a segment with no persisted run or no lease (ADR-141).

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), 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). 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, ADR-131). 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, ADR-131). 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 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 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 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 refuses a per-call verdict. "Approve 200 writes" is not a decision a card can carry.

Amended by ADR-162. 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.