---
title: "ADR-200: A denied approval tells the model who declined it"
manual: "TYPO3 LLM Extension"
version: "main"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-200@main"
source: "Adr/Adr200ADeniedApprovalSaysWhoDeclined.rst"
rendered: "2026-09-24T12:49:43+00:00"
---

# ADR-200: A denied approval tells the model who declined it {#adr-200}

-   *Status:* Accepted
-   *Date:* 2026-09-23
-   *Authors:* Netresearch DTT GmbH

## Context {#adr-200-context}

When a human denies a pending tool call ([ADR-084](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-084@main)), the
resume feeds one tool result per pending call back to the model and the
model answers from it. Until now that result read
`Error: tool "update_page_metadata" was denied by the operator.`

On the Netresearch demo a chat user asked for a meta description, pressed
*Deny* on the approval card himself, and was told: "I could not set the meta
description because the write was refused by the system/operator" (demo
conversation 102, run 179: `approved = false`, `decidedBy` = the chatting
user). The model could not know better. "The operator" names nobody, and it
is the one word in the result that says who decided.

Both facts the model needs are already in scope where the result is
written: `ToolLoopService::resume()` receives the decider's backend user
uid, and the execution context carries the run owner, because the resume
runs under the owner's identity ([ADR-083](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-083@main)). In the chat the
run owner is the person the model is talking to.

## Decision {#adr-200-decision}

The result of a denied call leads with fixed tokens, then says in plain words
who declined and what the model should tell the user:

```
Error: approval_denied (decided_by: run_owner). The approval for tool
"update_page_metadata" was declined by the user who started this run, the
person you are talking to. Nothing was executed. Tell them that they
declined the approval themselves ...

```

`decided_by` takes three values, exposed with the reason token as
constants on the `@api` interface `ToolLoopServiceInterface`, so the
API-surface snapshot guards the exact strings:

-   **`run_owner`**

    The decider is the backend user the run belongs to.

-   **`other_user`**

    Someone else decided — an approver in the AI Tasks module
    ([ADR-133](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-133@main)). The model is told "another backend user", not
    a uid: it does not need one to answer, and the person in the chat does not
    need to learn it from the model.

-   **`unknown`**

    No decider uid was passed (a bare loop consumer). The result says "the
    human reviewer" and tells the model not to call it a system error.

The `Error:` prefix and `toolIsError = true` stay, so every consumer that
tells a refusal from an execution by those still does.

## Consequences {#adr-200-consequences}

✓ The model can say "you declined the approval" to the person who did, and
"another user declined it" otherwise. Neither is a guess from prose.

✓ The token is stable text, so a consumer that wants to react to a human
"no" — rather than to a failure — can match it without parsing a sentence.

✕ The result is longer, and it is instruction-like text inside a tool
result. It is written by this extension, not by a tool or a user, so it
carries no injection risk the old sentence did not.

✕ The persisted `tool` event carries the new text only at the `full` and
`redacted` privacy levels ([ADR-064](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-064@main)); at the default
`metadata` level the result is stripped as before, and the `approval`
event's `approved` and `decidedBy` remain the audit record.

## Revisit when {#adr-200-revisit}

The chat or another consumer needs the reason as structured data rather
than as a tool result the model reads — then it belongs on the
`RunStep` of the refused call, next to `ToolOutcome`.
