---
title: "ADR-109: Agent Runs approvals inbox (backend module)"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-109@0.35"
source: "Adr/Adr109AgentRunsApprovalsInbox.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-109: Agent Runs approvals inbox (backend module)

-   *Status:* Accepted (stale-review binding superseded by [ADR-132](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-132@0.35))
-   *Amended:* 2026-08-09 by [ADR-132](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-132@0.35)
-   *Date:* 2026-07-22
-   *Authors:* Netresearch DTT GmbH

## Context

The human-in-the-loop suspension features had service plumbing but no operator
UI. A run can suspend WAITING_FOR_APPROVAL ([ADR-084](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-084@0.35)) or
WAITING_FOR_INPUT ([ADR-105](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-105@0.35)); `AgentRuntime::approve()` and
`submitInput()` continue it. Until now the only caller was the admin-only Tool
Playground developer tool — there was no first-class surface for an operator to
find the runs that need a decision and act on them.

## Decision

Ship a new admin-only backend submodule `nrllm_runs` ("Agent Runs") — an
approvals **inbox** focused on the runs that need a human, plus a read-only list
of recent terminal runs for context.

### Progressive enhancement, no AJAX

The page works fully with JavaScript OFF: native `<f:form>` POST to
module-route `controllerActions`, a POST-redirect-GET flush with session flash
messages, native `<details>` for long tool arguments, and native form
validation. The one JavaScript module is enhancement only — it moves focus to a
422 error summary reliably across browsers. There is no JSON/AjaxRoutes path and
no content-negotiation dual-path — a deliberate simplification over the
playground's batch-JSON contract.

Authorization is the module `access => admin` on all three actions
(`list` / `approve` / `submitInput`). A module-route action cannot be
reached without it, so `RequiresBackendAdminTrait` (whose JSON 403 body would
be wrong for an HTML page) is not used here. **Any** admin may act on any run;
the recorded `decidedBy` / `submittedBy` uid is audit-only. The CSRF defence
is the backend module route token the `<f:form action=...>` URL carries
(validated by the `RouteDispatcher`), not `__trustedProperties`.

### Four non-negotiable correctness/security properties

1.  **No-JS coercion.** A native `<form>` posts every field as a string, but
    the validator is strict and `submitInput()` validates verbatim. A
    `SchemaInputCoercer` casts the POST to the schema's declared types AND omits
    empty OPTIONAL fields, so a blank optional integer/boolean does not 422 the
    whole submission; a non-numeric string for an integer is left uncoerced so the
    validator rejects it with a clear per-field error. A shared
    `SchemaPropertyClassifier` is the single `type → control` mapping used by
    both the widget factory and the coercer, so a rendered widget can never drift
    from its coercion.
1.  **Never an empty form.** `InputSchema::isUsable()` returns `true` for a
    scalar top-level schema like `{"type":"string"}`, which would render an
    empty, unsubmittable no-JS form. The input form renders ONLY for an object
    schema with at least one property; anything else is classified `unreadable`
    and shows a fail-closed notice, never an empty `<form>`.
1.  **Stale-review binding.** *(superseded — see the ADR-132 paragraph below)*
    `approve()` binds only to the run uuid and reloads whatever suspended state
    is CURRENT — a deny/continuation can re-suspend the SAME run with a NEW
    pending turn, so a stale tab (or a second admin) could authorize a call the
    operator never reviewed. The reviewed turn's digest (a SHA-256 of the pending
    calls) travels as a hidden field; the controller recomputes it from the
    freshly-loaded current state and refuses `approve()` on a mismatch,
    re-rendering "the pending action changed, please re-review". The narrow
    check→approve window is consciously accepted; eliminating it entirely would
    require a runtime change to `ApprovalDecision` and is out of scope.

    ADR-132 took that runtime change and closed the window. The digest still
    travels from the card, but the controller no longer verifies it: it hands the
    value to `ApprovalDecision` and the verification happens inside
    `ResumeCoordinator::approve()` against the state the resume CLAIM won. The
    controller-side check is gone — it read the row before the claim, so it could
    pass on a turn a concurrent approval had already replaced. A missing digest is
    refused exactly like a mismatching one, which also closes the Tool Playground
    endpoint that this ADR left unbound.
1.  **Honest load errors.** The two new persister queries return `null` strictly
    on a store error (an empty list only when there genuinely are none), so a DB
    hiccup shows a visible error infobox instead of a silently empty inbox that
    could hide waiting runs.

### Accessibility (owner priority)

WCAG 2.1 AA and full keyboard operability are first-class: `h1 → h2` section
landmarks → `h3` per run; native `<button type="submit">` in a labelled
`role="group"`; native `<details>`; `<fieldset>`/`<legend>`/\``<label
for>`` with native ``required`` (preferred over ``aria-required`\`) and a visual
`*`; `aria-describedby` to a per-field description; a focusable 422 error
summary; the operator's raw input preserved across a 422 re-render; non-colour
status. Tool arguments are Fluid-auto-escaped (never `f:format.raw`) — a
stored-XSS guard, since they are model-chosen, attacker-influenceable text.

### Privacy (ADR-064 reconciliation)

The approval/input EVENTS record only who/when, never the values. But the
operator must see the pending call arguments to decide, so the view factory
reads the raw `suspended_state` directly (admin-only, display-only, escaped,
never re-emitted into an event or log). `TerminalRunView` deliberately carries
no suspended-state. This is a bounded, admin-only read, not an ADR-064
violation.

## Consequences

-   New: `AgentRunController` (3 actions), `WaitingRunViewFactory` \+ four view
    DTOs, `SchemaInputCoercer` \+ `SchemaPropertyClassifier`,
    `BackendUserUidTrait` (extracted from `ToolPlaygroundController` and shared),
    `AgentRunStatus::awaitingValues()`, two repository queries
    (`findAwaiting` / `findRecentTerminal`) and their fail-soft persister
    wrappers, Fluid templates/partials, the `nrllm_runs` module + icon + EN/DE
    XLIFF, and an enhancement JS module.
-   No change to `AgentRuntime`, `InputSubmission` or the DB schema/indexes —
    the existing `status_lookup(status, crdate)` index serves the inbox queries.
    `ApprovalDecision` was in this list too; [ADR-132](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-132@0.35) gave it a
    third constructor parameter (`?string $turnDigest`) to carry the reviewed
    turn into the runtime *(superseded)*.
-   Non-goals: no run detail/inspector page, no cancel control, no
    pagination/filter/search, no per-call approval verdicts (approval is
    turn-level), no nested-object input widget (rendered "unsupported"), no JSON
    path.

See also [ADR-084](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-084@0.35) (approval suspension),
[ADR-105](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-105@0.35) (typed input suspension) and
[ADR-064](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-064@0.35) (event privacy).
