---
title: "ADR-105: Typed user-input suspension (WAITING_FOR_INPUT)"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-105@0.35"
source: "Adr/Adr105TypedInputSuspension.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-105: Typed user-input suspension (WAITING_FOR_INPUT)

-   *Status:* Accepted (the approval+input registration ban is widened by [ADR-134: A builtin's declared write effect implies human approval](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-134@0.35))
-   *Amended:* 2026-08-09 by [ADR-134](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-134@0.35)
-   *Date:* 2026-07-22
-   *Authors:* Netresearch DTT GmbH

## Context

Human-in-the-loop so far means *approval* ([ADR-084](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-084@0.35)): a tool
opts into a verdict, the run suspends WAITING_FOR_APPROVAL, and `approve()`
continues it with approve/deny. But some tools need the human to supply
**typed data**, not a verdict — a target the model cannot know, a value only a
person can decide. The P1 roadmap's final queue-epic slice asks for exactly
this: a tool that suspends the run to collect schema-validated input and
resumes with it fed back into the tool's arguments.

## Decision

A new suspend kind, routed by the persisted status discriminator:
WAITING_FOR_APPROVAL → `approve()`, **WAITING_FOR_INPUT → \`\`submitInput()\`\`**.
The two never share a code path after the row is loaded, so the persisted state
needs no "kind" field.

-   **Tool signal.** A tool opts in with the `RequiresInputInterface` marker
    (the sibling of `RequiresApprovalInterface`), declaring an input schema via
    `getInputSchema()`. When the model calls an *offered* such tool, the loop
    throws `ToolInputRequiredException` carrying a `SuspendedRunState` whose
    new `inputToolName`/`inputSchema` fields name the target and its schema.
    The runtime's ladder catches it right after the approval arm (before the
    guardrail pair and the generic `Throwable`) and persists WAITING_FOR_INPUT.
-   **Reuse.** The status is stored in the existing `suspended_state` column
    (no new column), the guarded suspend/claim transitions mirror ADR-084
    (`suspendRunForInput` / `claimForResumeFromInput`), the lease is cleared
    so the reaper ([ADR-104](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-104@0.35)) ignores a waiting run, and the
    MAX(sequence)+1 resume-position resolution is unchanged.
-   **Divergence — validate before claim.** `submitInput()` validates the
    submission against the declared schema (via the existing structure-only
    `JsonSchemaValidator`, [ADR-082](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-082@0.35)) **before** probing or
    claiming the run. An invalid submission is rejected without consuming the
    claim, so the run stays WAITING_FOR_INPUT and the user can resubmit — a flow
    approve/deny has no analogue for.
-   **Overlay.** On resume, the human's validated values are overlaid onto the
    target call's arguments **bounded to the schema-declared keys**: the model's
    own values for those keys are stripped and only declared keys from the human
    are merged, so neither side can smuggle a value into the other's field.

## Fail-closed rules

-   **A degenerate schema is corruption, never accept-all.** An empty/shapeless
    `inputSchema` (against which `validate()` returns true for anything) is
    rejected at both the capture-time gate (`LogicException`) and the rehydrate
    gate (`CorruptSuspendedStateException`) — one shared predicate
    (`InputSchema::isUsable()`) is the single authority.
-   **A tool may not be both approval- and input-gated.** The approval-resume
    path carries no input and would silently drop the mandatory data; the
    combination is rejected at tool registration, and — defence in depth —
    `resume()` refuses an input-requiring pending call rather than fail-open
    executing it.

    [ADR-134: A builtin's declared write effect implies human approval](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-134@0.35) widened the ban without changing this reasoning: a declared
    write effect became a second way to be approval-bound, so a non-remote,
    write-declaring tool may not implement `RequiresInputInterface` either. The
    `resume()` refusal named above is what makes that combination permanently
    unexecutable, not what handles it. Read ADR-134 for the ban in force.
-   **An unstorable suspension fails closed** as `SUSPEND_FAILED` (as approval
    does): promising an input flow that cannot be resumed would strand the client.
-   **Submitted values are untrusted content** entering the model context; the
    submit entry point is admin-gated, which is the injection mitigation
    (structure-only validation does not sanitise content).

## Consequences

-   `AgentRunOutcome` gained `AWAITING_INPUT`; `AgentEventKind` gained
    `INPUT` (payload `{submittedBy}` only, never the values, ADR-064);
    `AgentRunStatus::WAITING_FOR_INPUT` already existed and needed no change.
    All are the documented minor-release growth path — consumers match with a
    default arm.
-   `AgentRuntimeInterface` gained `submitInput()` (an interface method, not a
    new public service — the audited public-service count is unchanged);
    `AgentRunRepositoryInterface` gained `suspendRunForInput()` and
    `claimForResumeFromInput()`; `ToolLoopServiceInterface` gained
    `resumeWithInput()`.
-   Accepted limitations: validation is structure-only (no min/max/enum/pattern —
    a tool needing those re-checks in `execute()`, as `completeStructured`
    does); a single turn requesting two tool inputs is fail-closed-refused on the
    second, not collected; the schema is delivered in the suspend response, not
    via `status()` (which strips `suspended_state`), so a client reloading
    mid-wait loses the form — matching the existing approval `pendingTools`
    limitation.
