ADR-168: A use-case pack declares editor actions, and only declares them
- Status
-
Accepted
- Date
-
2026-08-13
- Extends
-
ADR-163 (a fourth advisory field on the pack) and ADR-152 / ADR-158 (the editor-action declarations and the surface that offers them)
- Authors
-
Netresearch DTT GmbH
Context
A use-case pack installs a configuration preset, tasks and snippets. A task runs
as a plain completion:
Task builds a prompt,
injects skills and dispatches it — there is no tool path in it at all. The named
editorial writes an editor actually uses (set_file_alternative_text,
create_translation_draft, update_page_metadata,
move_content_element, create_content_element_draft) are tools,
reached through the Editor Action Center, disabled by default and enabled by an
administrator in the Tools module, with every run pausing for approval.
Nothing on
Use connected the two. So a pack could claim "installs
these tasks and snippets" and nothing more — which is fine for Editorial
Starter, and useless for a Translation or Media Accessibility pack, whose whole
point is the action. Issue #769.
The same file carried a second, smaller defect:
$recommended is
a
list<string>
that the template printed raw, so contnet rendered
exactly like content.
Decision
A new advisory field.
Use is a
list<string>
of tool names. It is the fourth field of the same kind as the
governance profile and the tool groups, and it is read by exactly one thing: the
install plan.
The plan states, it does not act.
Use reports
per declared action whether this installation declares it, whether it is enabled,
which record types it addresses, and its tool group — and the plan screen links
to the Tools module and to the Editor Action Center.
install never reads
the list. There is no branch that enables a tool group, enables an action or runs
one, and the readout interface has no writing method to call.
The reason is not caution, it is the same decision twice already made. ADR-145 established that a governance profile describes a posture and never applies one; ADR-140 refused an automatic apply engine for the same class of thing. An "apply" button here would be that engine arriving through the back door, holding the authority of the tool gate — the one control an administrator sets deliberately on top of the group gate and the approval pause. A pack is data. It is not allowed to be a privilege.
One registry, one enabled-check. The states are read off
Tool — the service that already resolves which
tools are registered, which declare an editor action (ADR-152)
and what is enabled after the group AND tool cascade. No second registry and no
second enabled-check:
Editor documents that the enabled
rule already has copies and that a fourth is the one that ages.
The ADMIN state, not the per-viewer offer.
Editor is deliberately not what the
plan asks. It answers for one viewer and folds four different reasons — no
default configuration, no access to it, the tool gate, the record type — into a
single absent row. On a plan screen that would make a pack's typo
indistinguishable from a missing default configuration. The plan answers what an
administrator can act on; the catalogue answers what an editor is offered.
Different questions, and the plan needs the one with a remedy attached.
Core declares the question, the tool module answers it.
\Service\ is core, and ADR-090 — enforced by
Module — forbids core importing
\Service\. So
Pack and its two DTOs live in
\Service\,
Pack implements it in
\Service\, and the container joins them. The alternative was a sixth
name on the seam test's by-name exception list; it was rejected because the
exception list is for classes that move with the tool module in a split, and
the pack plan moves with core.
The constructor validates the new field only. It rejects a blank entry and a
repeated one in
$recommended, and leaves
$recommended exactly as it was.
The asymmetry is the point. A new field ships with its contract, and no pack
outside this repository declares it yet.
$recommended is
pre-existing and reached through the frozen @api
Use, so a third-party pack declaring
['content', 'content'] is a supported scenario that works today — and
Use builds every pack in its constructor and catches
nothing, so a new throw there would take down every backend module that injects
the registry. That is the same blast radius this ADR invokes below to keep the
identifier sets open; it cannot be unacceptable for an unknown group and
acceptable for a repeated one.
What the untouched field costs is bounded and visible: a blank entry renders as an empty badge, a repeated one renders twice. Both are on the plan screen, where an operator reads them and the pack's author can fix them.
The two rejected alternatives:
- Validate both and record it as a contract change. Honest, and still an outage for anyone who ships the defect — a backend that no longer starts is not a validation message.
- Validate both and make the registry catch per provider. A real robustness change, and a much larger one: it would make a throwing pack silently absent rather than loud, and it would remove the very argument that keeps the identifier sets open. If it is wanted it is its own decision, not a side effect of adding a field.
Unknown identifiers are surfaced, not refused at declaration time. Neither
list is checked against
\Domain\ or against a list of known
editor actions, and this is the point issue #769 left open:
- Both sets are open.
Tool's own docblock says so, andGroup \Form\proves it — the selectable groups are the groups of the currently registered tools, not the enum's cases. A third-party extension ships its own tools, its own group and, through the sameTca\ Tool Group Items @apiUse, its own pack.Case Pack Provider Interface - A throw would not be a validation error, it would be an outage.
Usebuilds every pack in its constructor and catches nothing, so one unknown group in one third-party pack would take down every backend module that injects the registry.Case Pack Registry
So the typo is caught where the truth lives: the plan asks the live registry and
renders a third badge, "Not available here", distinct from "Disabled" — and both
cards carry the same explanatory sentence when that badge appears, because the
badge says an identifier is missing while only the sentence says that installing
the pack will not produce it. For the
packs this repository ships the check is a test —
Use
asserts every recommended group and action of every registered pack exists. That
is closed for our packs and open for everyone else's, which is the split the
extension point requires.
What this does not do
It does not connect a task to an action. A pack task still executes as a completion, and no field here changes that. Wiring a task to perform a write needs a new execution path with its own fence, preview and audit story; that is a separate decision and explicitly out of scope.
It does not block an install. A declared action that is disabled — or absent entirely — changes no count on the plan and cannot hide the confirm button. An advisory declaration that could stop an install would be an execution contract wearing a different name.
Editorial Starter declares no action, and that is the truthful answer. Its
four tasks are text transforms run in the Tasks module; none of them writes a
record. And an editor action runs on the default configuration
(
Editor), not on the pack's own, so the
pack's house-style snippet would not reach one. Naming an action there to
demonstrate the field would claim a link the pack's records do not have. The
field earns its keep with the Translation and Media Accessibility packs, which is
what issue #769 filed it as a precondition for.
Consequences
A pack can now state what it is for without overpromising, and an operator sees on one screen which switch the pack's value still depends on.
Use gains a constructor argument. It is autowired and
absent from the frozen @api surface, so the addition breaks no caller the
project supports. It carries no @internal annotation, which is a gap in the
class rather than a property this record may rely on.
A future nr_llm_tools extraction has one more seam to satisfy: core would
need a null implementation of
Pack for an
installation without the tool package. Until 1.0 there is one extension and the
container always has the real one.