---
title: "ADR-162: Bulk editor actions are N ordinary runs, not a bulk runtime"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-162@0.35"
source: "Adr/Adr162BulkEditorActionsAsOrdinaryRuns.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-162: Bulk editor actions are N ordinary runs, not a bulk runtime

-   *Status:* Accepted
-   *Date:* 2026-08-11
-   *Amends:* [ADR-158](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-158@0.35) (its rejection of a bulk surface)
-   *Authors:* Netresearch DTT GmbH

## Context

[ADR-158](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-158@0.35) gave an editor one action on one record. The next thing
an editor asks for is the same action on the twenty pages they just imported, and
both earlier records refused it.

[ADR-152](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-152@0.35) declined a `bulkCapability` flag on the declaration.
[ADR-146](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-146@0.35) put "what did the approver agree to" on its
`Revisit when` list. ADR-158 rejected a bulk surface outright. All three cite
the same reason, and it is a good one: **the approval unit is a turn.** One
suspension produces one digest and one verdict for every pending call in it, and
[ADR-133](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-133@0.35) explicitly refuses a per-call verdict — an approver
either takes the turn or refuses it. "Approve 200 writes" is not a decision a
card can carry. [ADR-141](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-141@0.35)'s write fence stamps ONE pending effect
per run row, so a runtime that folded many records into one run would also have
to invent a second stamping rule.

## Decision

> **A bulk editor action is N ordinary agent runs. There is no bulk runtime, no
> batch approval, no shared digest and no queue of its own.**

That single sentence is also the answer to the objection all three earlier
records raised. The objection is about one run holding many writes. It says
nothing about many runs:

-   N runs are **N turns** — one suspension each.
-   N turns are **N digests** and **N verdicts** — [ADR-133](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-133@0.35) is
    satisfied without an exception, because no approver is ever asked to decide
    more than one turn at a time.
-   N runs are **N run rows**, so [ADR-141](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-141@0.35)'s one-pending-effect
    fence stamps once per row, unchanged.
-   Budget, audit, routing, tool policy and idempotency see N indistinguishable
    single-record runs, because that is what they are.

Nothing about the approval unit changes, and that is the whole reason this is
buildable now while a `bulkCapability` flag still is not.

**Per record, the catalogue is asked again.**
`EditorActionBatchPlanner` calls
`EditorActionCatalogueInterface::runRequestFor()` once for each record and
collects the answers. A record the viewer may not act on comes back as null and
is recorded as skipped **with a reason an editor reads**, on the confirmation
page and again in the flash messages after the batch. It is never dropped
quietly, and the batch is never authorised once and applied to the rest.

The declaration used for the page heading IS resolved once for the batch — a
translatable name has to come from somewhere — and that is presentation only.
Authorisation stays at one `runRequestFor()` per record.

**Honest about what that check does today.** `runRequestFor()` authorises on
tool, table, configuration and viewer, and validates the record number only for
being a positive integer. Asking it twenty times therefore returns twenty
identical answers right now. It is still asked per record, because the catalogue
is the seam that owns the question: a per-record axis added there — a
record-level permission, a workspace state, a lock — must reach this surface
without anyone remembering to come back and change a loop.

**A bound of 20 records.** Two real constraints set the ceiling. The batch runs
its N runs **synchronously inside one backend request**, using the same
`AgentRuntimeInterface::run()` the single-record path uses, and each run
costs at least one provider round trip; a batch that outlives
`max_execution_time` is truncated mid-loop, which is the one failure mode that
leaves an operator unable to say what started. And each suspended run is its own
inbox card with its own verdict, so the number also bounds how many decisions one
press hands an approver. Twenty is a judgement inside those two bounds, not a
measurement. Raising it means moving the loop onto
`AgentRuntimeInterface::enqueue()`, which is a separate decision with its own
failure modes; this record does not take it.

## The cost estimate, and how wrong it is

The confirmation page shows requests, tokens and a price range before anything
starts. Every number is derived from something the plan already holds. None is a
typical-case guess, because a made-up range is worse than no range.

| Number | Where it comes from |
| --- | --- |
| Runs | The count of records the catalogue offered — the plan's own entries. |
| Provider requests | Runs × 2. Two is derived, not assumed: the first send decides the tool call and the declared write suspends ([ADR-134](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-134@0.35)); the send after approval turns the tool result into the answer. It is a FLOOR, and the page says "at least". |
| Input tokens | `TranscriptEstimator` ([ADR-107](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-107@0.35)) run over the ACTUAL messages of the first `AgentRunRequest` the plan built, plus the JSON schema of the one tool the run may call, taken from the registry with the run's own allow-list. Every request in a batch differs only in a record number, so the first stands for all of them. |
| Output ceiling | The configuration's own `maxTokens`. What the provider is asked to respect, not a guess at answer length. A `maxTokens` of **0 means unbounded** on `LlmConfiguration` — no `max_tokens` goes on the wire at all — so it is reported as "no ceiling", never as a ceiling of zero. |
| Price range | `Model::estimateCost()` on the model record's stored per-million prices. Low assumes no output at all; high assumes every request returns the full ceiling. Shown only when **both** rates are stored **and** a ceiling exists. |

**How wrong it can be**, and the errors do not cancel:

-   **Under, materially.** The loop's `assemble()` step prepends the
    configuration's system prompt and its skills before the first send. Those are
    not in the request the plan holds, so they are not counted. A long system
    prompt can be larger than the whole measured prompt.
-   **Under.** The second send carries the first send's transcript plus the
    assistant tool call plus the tool result. The estimate charges it the initial
    transcript, which is strictly smaller.
-   **Over.** `TranscriptEstimator` errs high by construction
    ([ADR-107](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-107@0.35)): a chars/3.5 prose divisor over UTF-8 bytes, plus
    per-message overhead.
-   **Over, by a lot, on the high price** — whenever a range is shown at all.
    `maxTokens` for every request is a ceiling an editorial change never
    reaches. The upper bound is a ceiling, not a forecast.
-   **Stale money.** Prices are what an administrator typed on the model record.
    Nothing in this extension refreshes them.
-   **No range at all** in three cases, and the rule behind all three is the same:
    a bound of `0.00` reads as "free" when it means "unknown", so no bound is
    printed unless it can be computed. The configuration names no model. The model
    is priced on **one side only** — `Model::hasPricing()` is true when
    either rate is set and `estimateCost()` charges the missing one as zero,
    which would price a whole output ceiling at nothing, so both rates are
    required here. Or the configuration sets **no output ceiling**, which leaves
    the upper end unbounded and therefore unquotable.

The estimator's learned calibration factor is deliberately NOT applied: it
belongs to a live context window, and borrowing it would make the same batch
quote different numbers on different days for no reason an editor can see.

Historical per-action usage was considered as a source and rejected on the
facts: `tx_nrllm_service_usage` aggregates per day, service type and provider.
It has no per-tool and no per-editor-action dimension, so it cannot answer "what
did this action cost last time".

## Consequences

**A half-done batch is real, and it is visible.** The budget gate
(`BudgetServiceInterface`) is hit once per run, which is correct — N runs are
N spends. An operator who starts twenty runs on an account with room for eleven
gets eleven. This record does not pretend otherwise:

-   The loop **stops at the first run the budget refuses**. Continuing would
    produce nine more identical denials and nine more empty run rows.
-   **A budget-denied run settles COMPLETED, not FAILED**, and the stop is
    detected on that shape. `ToolLoopService` catches the denial itself and
    returns a truncated result carrying
    `AgentRunTerminationReason::BUDGET_EXHAUSTED`
    ([ADR-092](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-092@0.35)); the executor has no arm for that, so the run is
    settled as an ordinary completion with **no error on the result**. The batch
    therefore reads the loop result's termination reason. It also accepts a
    `BudgetExceededException` on the result's error, because the loop's
    no-tools branch sends outside that catch and the exception does propagate
    there — but a guard that watched only the error would be dead code on the path
    an editor action actually takes.
-   The record the budget refused is **not** among the never-started: it was run.
    The message says the batch stopped, and names the records the stop kept from
    starting only when there are any — a denial on the last record still reports
    the stop.
-   Every other terminal outcome is per record and does **not** stop the batch: a
    model declining one page is not a reason to abandon the other nineteen. They
    are counted **by kind**, with the same partition the single-record path uses:
    a run that failed, one a guardrail stopped, one that was cancelled and one
    that simply proposed nothing are four different things to an editor, and a
    batch in which everything failed must not read like a batch in which nothing
    needed changing. `SUSPEND_FAILED` counts with the failures — an approval was
    required and could not be stored, so nothing can resume it.

The mitigation that makes a half-done batch survivable is the one the design
already relies on: because a declared write suspends before it touches anything
([ADR-134](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-134@0.35)), a batch that dies at run twelve has produced eleven
*proposals*, not eleven writes. Nothing is half-changed; some approvals exist and
some do not.

**The plan is rebuilt on the POST.** The confirmation page's plan is not carried
into the request that starts the batch — a plan carried across a request is a
permission carried across a request. `startBatch` re-plans from the same raw
inputs, so a POST naming records the GET never offered starts nothing.

**Duplicates and junk are reported, not absorbed.** A record number named twice
is planned once and the second mention is listed as skipped. Entries that are not
record numbers at all are counted and reported. Records beyond the cap are listed
as skipped rather than truncated away.

**A second ceiling bounds the input, not the runs.** The cap of 20 bounds how
many runs START. It does not bound how much is parsed: every number past it
still becomes an entry, a table row and a record number in a session-stored
flash message, so a pasted megabyte would build a page nobody can read. The raw
list is therefore cut at `MAX_INPUTS` — five times the cap, which leaves room
for duplicates, junk and a generous over-paste — and the cut is reported on the
page and in the messages. It falls on a separator, never inside a number: a
truncation that shortened `1234` to `12` would name a different record. The
count of what was dropped is deliberately NOT given, because counting the tail
means parsing the tail, which is the work the ceiling exists to refuse.

**The entry point takes record numbers, and does not pick them.** ADR-158
withheld a record picker because a picker is a read boundary
([ADR-130](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-130@0.35)), and that reasoning is unchanged: this surface
enumerates nothing and reads no record. It is handed a table and a list of
numbers — seeded from the record the context menu carried — and passes them on.
The consequence an editor sees is a text field of numbers rather than a list of
titles.

## Alternatives considered

**One run holding N tool calls.** Rejected — this is the shape all three earlier
records refused, and refusing it is the premise of this one. It would need a
per-call verdict ([ADR-133](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-133@0.35) says no), a digest format for many
writes, and a second write-fence rule for many pending effects on one row
([ADR-141](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-141@0.35)).

**A queue of its own.** Rejected: `AgentRuntimeInterface` already has
`enqueue()`, and a second one would be a second lifecycle. Not used either
— its transport is the operator's choice and defaults to synchronous, so it does
not remove the timeout the cap exists for, while adding an outcome shape this
surface would have to explain. The cap is the honest answer until asynchronous
execution is a decision someone takes deliberately.

**Continuing past a budget denial.** Rejected: every later run hits the same
exhausted bucket, so the only product is noise — nine more failed rows and nine
more messages saying the same thing.

**Reading the record list's clipboard for the selection.** Not built.
`Clipboard::elFromTable()` answers for the CURRENT pad, and the "normal" pad
holds a single element, so what an editor gets from a tick-and-click depends on
pad state this record's authors did not verify. Shipping a selection source whose
behaviour is assumed would be worse than a field an editor fills in. The entry
point takes a plain list of numbers, so a verified clipboard reader can feed it
later without changing anything else.
