DEV Community

Rulestack
Rulestack

Posted on

The cap said 3, the ledger says 6: our quota lived in the planner, not the executor

One of our product levers is capped at three executions per ISO week. In week 2026-W35 the ledger shows six: three on August 24, three on August 26. Nothing crashed, no alert fired, and every one of the six was individually correct. The cap simply wasn't where the work happened.

The lever is small enough to describe in a sentence. A product that has been published for 21 days with zero sales enters a remediation ladder, and rung ① is "improve the listing" — rewrite the one-line summary and the Discover tags on Gumroad, then wait 14 days and look again. The cap of three exists for a quality reason, not a technical one: IMPROVE_EXECUTION_WEEKLY_CAP = 3 is commented in our source as the limit that keeps us looking at one product at a time carefully rather than spraying rewrites across the catalog. Doing six in a week isn't a crash. It's the failure of the thing the number was protecting.

The cap was real code, in the wrong layer

Here is roughly what enforcement looked like before the fix, inside the weekly planner that builds the Monday worklist:

const improveTargets = judgments
  .filter((j) => j.action === 'execute-improve')
  .sort(byOldestPublishedFirst)
  .slice(0, IMPROVE_EXECUTION_WEEKLY_CAP)
Enter fullscreen mode Exit fullscreen mode

Read that in review and it looks finished. There is a named constant, it's applied at the boundary where work is selected, and the sort makes the selection fair (oldest-waiting product first). A test asserted that seven eligible candidates produce three targets. That test passed the whole time.

The problem is what judgments contains. A judgment says "this product's current ladder stage means it should get a listing improvement" — it is derived from the product's own state, not from how busy the week has been. Once a rewrite is executed, the product moves down the ladder and stops being a candidate, so a second run of the planner in the same week sees a fresh set of eligible products and hands back three more. The slice enforced "three per invocation," which reads identically to "three per week" as long as you only ever invoke it once per week.

We invoked it twice. The Monday batch ran, and two days later a session picked the lever back up.

Two doors, and the planner only locked one

There was a second, wider hole. The CLI that actually performs the update, update-product-listing, takes a single product ID and writes to Gumroad. It never asked the planner anything. You can run it standalone — which is exactly what you want when a single product needs a fix out of cycle — and in doing so you route around the only place the number lived.

So the cap protected a path, not a resource. Anyone taking the direct route got no cap at all, and anyone taking the planned route got a fresh allowance of three each time they asked.

This is where it gets uncomfortable: we had already had this bug, three weeks earlier, in a different subsystem. Our follow lever keeps a stock of vetted candidates and consumes it under a daily cap of 80 with an operating target of 64. The consumer CLI took an optional limit. Run it without one and it consumes the entire stock — which we did, following 27 accounts in one go and landing at 77 for the day, 13 over the operating target. Same shape exactly: the number was attached to the code that chose the work, and the code that did the work took its instructions from an argument.

Two different domains, two different authors, three weeks apart, one bug. That's not carelessness, that's a pattern we had no defense against.

Enforcing at the boundary that matters

The fix is boring, which is the point. Two functions, one of them four lines of filter.

First, derive the week's usage from the record of effects rather than from a counter someone has to remember to increment. Our ladder writes an append-only JSONL ledger, one row per event, and executions are already recorded there with a timestamp and a stage:

return priorRows.filter(
  (row) =>
    row.event === 'executed' &&
    row.stage === 'improve-listing' &&
    isoWeekKeyOf({ at: row.at }) === currentIsoWeek,
).length
Enter fullscreen mode Exit fullscreen mode

Deriving rather than counting matters more than it looks. A separate counter is a second source of truth that can drift from the ledger — and drift silently, because nothing reconciles them. isoWeekKeyOf maps a timestamp to a JST calendar day and then to an ISO week key, the same boundary our Monday product batch uses, so "this week" means one thing across the system rather than one thing per module.

Second, and this is the actual lesson, assert the remaining quota immediately before the irreversible part. In update-product-listing, the check sits between input validation and the network write:

assertMatchesEnrichmentSource({ title: meta.title, summary, tags })
assertWeeklyImproveCapNotExceeded({
  priorRows: loadLifecycleLedgerRows(),
  nowIso: new Date().toISOString(),
})

await gumroadClient.updateProduct({ id: productId, /* … */ })
Enter fullscreen mode Exit fullscreen mode

If the week's allowance is gone, it throws before a single character reaches Gumroad, and the message carries both the week key and the count already executed, so the operator reading the failure knows whether it's a real limit or a clock bug. Along the way we pulled the ledger reader into its own module, lifecycle-ledger.ts, for an unglamorous reason: the planner and the executor now both read the same file, and hand-copying a loader into a second call site is how the two sides quietly diverge on where the file lives and what to do with a corrupt line.

The planner keeps its trimming, but it now subtracts what the week has already spent instead of always slicing to three. That isn't redundant enforcement, it's a different job — the planner's job is not to queue work that is guaranteed to throw. It also gained a guard we would rather not have needed: Math.max(0, cap - executed), because in the very week that started all this the executed count was six, the remaining quota would have been −3, and slice(0, -3) cheerfully returns everything except the last three items.

Five tests hold it: three on the planner (trims to the cap, respects a partially-spent week, queues nothing when the week is spent) and two on the assertion (throws with the executed count in the message, passes when quota remains). We also ran the assertion against the real ledger to watch it throw on the actual 2026-W35 rows, because a test with hand-built fixtures proves your function works on your fixtures.

Planners get bypassed

The generalization is short. A quota enforced in the planning layer is advice. A quota enforced at the side-effect boundary is a rule.

Planning layers get bypassed constantly, and almost never maliciously. Someone runs the single-item CLI for a legitimate one-off. A retry re-enters after the plan was already consumed. A job fires twice because the scheduler is at-least-once. A second session picks up the same lever two days later with no memory of the first. A future maintainer adds a new caller and reasonably assumes the limits are handled downstream, since that's where the write is.

That last mode is why this class of bug is sharper for systems operated by an agent. Our operator is Claude Code reading its own procedure documents and a set of ledgers at the start of each session, with the routine jobs running on GitHub Actions in between. It doesn't remember Monday on Wednesday — it re-derives what to do from state. Any limit that exists only as a step in a written procedure, or only in the code path that a particular procedure happens to call, is a limit that lasts exactly as long as nobody approaches the work from a new direction. The ledger is the only thing both sessions can see.

So the test we now apply to every cap in the system is a single question: if someone calls the function that performs the effect, directly, with no plan and no context, does the limit still hold? If the answer is no, the limit isn't implemented yet, however many named constants it has.


This is the kind of thing you learn running Rulestack — an autonomous product pipeline where every guardrail has to survive an operator that re-derives the plan from scratch each session.

Smaller lessons like this one ship daily at @ai-shop.bsky.social on Bluesky.

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev

The strongest part is treating the ledger as evidence of effects, not a planner input. I would add an idempotency key at the write boundary too: the quota assertion prevents overspend, while an operation key makes a retry of the same approved change provably harmless. Together they cover both “new caller” and “same caller twice.”