ADR 109 · Accepted
ADR-109: Agent Runs approvals inbox (backend module)
Context
The human-in-the-loop suspension features had service plumbing but no operator UI. A run can suspend WAITING_FOR_APPROVAL (ADR-084) or WAITING_FOR_INPUT (ADR-105); 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
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.
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>.
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.
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 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 (approval suspension), ADR-105 (typed input suspension) and ADR-064 (event privacy).