DEV Community

Taylor Zhu
Taylor Zhu

Posted on

The Burn-Rate Gate: A Fail-Closed Checklist for Shipping on a Free AI Allowance

Your free allowance does not run out by accident. It runs out on a schedule nobody computed.

Three numbers decide whether a free-tier AI build survives its launch day: what you were granted, what the provider says you consumed, and what you reserved for rollback. Most teams watch the first, guess at the second, and never write down the third. Then a demo stalls two-thirds of the way through and everyone blames the model.

This is a production-readiness checklist plus a small runnable gate for that arithmetic. It assumes the model access and the server are free — which makes the failure cheap to repair and just as easy to ignore.

The failure mode is arithmetic, not availability

Free tiers rarely fall over. They reach zero.

  • Granted: a number someone published, revocable without notice.
  • Observed: what the provider's own console reports you used.
  • Reserved: headroom you refuse to spend so a rollback drill still has budget.

A zero bill is evidence about price. It is not evidence about headroom. So the gate below never reads the bill; it reads dated observations and does division.

Step 1: keep a ledger you can diff

Append-only JSONL, one line per measurement. No database, no dashboard, no service to keep alive.

{"ts":"2026-10-17T09:00:00Z","kind":"observed","source":"provider_console","tokens":184000}
{"ts":"2026-10-18T09:00:00Z","kind":"observed","source":"provider_console","tokens":241500}
{"ts":"2026-10-18T09:05:00Z","kind":"estimate","source":"local_counter","tokens":262000}
Enter fullscreen mode Exit fullscreen mode

Two rules make the file useful:

  1. Every row carries a timestamp and a source.
  2. estimate rows are recorded but never treated as evidence — local counters drift, and drift is exactly what you are measuring.

Commit the file. When someone asks "how much is left?", the answer is a diff, not a feeling.

Step 2: gate on the projection, not the balance

The script is short on purpose. It exits non-zero unless every gate passes.

# allowance_gate.py — fail closed when the free allowance cannot be defended
from __future__ import annotations

import json, sys
from dataclasses import dataclass
from datetime import datetime, timedelta

EVIDENCE_TTL = timedelta(hours=48)

@dataclass(frozen=True)
class Entry:
    ts: datetime
    kind: str      # "observed" | "estimate"
    source: str
    tokens: int

def load(path: str) -> list[Entry]:
    rows: list[Entry] = []
    with open(path, encoding="utf-8") as fh:
        for line in fh:
            line = line.strip()
            if not line:
                continue
            raw = json.loads(line)
            rows.append(Entry(
                ts=datetime.fromisoformat(raw["ts"].replace("Z", "+00:00")),
                kind=raw["kind"],
                source=raw["source"],
                tokens=int(raw["tokens"]),
            ))
    return sorted(rows, key=lambda e: e.ts)

def gates(rows, *, now, granted, reserve, window_days=7, launch_hours=72):
    fails: list[str] = []
    observed = [e for e in rows if e.kind == "observed"]

    # G0: the granted number must be on record at all
    if granted <= 0:
        fails.append("G0: no granted amount on record")

    # G1: at least one row is provider-observed, not self-counted
    if not observed:
        return fails + ["G1: only local estimates on record"]

    # G2: the newest observation is inside the evidence TTL
    newest = observed[-1].ts
    if now - newest > EVIDENCE_TTL:
        fails.append(f"G2: newest observation is {(now - newest).days}d old")

    # G3: a burn rate needs two points spread over the window
    cutoff = now - timedelta(days=window_days)
    recent = [e for e in observed if e.ts >= cutoff]
    if len(recent) < 2:
        return fails + ["G3: fewer than two observations in the burn window"]

    used = sum(e.tokens for e in observed)
    span = (recent[-1].ts - recent[0].ts).total_seconds() / 86400

    # G4: projected exhaustion must clear the launch window
    remaining = granted - used - reserve
    if remaining <= 0 or span <= 0:
        return fails + ["G4: no headroom left after reserve"]

    rate = sum(e.tokens for e in recent) / span          # tokens per day
    hours_left = (remaining / rate) * 24
    if hours_left < launch_hours:
        fails.append(f"G4: {hours_left:.1f}h of headroom < {launch_hours}h window")

    return fails

if __name__ == "__main__":
    import argparse
    p = argparse.ArgumentParser()
    p.add_argument("ledger")
    p.add_argument("--now", required=True)      # pin the clock; do not trust the host
    p.add_argument("--granted", type=int, required=True)
    p.add_argument("--reserve", type=int, required=True)
    p.add_argument("--launch-hours", type=int, default=72)
    a = p.parse_args()

    rows = load(a.ledger)
    failures = gates(
        rows,
        now=datetime.fromisoformat(a.now.replace("Z", "+00:00")),
        granted=a.granted,
        reserve=a.reserve,
        launch_hours=a.launch_hours,
    )
    for f in failures:
        print(f"FAIL {f}", file=sys.stderr)
    sys.exit(1 if failures else 0)
Enter fullscreen mode Exit fullscreen mode

Two design choices matter more than the code.

  • --now is required. A gate that reads the host clock passes on a laptop with the wrong timezone and fails in CI for no reason.
  • estimate rows are loaded and then excluded from the projection. They exist to show you drift, not to excuse it.

Run it before a demo, not after:

python allowance_gate.py ledger.jsonl \
  --now 2026-10-18T12:00:00Z \
  --granted 10000000 \
  --reserve 2000000 \
  --launch-hours 72
Enter fullscreen mode Exit fullscreen mode

The 10000000 above is an input, not a fact. Read the current allowance from your own console, then paste that number together with the date you read it.

Step 3: prove the gate can fail

A gate you have never seen fail is decoration. Four fixtures — each one line of JSONL plus an expected exit code — take ten minutes.

Fixture Ledger state Expected
happy path two observations 24h apart, low rate exit 0
stale evidence newest observation 5 days old exit 1, G2
launch squeeze rate pushes exhaustion 12h out, window 72h exit 1, G4
self-counted only all rows kind=estimate exit 1, G1

Wire them into CI as a table test:

import subprocess, pytest

CASES = [
    ("fixtures/happy.jsonl",     "10000000", "2000000", 0),
    ("fixtures/stale.jsonl",     "10000000", "2000000", 1),
    ("fixtures/squeeze.jsonl",   "10000000", "2000000", 1),
    ("fixtures/estimates.jsonl", "10000000", "2000000", 1),
]

@pytest.mark.parametrize("path,granted,reserve,want", CASES)
def test_gate(path, granted, reserve, want):
    proc = subprocess.run(
        ["python", "allowance_gate.py", path, "--now", "2026-10-18T12:00:00Z",
         "--granted", granted, "--reserve", reserve],
        capture_output=True,
    )
    assert proc.returncode == want, proc.stderr.decode()
Enter fullscreen mode Exit fullscreen mode

Now the checklist has teeth: if a later change makes the gate permissive, a test goes red before a user does.

The checklist you can copy

Each row is one gate, one piece of evidence, one fail-closed condition.

Gate Evidence Fails closed when
Allowance is dated console export or note with a timestamp the number has no date, or predates your last release
Observed usage exists provider-side usage export only local counters are present
Evidence is fresh newest observation older than 48h
Burn window has two points observations ≥24h apart one point, or all points inside the same minute
Projection clears the window headroom ÷ rate projected exhaustion lands inside the launch window
Reserve is intact reserve line in the ledger remaining ≤ reserve
Server has its own meter instance-hours per day compute burn is unmeasured

That last row is the one teams skip. A free server bills in hours, not tokens, and it fails on a different curve — usually during a long test run rather than a burst of traffic. Track it in the same file with a kind="server_minutes" row and the same TTL. One ledger, two meters.

Where a free environment fits — and where it does not

If your rehearsal environment is a MonkeyCode workspace, the gate treats its availability as an input, not a promise. The project is open source, and the operator currently states a free allowance of 10,000,000 tokens plus a free server option. Both numbers belong in the ledger with the date you verified them, because free tiers move and stale numbers are the failure this whole post is about.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

What the free environment is genuinely good for: running the fixtures, keeping the ledger in the repo, and giving a reviewer somewhere to reproduce the gate without a card on file. What it cannot tell you: whether your production burn rate will hold once real traffic arrives. Only your own observations do that.

Who should not use this

  • Weekend prototypes. A ledger is overhead until something ships. Ship first, then add gates.
  • Teams on a committed paid contract. Different controls: committed-spend alerts and invoice reconciliation beat a JSONL file.
  • Anyone who cannot get a provider-side export. Fix that first. Otherwise every row is an estimate, and the gate will fail closed forever — correctly, but uselessly.

Close the loop

The uncomfortable part of free-tier engineering is that the constraint is invisible right until it is expensive. A dated ledger, five gates, and four fixtures turn it into arithmetic you can run in CI in under a second.

If you want to try the pattern, start with the gate file, not the model call. Point it at whatever free workspace you already have, add the two rows you can export today, and let it fail once on purpose.

MonkeyCode provides free models that can run this workflow.

Top comments (0)