Skip to content
MCP Five

The LedgerProject #539 / 51

A budget is not a gate

The same tool costs 0.22¢ or 6.0¢ depending on its arguments. So the rule has to be a number.

Something can now spend your money. You need a rule.

There are two tempting ones, and both are wrong in an instructive way. Working out why is how you arrive at the third.

Part 1 · Tempting design #1 — a budget

Give the run a spending limit. Subtract as you go. Stop at zero.

ts
// ❌
budget -= cost;
if (budget <= 0) throw new Error("out of budget");

It is worse than it looks, and the reason has nothing to do with arithmetic.

A budget is an accounting device. It tells you how much is left. It is not a place where a decision gets made — and pretending it is one means the most consequential moment in the run happens by accident.

Part 2 · Tempting design #2 — project #3's gate

Gate on what the call is. That worked beautifully for destruction: smash_jar always asks, jar_history never does.

It works because destruction is a property of the verb. A tool that deletes things deletes things every time you call it. The name tells you, and reading the arguments covers the rest.

Cost is not like that. And project #5 can prove it with a single tool:

the same tool, the same server, the same user questionestimate
summarise_week { depth: "brief" } — reads the event log, small model0.22¢
summarise_week { depth: "deep" } — every jar's full report, bigger model6.0¢
0.22¢summarise_week, briefwhat it actually cost: 0.11¢
6.0¢summarise_week, deepsame tool, same name, ~27× the price
the default ceilingper request, per server

Twenty-seven times the price, same name. A rule keyed to summarise_week would have to stop both or allow both, and neither is what you want.

And reading the arguments does not rescue it either

You could write matches: (args) => args.depth === "deep". That works — for exactly this server, on exactly this tool, until somebody adds a depth: "exhaustive", or the pantry grows from 60 jars to 600 and brief quietly becomes expensive.

Part 3 · So the rule is a number

below the ceiling   →  runs immediately, logged to the ledger
above the ceiling   →  refused, with the price attached

Two cents per request, per server, by default. Small enough that the demo trips it deliberately; large enough that an honest weekly summary of sixty jars flows through without anybody clicking anything.

Why per server, and not one global budget

Because a server is the unit of trust.

"The pantry may spend 2¢ a call, the kitchen 5¢, and that server I connected once and never audited: nothing."

That is a sentence you can actually reason about. One global budget cannot express it — and worse, under a single pot a compromised server eats everybody else's headroom before anybody notices.

The estimate is what makes a refusal free

This is the part that turned out to matter most, and it fell out of the design rather than being aimed at.

The gate reasons about an estimate, computed before the call. So when it refuses, the host has not contacted the model at all.

The most expensive thing a server can ask for costs exactly as much as the cheapest thing it can ask for: nothing.

Project #3's gate saved you from a destroyed cookie jar. This one saves you from the bill itself — and that asymmetry is the actual argument for gating on price rather than on which tool was called. A destructive-action gate that fires has already cost you the tokens of getting there. A price gate that fires has cost you nothing.

Part 4 · The gate you can unit-test

One property worth calling out, because it is a deliberate choice and it is why this control is actually exercised.

ts
export function classifySpend(
  request: SpendRequest,
  context: SpendContext = {},
): SpendVerdict

It is not async. It does not read the database. It does not touch the network. Everything it needs is passed in.

Part 5 · Two more numbers the gate holds

A ceiling on its own is not enough, and both of these are project #4's seatbelts wearing new hats.

ControlValueWhy
Output clamp1024 tokensA server may ask for whatever maxTokens it likes. It gets this or less. maxTokens: 1_000_000 is a denial-of-wallet attack that arrives looking exactly like a JSON field.
Per-run quantity cap12 sampling requestsA per-call ceiling with no total is a machine for making a thousand cheap calls. Nineteen requests at 1.9¢ each, every one under a 2¢ ceiling, is 36¢ nobody approved.

The quantity cap is checked first, before the price — because a server making its thirteenth cheap request is a runaway regardless of how cheap each one was.

Part 6 · Estimate high, bill honestly

One last detail, and it is the only file in the repo whose correctness expires.

lib/pricing.ts holds the price table. Sonnet 5 was on an introductory rate with an end date. Hard-code the discounted number and every downstream calculation reports to two decimal places, with total confidence, and is wrong from the day the discount ends.

So: encode both rates and the date the intro ends, and stamp PRICES_CHECKED_ON where the UI and the checkpoint scripts print it.

Now for the part that took longest to get right: this gate cannot do the one thing project #3's gate was built around.

The gate that cannot pause →

What you now know

  • A budget truncates at an arbitrary point; it is a backstop, not a decision.
  • Destruction is a property of the verb; cost is not. The same tool is 0.22¢ or 6.0¢.
  • Matching on arguments only correlates with cost — the consequence has to be computed.
  • Ceilings are per server, because a server is the unit of trust and a shared pot lets one server eat everyone's headroom.
  • Gating on the estimate is what makes a refusal cost $0.00.
  • Keep the gate synchronous and dependency-free so it can actually be tested.
  • Clamp output, cap quantity, and make a price table's expiry date part of the value.