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 hasGrant(), mayAccessSession() and mayActOnRun() (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)" (BackendUserGrant'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: `TaskExecutionController:198, 293``; ``AiTaskController:69, 98``; ``EditorActionController:78, 109, 170, 210`; 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. BackendUserGrant::AGENT_APPROVE; the grant branch in AiActorContext::mayActOnRun() (: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: TaskExecutionService::execute() takes (Task, string, ?int $beUserUid) — no actor, nothing to check against. tasks_manage occurs once in Classes/, in BackendUserGrant'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 AiActorContext as the first parameter of every AgentRuntimeInterface 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)" (BackendUserGrant'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 hasAccess(), 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 resolveEffectiveConfiguration($task->getConfiguration()), 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-64AGENT_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. BackendUserGrant'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:650 calls 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 in Classes/ implements RequiresInputInterface.
  • "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-113 is 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:233 is real, but "owner" is a column on AgentRun, not a job; every tasks_use holder 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 (BackendUserGrant's class docblock). Both are load-bearing; neither is a person.
  • 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 ModelCapability 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. isAllowed() 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 AiActorContext 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 AiActorContext 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.

  1. 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?
  2. Does anyone at a customer write the AI instructions ("tasks") without being a TYPO3 administrator? If yes, may they also see the API keys?
  3. 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?
  4. 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.
  5. How does that reviewer learn a decision is waiting? Nothing notifies anyone.
  6. 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?
  7. 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.
  8. When a governance finding says a setting is wrong, who changes it — and does that person have access to the Install Tool?
  9. Is anyone outside Netresearch building on nr_llm's API today?
  10. 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 #768 as 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.rst or 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 RequiresInputInterface: whether the submitter shares the approver's grant becomes a decision rather than an inheritance.
  • 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.