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

Status

Accepted

Date

2026-08-11

Amends

ADR-158 (its rejection of a bulk surface)

Authors

Netresearch DTT GmbH

Context 

ADR-158 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 declined a bulkCapability flag on the declaration. ADR-146 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 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'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 verdictsADR-133 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'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); 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) 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): 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 onlyModel::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); 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), 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), 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 says no), a digest format for many writes, and a second write-fence rule for many pending effects on one row (ADR-141).

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.