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, ADR-146).
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 Editor beside
Tool. 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 Agent
(ADR-141), the effect stamp on the run row
(ADR-111), the implied approval a declared write carries
(ADR-134), the preview produced at suspend
(ADR-136), and the acting-user authorisation each tool performs
itself (ADR-135). 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:
Editor(opt-in)Action Interface -
Returns one
Editorvalue object carrying a translatable label key, a short human description key distinct from the model-facing one, an icon identifier registered inAction Configuration/Icons.php, and the record types the action addresses, machine-readably. It is a marker-style optional interface exactly likeToolandEffect Interface Tool: the read-only builtins are untouched, and a tool that does not implement it is simply not an editor action.Preview Interface 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
Toolcollects 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.Availability Service:: editor Actions () - The declaration is collected by its OWN method, not by
states() Toolis derived fromAvailability Service Interface:: enabled Names () states, and the tool-call gate (() Tool) reads it on every decision. Building the declaration there would run foreign code —Call Policy:: decide () Editor's constructor refuses an empty label key or an emptyAction 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.editoris 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 byActions () enabledand still allowed byNames () decide, and the module renders it under its wire name beside a sound declaration.() recordTypesnames the SUBJECT, not the written row-
set_file_alternative_textdeclaressys_file: that is the uid the call names and the record an editor selects. The row it writes is that file'ssys_file_metadata. A catalogue answers "what can I do with this record?", and only the subject answers it.create_translation_draftdeclares bothpagesandtt_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_drafttherefore declarespages— its only required record identifier ispage— even though the row it creates is att_contentrow. Declaringtt_contentwould 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_elementdeclarestt_contentfor itsuidand still requires atarget_pagethat 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.
Tool is an enum, not a value object, and it is deliberately
not the return type of Tool.
- 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_groupsitem provider and the egress policy already treat an unknown group as ordinary. NarrowinggetGroup()to an enum would close a set that must stay open, and would break an@apiinterface 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) 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: Tool'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 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'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
Toolreturning 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). 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.Preview Interface:: preview Call () - A per-action grant
- Not built. ADR-130 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's.
- A sixth writer
- The catalogue is complete. ADR-146 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,
Tool, and the module controller that renders what it
collects. The collector's new method is off the tool-call path:
enabled and Tool never construct an
Editor.
✓ 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 Tool, 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 Tool, 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 Tool and nothing about this record changes that. This is
deliberate: making the declaration mandatory would be a breaking change to
Tool 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.