Print the list before you touch anything
A batch ceiling refuses work without telling you how much exists. Enumerating first turns an unknown amount of work into a finite list you can decide about.
A process that changes things is given a limit: it may act on at most twenty items per run. That feels like a safety measure. It is a rationing measure, and it is not the same thing.
Nobody knows whether there are twenty-one items or four hundred.
The case this post is about
One batch operation, and the difference between two ways of bounding it:
enumerate the full set, cheaply, with no side effects
print it — the count and the items, before anything is touched
decide proceed, narrow, or stop, with the actual size in view
drain the printed list, in order, until it is empty
The version this replaced had a cap: never act on more than twelve items per run. That felt like a safety property and is not one — it bounds how much damage happens per invocation and says nothing about whether the twelve are the right twelve, because nothing had counted the set first.
This post follows one such run, because a cap on the action and knowing the size before you start look equivalent right up until the enumeration returns ninety items you expected to be three.
The symptom
A run that stops at its ceiling and tells you nothing about what remains. It did twenty things, it reports twenty things, and the question that matters — how much is left — is unanswered. So it gets run again, and again, and the only way to discover the total is to keep going until a run does fewer than twenty.
The second thing is the false reassurance. A cap genuinely limits blast radius per invocation, which is real and is the whole of what it does. It does not tell you whether the work is small enough to do, whether anything in it is surprising, or whether the set is the one you expected — and those are the questions a safety property should answer.

The third is that the cap interacts badly with discovery. If the set is being enumerated as the run proceeds, a cap truncates the enumeration too, so you learn about exactly the first twenty and nothing about whether item twenty-one is the one that would have alarmed you.
The fourth is what happens when somebody raises the cap under pressure. A ceiling with no accompanying knowledge of the total invites exactly one response when a run stops short, which is to increase it — and because nobody knows the size of the set, the new number is as arbitrary as the old one and is chosen while impatient.
The ratchet only goes one way, too. Nobody lowers a cap, because lowering it produces more runs and no new information, so the number drifts upward over the life of a tool until it is high enough never to fire — at which point the protection has been removed by a series of individually reasonable decisions.
Why the obvious fix failed
Raise the cap. The reflex, and it changes a rationing parameter without producing any information. The run goes further and still stops at an arbitrary point, and the question of how much work exists remains unasked.
Remove the cap. Now the run does an unknown amount of work, which is worse in the way the cap was meant to prevent. Both settings share the same defect — neither involves knowing the size of the job — so choosing between them is a choice between two kinds of blindness.
Which is why the argument between them never resolves. Both sides are right about the other's failure and neither position addresses the thing that would settle it, so the discussion recurs every few months with the same two options and the same lack of a number.
Dry-run mode. Much closer, and it is usually implemented as "do the work but skip the writes", which means it takes as long as the real thing and often diverges subtly from it. It also tends to inherit the same cap, so the dry run reports twenty too.
The version that works is not a mode at all but a separate, genuinely cheap operation: list, without the machinery that does the work. Reusing the real code path with the writes disabled keeps the cost, the cap and the complexity, and buys only the absence of side effects — which is the least valuable of the three things you wanted.
Log what you did. After the fact. A record of twenty actions tells you about those twenty, and the thing you wanted to know before starting was how many there would be.
The actual mechanism
Enumerate first, print the list, then drain it. The bound is the list, not a number somebody chose.
enumerate the full set, cheaply, with no side effects
print it — the count and the items, before anything is touched
decide proceed, narrow, or stop, with the actual size in view
drain the printed list, in order, until it is empty
The second line is the whole idea. A printed list of four hundred items is a different object from a counter that says twenty: it is finite, inspectable, and it makes the decision a decision. The run has not yet changed anything, and the person looking at the list can see that item two hundred and six is something they did not expect to be in scope.
The fourth line is what makes the first three worth it. Draining a known list is monotone — each item is done or not, the remainder is always computable, and resuming after an interruption is unambiguous. A capped run has none of those properties, because there is no list to be partway through.
Resumability is the property I underestimated most. It turns an interrupted run from an event requiring judgement — what did it get to, is it safe to re-run, will anything be done twice — into a mechanical continuation. That is worth as much as the visibility, and it falls out of the same change.

The sub-case that determines whether this works at all is whether enumeration is genuinely cheap. It usually is — listing is almost always far cheaper than acting — and where it is not, the honest answer is that you have a different problem and the enumeration itself needs bounding, which should be stated rather than solved with a cap on the acting.
Saying it out loud has value even when there is no fix. "This set cannot be fully enumerated, so the run is unbounded in principle and capped in practice" is an honest description of a real situation, and somebody reading it knows what they are dealing with. A cap presented without that sentence implies a boundedness that was never established.
The fix
enumerate with no side effects, completely, before anything else
print the count and enough of each item to recognise it
bound by narrowing the enumeration, not by capping the action
drain the list, recording progress, so a resume is exact
The separation is the whole thing: enumeration has no side effects and completes, then the drain acts on a list that cannot change under it.
export async function enumerate(): Promise<Item[]> {
const items = await scanLocalState(); // no network, no writes, no early exit
return items.sort((a, b) => a.id.localeCompare(b.id)); // stable: a resume must align
}
export async function drain(items: Item[], ledger: string, max?: number) {
const done = new Set(await readLedger(ledger));
const pending = items.filter((i) => !done.has(i.id));
const slice = max ? pending.slice(0, max) : pending;
console.log(`${pending.length} to do` + (max && pending.length > slice.length
? `; --max=${max} leaves ${pending.length - slice.length} NOT attempted this run` : ""));
for (const i of slice) {
await act(i);
// Append BEFORE the next item. A ledger written at the end makes an interrupted
// run indistinguishable from one that never started.
await Deno.writeTextFile(ledger, i.id + "\n", { append: true });
console.log(` done ${i.id}`);
}
}
Interrupted halfway, then re-run — the second invocation enumerates the same list and skips what the ledger already holds:
$ drain
48 to do
done canonical-paths
done conflict-policy
^C
$ drain
46 to do # not 48, and not a fresh guess
done content-addressed
The bound goes on the enumeration, never on the action: --max reports what it did not attempt, so
a short run and a complete run cannot look alike.
The third line is the distinction that matters most and is the easiest to lose. Narrowing the enumeration — "only items older than a month", "only this directory" — produces a smaller list that is still complete with respect to its own definition. Capping the action produces an arbitrary prefix of an unknown set, which is complete with respect to nothing.
What makes this cheap to adopt is that most tools already enumerate internally; they simply do it lazily, interleaved with the work, and never show it. Hoisting the enumeration to the front and printing it is usually a rearrangement rather than new logic.
The rearrangement does have one real cost: the whole set has to be held before any of it is acted on, which for a very large set means memory and a longer wait before anything visible happens. Neither is usually prohibitive, and both are worth naming rather than discovering in the one case where the set is enormous.
How we proved it
The difference shows up the first time a list is longer than expected. A run that prints four hundred items before touching anything produces an immediate reaction from whoever is watching — and that reaction is the entire value, available before any change has been made.

Resumption becomes exact rather than approximate. With a printed list and a record of what has been drained, an interrupted run resumes at a known position. With a cap, resuming means running it again and hoping the ordering is stable, which it frequently is not.
What I cannot tell you is how often a cap prevented something bad. Caps have been in these tools for a long time and nothing recorded when one fired or what it stopped. It is entirely possible a ceiling saved me from something and I never knew, and I would not claim otherwise — the argument is that enumeration gives you more, not that capping gave nothing.
What I have not solved: enumeration that is itself expensive or unbounded. A set defined by a remote query with no total, or a recursive walk over something unbounded, cannot be fully printed before starting. The pattern needs the list to be knowable, and when it is not, the honest position is to say so rather than to pretend a cap is equivalent.
And a limitation of printing: a list of four hundred items is not read. Printing the count and a sample, with the full list written somewhere, is the version that survives contact with a terminal — and that is a small concession that slightly weakens the "somebody will notice item two hundred and six" claim I made above.
The thing that reframed it was noticing that a cap answers "how much will this do" and never "how much is there". Those sound adjacent and are completely different: the first is a property of the run, chosen by me, and the second is a property of the world. Only the second one can surprise you, which is exactly why it is the one worth knowing before you start.
What this generalizes to
A limit on action is not knowledge about scope, and the comfort of having set one often substitutes for finding out.
The pattern recurs across operations. A migration that processes a thousand rows per run with no count of the table. A cleanup that deletes at most a hundred files without saying how many match. A notification job capped per batch against an unknown queue. In each case the ceiling makes any single run survivable and leaves the total unknown, which means nobody can say whether the work is nearly finished or has barely begun.
That question — nearly finished or barely begun — is usually the one somebody actually wants answered, and it is the one a capped run is structurally incapable of addressing. You can run it a hundred times and still not know, because every run reports the same number by construction.
So the question worth asking before any bounded operation is how many are there — and if the tool cannot say, then the cap is not protecting you from the size of the job, it is protecting you from finding out.
That is a harsher phrasing than I would have accepted a year ago, and I have come to think it is accurate. A cap is genuinely useful as a second line — a backstop against a bug in the enumeration itself. As the only bound, it is a way of operating indefinitely without ever confronting the size of what you are operating on.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".