ADR-171: The personas nr_llm's code already assumes
- Status
-
Proposed
- Date
-
2026-08-13
- Authors
-
Netresearch DTT GmbH
Context
Issue #768 blocks a management grant on questions that are not about code:
whether a customer has a person who writes AI prompts but must not hold API
keys, whether an editor's lead needs to see their team's spend. Those questions
cannot be answered from the repository, and every attempt so far has answered
them by shipping a gate.
That has already cost once. ADR-023 shipped eleven backend-group checkboxes for a person who could not reach the product; ADR-117 removed them three months later. The section below states what that episode assumed about people.
This record does not decide who our users are. It states which people the code already assumes, separates that evidence from the assumptions layered on top, and lists what a human has to answer. Every recommendation in it is open.
Method. Personas are named on TYPO3's own role vocabulary wherever one exists, and listed only where a gate, a module registration or a shipped documentation claim points at one; none was invented to fill a gap. The "code" half of each entry is citation-backed. The "human" half is unvalidated, in every entry, without exception.
Personas
1. Administrator (TYPO3 Administrator)
Code. Fifteen of the sixteen registered backend modules are
'access' => 'admin' (Configuration/Backend/Modules.php:53 … :321);
forty denyNonAdmin() call sites in fourteen controllers restore that line on
AJAX routes (ADR-037, enumerated in
Adr169RecordManagementUsesTypo3Permissions.rst:308-341). isAdmin
short-circuits has, may and
may (AiActorContext.php:180, :196, :217) and bypasses
the configuration group restriction (ConfigurationResolver.php:218). The
docblock records where that comes from: "Administrators hold every grant
implicitly (the core check() short-circuits on isAdmin)"
(Backend's class docblock) — the platform's rule, not ours.
Human (unvalidated). One person owning the instance's AI capability end to end: holds the vault key, chooses providers and models, decides which tools exist and which groups may use them, and personally does everything not yet delegable. Afraid of an unbounded bill and of instance data reaching a provider.
Confidence. Enforced by code.
Blocks. #768 question 1. The forty gates protect three unrelated things —
credentials and outbound reach (17 sites), policy binding other people (11), and
an unbuilt read boundary (TaskRecordsController, 3; ADR-130
named constraint 5). One admin bit cannot tell them apart.
Point at this if you disagree: the person holding the OpenAI key is the same person who decides which tools an editor may run.
2. Editor (TYPO3 Editor plus the tasks_use grant)
Code. nrllm_aitasks, 'parent' => 'web', 'access' => 'user'
(Modules.php:342-344) — the only non-admin surface in the extension. Two
switches are required: the module tick in be_groups and the grant. The grant
has nine enforcement points in four classes: `Task`;
ContextMenu/EditorActionItemProvider:101. The module withholds what an editor
must not reach: no FormEngine links, no record picker, table-input tasks
filtered out (AiTaskController:106). Entry is from the record's own context
menu.
Human (unvalidated). Works on one record and wants one thing done to it, does not know what a provider is, and is afraid of publishing something wrong under their own name.
Confidence. Enforced by code.
Blocks. ADR-119's reopening, which
Adr169RecordManagementUsesTypo3Permissions.rst:293-296 recommends and does
not do. ADR-119 argued for leaving the modules under Administration because
"an editor would never open an nr_llm module" (Adr119:69); the context-menu
item now sends them into one.
Point at this if you disagree: AiTaskController:149-167 offers every
active non-table task in the instance to every grant holder. There is no
per-task, per-category or per-group scoping, and no task ownership field.
3. Approver (agent_approve)
Code. Backend; the grant branch in
Ai (:229) — "deliberately the only scope
with a grant equivalent" (ADR-130, Decision); the write side at
ResumeCoordinator.php:205 and :650; the list viewport at
AgentRunController.php:267. It sits in no recommended preset: "granting it is
an explicit trust decision"
(Documentation/Administration/Permissions.rst:55-59).
The card renders the tool's wire name and its pretty-printed argumentsJson
(ApprovalControls.html:8, :30), and states its own limits — "Captured
when the run paused — the target may have changed since" (:25), "No preview
— you are deciding without one" (:16).
Human (unvalidated). Decides somebody else's proposed write without editing it, and can predict a tool call's effect from its arguments.
Confidence. Enforced by code; the population may be empty — see invented personas.
Blocks. Whether non-admin approval is offered at all, and contradiction A.
Point at this if you disagree: the approver may release a write to a record
they hold no rights on, and the preview is withheld exactly then
(WaitingRunViewFactory.php:47, :153-162); the run detail page is withheld
too, because AGENT_READ has no grant equivalent
(AgentRunController.php:61-64).
4. System maintainer (TYPO3 systemMaintainer)
Code. grep systemMaintainer Classes/ returns zero hits. nr_llm never
checks this role, and every remedy the governance readout names is behind it: the
page routes the reader to "the Install Tool under Settings > Extension
Configuration > nr_llm" (locallang.xlf:295-296, rendered at
Governance.html:20-22), and the Settings module is
'access' => 'systemMaintainer' — "stricter than admin"
(Adr169…rst:383-385, reading core's
cms-install/Configuration/Backend/Modules.php:32).
Human (unvalidated). Holds the instance's global switches. May or may not be the same account as the administrator who reads the governance page.
Confidence. Enforced by TYPO3, never checked by us.
Blocks. Whether the governance readout needs a "who to ask" line. ADR-140 refused an apply path on sound grounds, leaving the reader no route to the fix.
5. Task manager
Code. None. The role exists only as a shipped claim: "What a task may read
and which model and configuration it uses is defined by whoever manages the task
— that is the trust boundary: task managers define, grant holders execute"
(Permissions.rst:40-43). Nothing checks it:
Task takes (Task, string, ?int $beUserUid) —
no actor, nothing to check against. tasks_manage occurs once in Classes/,
in Backend's class docblock — which, since
ADR-169 was accepted, records the reservation as retired rather
than pending.
Human (unvalidated). Writes the prompt, picks the configuration, and thereby sets the data reach of everything an editor runs — without holding credentials.
Confidence. Assumed in prose.
Blocks. #768 questions 1-5, and the entire safety argument for
tasks_use, which is literally "somebody else drew the boundary".
Point at this if you disagree: today that somebody is the administrator, and the permissions page describes a two-party structure the code does not implement.
6. Downstream integrator
Code. Named first of three stated audiences
(Documentation/Introduction/Index.rst:19-24) and the only actor with a
machine-enforced contract: Tests/Unit/Api/api-surface.txt (1942 lines) freezes
Ai as the first parameter of every
Agent method (:1130-1138), so no consumer can call the
runtime without constructing an actor. ADR-070 exists because
consumers were calling findOneByIdentifier() and skipping both guards.
Human (unvalidated). Adds AI to their own extension and never owns provider plumbing. Wants a typed exception rather than a silent fallback when a configuration is inactive or restricted.
Confidence. Assumed in prose; enforced by a test, not by a gate.
Blocks. #769 — whether a third-party use-case pack author is a colleague or
a supplier, and therefore whether pack declarations stay advisory
(ADR-168) or become gated.
Point at this if you disagree: every consumer of this API that we know of is one of ours. No third-party integrator is evidenced anywhere.
7. Management-grant holder
Code. None, by design: "a case is only added TOGETHER with its consumer (a
grant nothing reads is worse than none)" (Backend's class
docblock). It was reserved again in ADR-130 and
ADR-131; all three reservations are now retired.
Human (unvalidated). Curates the AI catalogue for their own team while keys and endpoints stay administrator-only.
Confidence. Was required by a planned feature that
ADR-169 section 5 recommended deleting: "Recommendation:
neither name. Close #691 as answered."
Blocks. Nothing any more, and how it stopped is worth recording. ADR-169 was accepted on 2026-08-18 while this persona was still unvalidated, so section 5's recommendation carried. If a validated persona 7 turns up later, section 5 is the decision that was made without them and the one an answer would reopen — not this paragraph.
Contradictions found
A. A control ADR-130 said was inactive went live — RESOLVED, #787.
ADR-130 constraint 3 read "agent_approve is doubly unreachable for
non-admins today — both human approval surfaces sit behind admin gates …
only becomes exercisable for non-admins with the editing module. Stated here so
nobody reads it as an already-active control." It shipped: Modules.php:375-381
registers AgentRunController::approve and submitInput in
nrllm_aitasks, 'access' => 'user' (:344). A non-admin who holds the
grant and whose group has that module ticked decides another user's write today.
ADR-130 now carries :Amended: and a rewritten constraint 3, and
Tests/Unit/Configuration/ApprovalSurfaceInventoryTest.php pins the module
inventory, so a new approval module cannot be registered without ADR-130
naming it. It does not cover every shape an approval surface could take;
ADR-130 says which.
B. One codebase, two answers to "may this person use this model". The Editor
Action Center refuses a user outside the configuration's allowed groups —
EditorActionCatalogue.php:206 via has, with the comment "a
fourth copy of it is the copy that ages" (:195). The task list in the same
module does not: TaskExecutionService.php:63 calls
resolve, which at
ConfigurationResolver.php:50-53 is $configuration ?? … — the task's pinned
configuration, returned unchecked; the method has no actor to check with. A task
pinned to a restricted configuration bypasses that restriction.
C. The bound that justifies the grant is opt-in. ADR-130,
Decision: "The per-user budget pre-flight … bounds what a grant holder can
spend."
Permissions.rst:43-45 states it in its own words, not this one's — "Every run
is pre-flighted against the user's own usage budget and attributed to them."
BudgetService.php:76-79: with no tx_nrllm_user_budget
record for that uid, an inactive one, or one with no limit set, the check returns
allowed(). The budget is per user and opt-in; the grant is per group.
Ticking tasks_use for a group without creating budget records ships an
unbounded spend, and nothing says so.
D. The approver may decide what they may not read.
AgentRunController.php:61-64 — AGENT_READ has no grant equivalent, "so the
approval grant widens the list but not the detail page"; the preview is withheld
on the same ground (WaitingRunViewFactory.php:47, :153-162). A coherent
security position and an incoherent human one. Needs a decision, not a patch.
E. "Operator" is the most-used word for a person in the corpus and is defined
nowhere. 201 occurrences across 57 of 169 ADR files, counted at 96ee600d
and rising with every record that uses the word; no enum case, no gate, no
glossary. It is used for at least three disjoint permission levels: the
administrator configuring records, the systemMaintainer changing extension
configuration (Adr140:164), and a person with shell and cron
(Adr103:23). Meanwhile Permissions.rst:70-72 ships a preset called "AI
operator" that is a non-admin holding one grant, and says so: "identical to
AI editor on purpose".
F. The enum's own invariant no longer holds. Backend's class docblock:
"Each case maps to exactly one enforcement point — there is no wildcard"; the
TASKS_USE docblock names two. There are nine, across task execution, a module
index, the Editor Action Center and a record context menu. That is a role-ladder
rung, which ADR-130's Context promised the design would not
grow.
Personas we may have invented
These exist because a gate exists, not because anyone does the job. A reviewer should read this section first.
- The approver, as a distinct person. The gate is real and tested. But ADR-130 calls it "deliberately the only scope with a grant equivalent" — a design choice, not an observed org chart. Until contradiction A shipped, no non-admin could reach it at all. We built the separation before meeting anyone who wants it.
- The input submitter.
ResumeCoordinator.php:650calls the same predicate as:205, so submitting is the approver's grant. ADR-150 reasons about them as a distinct actor with a different danger model — and no production tool can produce one:InputPauseCoverageTest::INPUT_REQUIRING_TOOLS = []pins the empty list, and nothing inClasses/implementsRequires.Input Interface - "AI operator." A documented role whose only distinguishing feature is a grant that does not exist, using the corpus's most loaded word to mean its opposite.
- The lead editor. One clause, once: "and a lead editor needs the same for
their editors, or for a group" (
Adr119:79). Nothing implements it; budgets have no group dimension.Adr157:109-113is the one surface that tried and declined on principle: "A group is not resolvable to an acting backend user … A group entry would have been a control with no reader." It is nonetheless ADR-119's stated trigger for moving the whole module tree. - The run owner.
AiActorContext.php:233is real, but "owner" is a column onAgentRun, not a job; everytasks_useholder becomes one by pressing execute. Its concern — a write under your identity because somebody else approved it — belongs to the editor, where a human can be asked about it. - The anonymous caller and the backend group. A fail-closed null object
(
AiActorContext.php:72-79) and the unit of entitlement (Backend's class docblock). Both are load-bearing; neither is a person.User Grant - The service account. A machine, not a persona — and the counter-example to
ADR-023: fail-closed from its first line. One exists,
cli:nrllm:agent:cancel(CancelAgentRunCommand.php:69), declaring one of the five scopes.
The documented failure: ADR-023, withdrawn by ADR-117
ADR-023 registered eleven Model cases as backend-group
permissions. Three assumptions about people were built in, and ADR-117 verified
each against the code:
That capability describes a job. ADR-023 put the flags on the group rather
than the configuration because "capability is an editor-role concern, not a
configuration concern" (:98). Half the execution paths have no person on
them: streaming bypasses the pipeline, and the queue worker never populates
$GLOBALS['BE_USER'] — so the same run "would be denied synchronously and
allowed after enqueue" (Adr117:49-57). A role-shaped control could
not describe a queue.
That the person being restricted can reach the product. They could not. "All
thirteen nr_llm backend modules are 'access' => 'admin' and administrators
bypass the check by definition, so unticking a capability would change nothing
inside nr_llm's own interface" (Adr117:59-63). It was a permissions model for
a user who did not exist on the system.
That absence of a person means nothing to restrict. is
returned true with no backend user — the natural code to write when thinking
about a human being restricted. Take the caller seriously and "no person" is
the case that most needs denying.
Verdict, Adr117:78-79: "a control that has to be labelled 'has no effect' is
worse than its absence." The correction was to reason about an actor, which
may not be human, rather than a role, which always is — and it is why
Ai exists.
One consequence is under-read. ADR-130 pushed roles out of code and into
documentation ("Roles are documentation-level presets (named grant bundles), not
code", :36). The documentation then invented "AI operator" and "task
managers". A role nothing enforces is the same defect as a grant nothing reads,
one layer up.
What TYPO3 gives us and what it does not
TYPO3 gives roles: three enforcement primitives, all of which nr_llm uses.
admin is the whole default posture. user on a module means one tick in a
group's module list and nothing more — ADR-131 records the verified trap
that 'user,group' denies everyone. systemMaintainer we never check but
depend on.
nr_llm adds a fourth axis TYPO3 has no vocabulary for: customPermOptions
grants frozen into Ai at the HTTP boundary — a capability, not
a role, and group-scoped. A fifth has no name in either vocabulary: a
configuration's allowed_groups (ADR-070). It decides what the
Editor Action Center offers and became a simulated axis only on 2026-08-13
(ADR-167). It is a permission that looks like a configuration
field. If this work invents one word, this is where it is needed.
TYPO3 gives no personas. It attaches no goal, context or fear to any role, and neither does this codebase: no surface here records why anyone opens it. That is the gap this record cannot close, and why the section below asks rather than decides.
What a human must answer
Answerable, specific, and free of anything a non-developer would have to look up.
- Is the person who holds the AI provider's API key the same person who decides which AI actions an editor may perform? If not, who is the second person?
- Does anyone at a customer write the AI instructions ("tasks") without being a TYPO3 administrator? If yes, may they also see the API keys?
- Is there a person who reviews and releases an AI-proposed change to a page somebody else asked for? Do they read the page, or only the proposed change?
- Can that reviewer be expected to judge twenty pending decisions produced by one colleague's single click? One editor action over twenty records produces twenty separate decisions today.
- How does that reviewer learn a decision is waiting? Nothing notifies anyone.
- Should an editor be able to run every AI task that exists in the installation, or only some? If only some, what decides which — the person, their team, or the kind of page?
- Are AI budgets set per person or per team, and who sets the number? Today they are per person and opt-in, so a group with no budget records has no ceiling.
- When a governance finding says a setting is wrong, who changes it — and does that person have access to the Install Tool?
- Is anyone outside Netresearch building on nr_llm's API today?
- Do customer installations run backend groups per job ("editors", "leads") or per department ("marketing", "shop")? Both entitlement axes here are group-scoped, and the code cannot tell the two apart.
Consequences
If this record is accepted as it stands:
- Questions 1-10 go to
#768as its blocking input; nothing here answers them. - Contradictions A, B, C and F each get their own issue — an approval control ADR-130 declared inactive but is live (ADR-130 also needs the amendment), a configuration restriction bypassed by a pinned task, an opt-in spend bound sold as a guarantee, and an enum invariant the grant outgrew. C is a defect, not persona work.
- "AI operator" is removed from
Permissions.rstor given content. - No grant, module or field is added by the accepting change. This record adds nothing enforceable — the ADR-023 lesson applied to itself.
Revisit when
- Any question above is answered. A persona whose answer arrives becomes a requirement.
- A non-admin approver exists at a real installation: contradiction A becomes a behaviour question, not a documentation defect.
- A tool implements
Requires: whether the submitter shares the approver's grant becomes a decision rather than an inheritance.Input Interface - A third-party consumer appears, and question 9 above gets a real answer instead of a frozen api-surface file standing in for one.
- A second extension wants to cite these personas. They are not nr_llm's
property —
nr_ai_search,nr_repurpose, the Cowriter and the rest share the same audience, and this record lives here only because the decisions it unblocks (#768,#691) live here. At that point it moves to a shared home and this file becomes a pointer. Citing an nr_llm ADR from a sibling extension would create a dependency between two extensions that nobody decided to have.