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 verdicts — ADR-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.
Editor calls
Editor 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
Agent 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
Agent, 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 | Transcript (ADR-107) run over the ACTUAL
messages of the first Agent 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 Llm — 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:: 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.
Transcripterrs high by construction (ADR-107): a chars/3.5 prose divisor over UTF-8 bytes, plus per-message overhead.Estimator - Over, by a lot, on the high price — whenever a range is shown at all.
maxTokensfor 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.00reads 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::is true when either rate is set andhas Pricing () estimatecharges 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.Cost ()
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
(Budget) 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.
Toolcatches the denial itself and returns a truncated result carryingLoop Service Agent(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 aRun Termination Reason:: BUDGET_ EXHAUSTED Budgeton 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.Exceeded Exception - 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_FAILEDcounts 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: Agent 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:: 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.