●SANDBOX — Claude Code adds a sandbox.credentials setting that blocks sandboxed commands from reading credential files and secret environment variables●MODEL — Org-configured model restrictions now apply across the model picker, --model, /model, and ANTHROPIC_MODEL●AGENTS — Sessions that edit, merge, comment on, or push to an existing PR now link to that PR in the claude agents view●FIX — Fixed --json-schema silently producing unstructured output on an invalid schema, plus messages lost at the --max-turns limit●RECAP — Claude adds a monthly recap and focus settings in beta, with break reminders, quiet hours, and work insights●OPUS — Claude Opus 4.7 is now generally available with stronger long-running coding tasks and higher-resolution vision●SANDBOX — Claude Code adds a sandbox.credentials setting that blocks sandboxed commands from reading credential files and secret environment variables●MODEL — Org-configured model restrictions now apply across the model picker, --model, /model, and ANTHROPIC_MODEL●AGENTS — Sessions that edit, merge, comment on, or push to an existing PR now link to that PR in the claude agents view●FIX — Fixed --json-schema silently producing unstructured output on an invalid schema, plus messages lost at the --max-turns limit●RECAP — Claude adds a monthly recap and focus settings in beta, with break reminders, quiet hours, and work insights●OPUS — Claude Opus 4.7 is now generally available with stronger long-running coding tasks and higher-resolution vision
My Nightly Batch Was Quietly Running on a Bigger Model — Declaring Per-Task Model Ceilings and Enforcing Them
Scatter model names through your code and a cheap batch job will eventually run on an expensive model. Here is a manifest that declares a ceiling per task, a deny-by-default resolver, and a morning audit that catches drift — with working code.
I opened the billing page and my hand stopped on the trackpad.
Same number of nightly jobs as the month before. The number was 1.7x higher. When you run scheduled work unattended across several sites as a solo developer, a jump like that is not a billing anomaly. It is a signal that something in the design broke.
It took a few minutes to find. A small auxiliary task had been refactored six months earlier to pull its model name from a shared constant. That constant was named for the interactive default, and someone — me — later pointed it at a stronger model. Nobody lied. The judgment that this particular task was fine on a cheap model simply had never been written down anywhere the code could see.
The per-model entitlements and spend alerts that landed for Enterprise in July 2026 close exactly this hole at organizational scale. The same idea works at solo scale, and you can build it yourself. What follows is the implementation I run today across the four Dolice Labs sites.
Treat a model name as a contract, not a setting
To the code, model="claude-opus-4-8" is a configuration value. To operations, it is a statement about how much this task is permitted to spend per invocation.
Contracts scattered across config files get silently rewritten by refactors. So I split the two concerns.
Each task declares the minimum capability it needs and the maximum cost it tolerates. A resolver decides which concrete model satisfies those bounds.
With that split, rewriting a shared constant cannot lift a task above its ceiling. And a task that genuinely needs a stronger model now requires editing the manifest — which means the change shows up as a reviewable diff.
The manifest: what gets declared
Keep the declaration small. Anything larger stops being maintained.
Field
Meaning
Example
ceiling
Strongest model this task may use
sonnet-5
floor
Below this, do not run at all
haiku-4-5
max_output_tokens
Per-call output cap
4096
daily_usd
Approximate daily budget for this task alone
0.80
on_ceiling_miss
Behavior when the request exceeds the ceiling
degrade / fail
The floor exists because silent downgrades are the worst failure mode in this system. Errors announce themselves. A weaker model producing plausible-looking output can go unnoticed for weeks. I wrote about that failure in depth in A Silent Drop to a Weaker Model Is Scarier Than an Error.
_ladder is explicit so that nothing has to infer price ordering from a model string. Inference like that will betray you the moment a naming convention shifts. When a new model ships, a human edits those three lines. That is the only entry point.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A 120-line deny-by-default policy layer that resolves each task to the cheapest model satisfying its declared ceiling and floor
✦A morning audit script that reconciles declared ceiling, resolved tier, and the model the API actually billed
✦Clear criteria for when to degrade-and-continue versus fail hard, and why cost drift and capability drift deserve opposite treatment
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The single most important property of this layer is that an unknown task name must fail. If a typo in a task name resolves to "not in the manifest, therefore unconstrained," the guard does not exist.
# entitlements.pyfrom __future__ import annotationsimport jsonimport osimport timefrom dataclasses import dataclassimport yamlfrom anthropic import Anthropic_CFG = yaml.safe_load(open("config/model_entitlements.yaml", encoding="utf-8"))_LADDER: list[str] = _CFG["_ladder"]_ALIASES: dict[str, str] = _CFG["_aliases"]_TASKS: dict[str, dict] = _CFG["tasks"]# USD per 1M tokens (input, output). Approximate, for reconciling against the bill._PRICE_USD = { "haiku-4-5": (1.00, 5.00), "sonnet-5": (2.00, 10.00), # introductory pricing through 2026-08-31 "opus-4-8": (5.00, 25.00),}class EntitlementError(RuntimeError): """A manifest violation. Never swallow this — let it reach the caller."""@dataclass(frozen=True)class Decision: task: str tier: str # name on the ladder model: str # concrete API model string max_output_tokens: int degraded: booldef _rank(tier: str) -> int: try: return _LADDER.index(tier) except ValueError as exc: raise EntitlementError(f"unknown tier: {tier}") from excdef resolve(task: str, want: str | None = None) -> Decision: """Decide which model a task is actually allowed to use. Omit `want` to take the ceiling as-is. If `want` exceeds the ceiling, on_ceiling_miss decides: `degrade` clamps down, `fail` raises. """ spec = _TASKS.get(task) if spec is None: # Deny by default. An unregistered task spends zero tokens. raise EntitlementError(f"unregistered task: {task}") ceiling, floor = spec["ceiling"], spec["floor"] if _rank(floor) > _rank(ceiling): raise EntitlementError(f"{task}: floor is above ceiling") target = want or ceiling degraded = False if _rank(target) > _rank(ceiling): if spec.get("on_ceiling_miss", "fail") == "fail": raise EntitlementError(f"{task}: {target} exceeds ceiling {ceiling}") target, degraded = ceiling, True if _rank(target) < _rank(floor): # A floor breach is never repaired by degrading. Capability loss # must not pass silently. raise EntitlementError(f"{task}: {target} is below floor {floor}") return Decision( task=task, tier=target, model=_ALIASES[target], max_output_tokens=int(spec["max_output_tokens"]), degraded=degraded, )
Clamp down only when on_ceiling_miss says degrade; always raise when the floor is breached. That asymmetry is the whole design. Cost drift may be corrected silently. Capability drift may not.
The wrapper: record every decision
A policy matters only if you can later verify it held. So every call appends one JSON line.
_client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])_LEDGER = os.environ.get("ENTITLEMENT_LEDGER", "logs/entitlement.jsonl")def _estimate_usd(tier: str, usage) -> float: pin, pout = _PRICE_USD[tier] return (usage.input_tokens * pin + usage.output_tokens * pout) / 1_000_000def call(task: str, messages: list[dict], want: str | None = None, **kw): d = resolve(task, want) started = time.monotonic() resp = _client.messages.create( model=d.model, max_tokens=d.max_output_tokens, messages=messages, **kw, ) record = { "ts": time.time(), "task": d.task, "declared_ceiling": _TASKS[d.task]["ceiling"], "resolved_tier": d.tier, # What the response claims to be. If this diverges from resolved, # the audit will catch it. "billed_model": resp.model, "degraded": d.degraded, "input_tokens": resp.usage.input_tokens, "output_tokens": resp.usage.output_tokens, "est_usd": round(_estimate_usd(d.tier, resp.usage), 6), "elapsed_ms": round((time.monotonic() - started) * 1000), } os.makedirs(os.path.dirname(_LEDGER), exist_ok=True) with open(_LEDGER, "a", encoding="utf-8") as fp: fp.write(json.dumps(record) + "\n") return resp
Keeping billed_model is the part people skip. The model you request and the model that answers will diverge whenever a fallback or an automatic downgrade fires. Only by reconciling declared, resolved, and billed can you claim the policy actually held.
Catching a nightly deviation the next morning is more practical than halting the run in the moment. Stopping unattended work has a high false-positive cost.
# audit_entitlements.pyimport collections, datetime, json, sysfrom entitlements import _TASKS, _ALIASESWINDOW_H = 24now = datetime.datetime.now().timestamp()rows = [json.loads(l) for l in open(sys.argv[1], encoding="utf-8") if l.strip()]rows = [r for r in rows if now - r["ts"] < WINDOW_H * 3600]spend = collections.defaultdict(float)violations: list[str] = []for r in rows: spend[r["task"]] += r["est_usd"] expected = _ALIASES[r["resolved_tier"]] if not r["billed_model"].startswith(expected.split("-2025")[0]): violations.append( f"{r['task']}: resolved {expected} / billed {r['billed_model']}" )for task, usd in sorted(spend.items(), key=lambda kv: -kv[1]): cap = _TASKS[task]["daily_usd"] pct = usd / cap * 100 flag = "OVER" if usd > cap else ("WARN" if pct >= 80 else "ok") print(f"{flag:4} {task:28} ${usd:6.3f} / ${cap:5.2f} ({pct:5.1f}%)")degraded = sum(1 for r in rows if r["degraded"])if degraded: print(f"\ndegraded calls: {degraded} of {len(rows)}")for v in violations: print(f"VIOLATION {v}")sys.exit(1 if violations or any(spend[t] > _TASKS[t]["daily_usd"] for t in spend) else 0)
Two consecutive WARN nights and a human decides: raise the cap, or split the task. The 80% threshold is empirical — across my four sites, tasks that crossed it tended to breach within the following week. Tune it to your own variance.
Setting a ceiling on the total daily spend is a separate concern, handled by a budget circuit breaker. The manifest here is responsible for the breakdown underneath that total.
Rolling it out
Inventory the model names you already have. Run grep -rn "claude-" --include="*.py" . and tabulate which task touches which model. This step alone is usually a surprise.
Write each task's ceiling as it is today. Do not tighten yet. Visibility comes first.
Route every call through the wrapper. Zero direct messages.create calls should remain. In CI, assert that grep -rn "messages.create" --include="*.py" . | grep -v entitlements.py returns nothing.
Run the audit only, for seven days. This is how you obtain real daily_usd numbers.
Set daily_usd to 1.3x the observed value. I started at 1.1x and generated a week of pointless OVER flags during a heavier publishing stretch.
Now lower ceiling one rung at a time and watch quality. Only here does the bill start to fall.
What the docs won't tell you
Choose degrade or fail based on output verifiability. A task whose output passes through a schema check or a downstream gate can be degraded safely, because breakage announces itself. A task that produces prose for a human to read should be on_ceiling_miss: fail. I run article drafting as degrade and the final publishing gate as fail.
Stamp the manifest version onto every record. Compare costs across a day when you changed a ceiling and you lose the ability to attribute the change. I append the first eight characters of the file's SHA-1.
Never create a task without a floor. As an indie developer it is tempting to leave a task unbounded, but if it feels like "any model will do," it almost certainly means nobody is checking its output. In my experience it is worth asking whether that task should run at all.
Hold budgets per day, not per call. Per-call limits are already enforced by max_tokens. A daily budget is what makes a retry storm visible as money. The first OVER I ever hit was not a model-selection mistake — it was a failing tool call retried forty times in a row.
Closing
Treat model names as contracts rather than constants, and cost drift turns into a reviewable diff.
For a concrete next step, run grep -rn "claude-" --include="*.py" . against your own repository. If even one task turns out to be reaching for a stronger model without saying so, this design earns its keep.
Long-running unattended systems get stable less through clever models than through small promises that cannot be broken. Thank you for reading this far.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.