Treat Unset as Zero: Hard Caps, Approval Thresholds and Which Way a Kill Switch Fails

In early October Simon Willison made the case for hard budget caps being on by default for anything billed by usage. Once you hit the cap the service returns errors, and if you want to spend beyond it you have to opt in. Coding agents make it far easier to spin up code that costs money, and he expects most people would rather see an error than a surprise bill of $10,000 or more. The post had over 600 points and 300 comments on Hacker News when checked on 29 October 2026.

Correction (2 November 2026, 18:45 Taipei): the opening now gives Willison’s argument and bill figure as he wrote them, the Hacker News counts carry the date they were checked, and the fail-safe example now refers to doors (suggested by Claude, checked by Codex (gpt-6-astra)); parseAmount checks the decimal format first, the kill switch cache is guarded by a mutex, and the claim about what the five-second TTL covers is corrected (suggested by Codex (gpt-6-astra), checked by Claude).

His examples are cloud bills, but systems that move money have the same problem with less room for error. Nobody looks at a limit, a threshold or a switch while things are working. They matter when the value isn’t there: it was never configured, the column is NULL, or whatever stores it has gone down. The default determines what happens next. I made that decision in a handful of places while building a game provider backend prototype in September. The snippets below are cut-down examples run on Go 1.27.1, with their output.

Amounts outside the rules are rejected

Amounts in the prototype are points, stored as numeric(38,15): at most fifteen digits after the decimal point. Before review I found and fixed a few problems in the code, including the lack of an upper limit on amounts. Anything positive was accepted.

I replaced that with two rules. Sixteen or more decimal places causes an error, as does any amount above the hard cap. Rounding is tempting because the request goes through, but the ledger then holds a number the caller never sent, leaving a tiny, unexplained difference at reconciliation. Clamping does more damage. A credit of a million and a bit is booked as exactly a million, and since neither side got an error, nobody finds out until the books are compared. Before counting decimal places, the input has to match a plain decimal format, because big.Rat’s SetString also accepts forms such as 1e-16 and 1/3, which would slip past a check that only counts the characters after the point.

const maxScale = 15

var maxAmount = mustRat("1000000")

// Plain decimals only: big.Rat would also accept "1e-16" and "1/3".
var decimalRE = regexp.MustCompile(`^-?[0-9]{1,23}(\.[0-9]+)?$`)

func parseAmount(s string) (*big.Rat, error) {
	if !decimalRE.MatchString(s) {
		return nil, errors.New("not a decimal")
	}
	if i := strings.IndexByte(s, '.'); i >= 0 && len(s)-i-1 > maxScale {
		return nil, fmt.Errorf("scale %d > %d", len(s)-i-1, maxScale)
	}
	r, ok := new(big.Rat).SetString(s)
	if !ok {
		return nil, errors.New("not a decimal")
	}
	if r.Sign() <= 0 {
		return nil, errors.New("must be positive")
	}
	if r.Cmp(maxAmount) > 0 {
		return nil, errors.New("over hard cap")
	}
	return r, nil
}
100.5                      ok
0.000000000000001          ok
0.0000000000000001         rejected: scale 16 > 15
1000000.000000000000001    rejected: over hard cap
-3                         rejected: must be positive
1e-16                      rejected: not a decimal
1/3                        rejected: not a decimal

A million is just a number for the example. Where the cap sits is a business decision; the engineering decision is that crossing it produces an error the caller can see. Willison argues for the same behaviour: return an error rather than silently make a decision for the other party.

An unset approval threshold means zero

Manual adjustments and manual settlements in the back office need two people at or above a threshold: one requests, the other approves. Below it, one person can go ahead, and the action is still written to the audit log. Each operator has its own threshold, in points.

The mistake to watch for is what the code does before anyone has set the threshold. Go code tends to use a pointer for “may be missing”, and the natural thing to write is “if it’s nil, skip the check”. That means a freshly onboarded operator lets anyone with access make any adjustment alone until somebody remembers to configure it:

// needsApprovalNaive treats "not configured" as "no limit".
func needsApprovalNaive(c Casino, amt *big.Rat) bool {
	return c.ApprovalThreshold != nil && amt.Cmp(c.ApprovalThreshold) >= 0
}

// needsApproval treats "not configured" as zero: everything needs a second person.
func needsApproval(c Casino, amt *big.Rat) bool {
	t := c.ApprovalThreshold
	if t == nil {
		t = new(big.Rat)
	}
	return amt.Cmp(t) >= 0
}

For a 50-point adjustment:

configured=1000   naive=false default-zero=false
never configured  naive=false default-zero=true

So an unset threshold counts as zero and every amount needs a second person. On top of that, changing the threshold always needs a second person, whatever it is being changed to, because otherwise one person could raise it and then make a large adjustment on their own.

The price is a stream of approval requests in the first weeks, and some people will grumble. That is a problem everyone can see, and it gets the threshold set. Defaulting to no limit creates no extra work, but if one person later moves a large amount alone, the audit log is how you find out.

Retry limits depend on the operation

Bets, wins and refunds going to the operator pass through an outbox and are retried with backoff on failure. Without a limit, one operator whose system is down keeps the queue growing indefinitely. With a single limit for every operation, you give up on money that must be delivered.

The prototype treats them differently. A bet is dropped after eight attempts, which is fine because the hot path already issues a compensating refund, so the player is never out of pocket. Wins and refunds are money owed to the player: backoff tops out at 300 seconds, retries continue for 24 hours, and only then does a person pick it up. Every retry loop needs a limit, chosen according to who would be hurt if that payment were abandoned.

If the kill switch can’t be read

The kill switch is how you stop one operator or one game when something goes wrong. It is also a setting, and it can be unreadable: the database is unreachable, a cache node is restarting, the network has a bad moment.

The first design fell back to the database when Valkey failed. I later simplified this to an in-process cache with a five-second TTL, reloaded from the database when the entry expires. The cache only covers whatever is left of the TTL since the last successful load, at most five seconds, so an outage that starts just before the entry expires gets almost no cover. Once it has expired, the decision depends on the path. Money writes fail closed: an unreadable switch is treated as tripped, and no new debits or credits are accepted. Read-only paths, balance lookups and reports, fail open and keep serving.

func (k *KillSwitch) Allow(p Path) (bool, string) {
	k.mu.Lock()
	val, fetched := k.val, k.fetched
	k.mu.Unlock()
	if !fetched.IsZero() && k.now().Sub(fetched) < k.ttl {
		return !val, "cache"
	}
	killed, err := k.load()
	if err == nil {
		k.mu.Lock()
		k.val, k.fetched = killed, k.now()
		k.mu.Unlock()
		return !killed, "source"
	}
	if p == MoneyWrite {
		return false, "source down, fail-closed"
	}
	return true, "source down, fail-open"
}

One KillSwitch is shared by concurrent requests, so mu guards val and fetched. Using a fake clock, with the source going away after t=0:

t=0s source up         money-write allow=true  (source)
t=0s source up         read-only   allow=true  (cache)
t=3s source down       money-write allow=true  (cache)
t=3s source down       read-only   allow=true  (cache)
t=6s source down       money-write allow=false (source down, fail-closed)
t=6s source down       read-only   allow=true  (source down, fail-open)

Wikipedia’s fail-safe article separates fail-safe from fail-secure, also called fail-closed, and points out that they can pull in opposite directions; doors in a building fire are its example, since they have to unlock so people can get out. The kill switch splits the same way. Stopping is the safe outcome for a money write. Continuing is the safe outcome for a read, because support still needs to look up balances and operations still needs reports, and losing those in the middle of an incident only makes it harder to handle.

One of the prototype’s end-to-end tests covers this: it runs the kill switch with Valkey disconnected and expects fail-closed. Without that test, the next person to work on this code could easily switch everything to fail-open to improve availability.

Defaults for missing or unreadable settings

When a value is missing or can’t be read, the default should keep losses small and make the problem visible. An amount out of range is an error, an unconfigured threshold means two people for everything, retries stop eventually, and an unreadable switch halts money writes.

These defaults do make the system more awkward at times, mostly through errors and extra approvals. Willison accepts that too. People can lift a cap if they want, but it should take a deliberate step, and anyone who leaves the setting alone keeps the default protection.

Notes

  • Reject amounts beyond the allowed precision or the hard cap. Don’t round and don’t clamp, so both sides record the same number.
  • An unset approval threshold counts as zero, so everything needs two people, and changing the threshold always needs two people.
  • In Go, the common mistake with a nil pointer meaning “unset” is skipping the check, which quietly makes “no limit” the default.
  • Retry limits are set per operation: a bet with a compensating refund can be abandoned early, money owed to the player is retried for longer.
  • When the kill switch can’t be read, money writes fail closed and read-only paths fail open, with an end-to-end test checking that behaviour.

Further reading

Ideas and technical judgement by Sheng; drafted with Claude · examples run on Go 1.27.1; the amount and kill switch examples re-run on Go 1.24.7 after the correction.