Two of this week's most-read posts on DEV were about AI fatigue and about tooling that lags behind model capability. Read together, they describe the same operational gap: models got cheaper and better, while the plumbing that decides where a job runs stayed informal, undocumented, and unreproducible.
The failure shape is familiar. A script that used to run on a laptop gets promoted to "the free tier" because the free tier is free. Nobody notices that the job is a webhook receiver — something has to connect to it. Nothing does. The retry loop runs for six hours, duplicate units pile up, and the only record of the routing decision is one sentence in a chat thread.
A week later, three questions have no answer: which lane did this job belong in, what evidence justified that lane, and what would move it out again.
So here is the artifact this post builds: a glossary, a decision tree with four leaves, a dependency-free router you can run (route_job.py), a test file that pins every branch, and a log query that turns routing records into a table you can argue with.
Glossary: the eight terms the tree depends on
Precision here saves arguments later. If two people mean different things by "free tier", the decision tree resolves nothing.
- Job — one unit of work with a name, an input, an output, and an owner. Not a pipeline.
-
Lane — the placement class a job routes to. This post uses four:
local_only,free_model,free_server,controlled. - Leaf — a terminal outcome of the tree, carrying one example, one expected routing record, and one exit condition.
-
Data class — what the input contains:
public,internal, orsensitive. Assigned by the data owner, not by the engineer who is in a hurry. - Idempotent unit — work that replays from the start with no extra side effect. A duplicate is a wasted call, not a corrupted record.
- Addressable — something must initiate a connection to the job: a webhook, a schedule, an SSH session. Addressability is a hosting requirement, not a model requirement.
- Pinned model — a job whose output is reproducible only against a specific model revision. Reproducibility outranks cost.
- Routing record — one JSON line written at dispatch time: job, lane, reason, data class, and the quota observed on that call.
The decision tree: five branches, in order
Branch order is the policy. Reorder it and you have a different system, so write the order down and test it.
Branch 1 — Can the answer be computed without a model? If yes, local_only.
Branch 2 — Is the input sensitive, or is there a contractual review? controlled. Boundaries beat convenience.
Branch 3 — Does the job need a pinned model? controlled. Free tiers move underneath you by design.
Branch 4 — Must something connect to the job, or does it run longer than a short request? free_server.
Branch 5 — Is a re-run cheap and safe? Not idempotent goes to controlled. Idempotent goes to free_model.
The two interesting leaves are free_model and free_server, because both assume you have a model endpoint and a host. Many free options exist for each; this post treats them as properties, not brands. MonkeyCode, for example, is an open-source project whose free model access and free server option map onto those two leaves.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Its current outreach states a free tier of 10,000,000 tokens plus a free server option. Treat both as configuration you read at run time, never as constants you hard-code: quotas and terms are set by the project and can change between the day you write the number down and the day you rely on it.
Four worked leaves
Leaf A — local_only
Example: a nightly disk-usage digest that reads /proc and prints a table. Why it stops here: the answer is computable without a model.
{"name":"disk-report","lane":"local_only","reason":"decidable without a model; keep it off the network"}
Exit condition: the day someone adds an LLM summarization step, this becomes two jobs. Keep the deterministic half exactly where it is.
Leaf B — free_model
Example: drafting a changelog entry from a public PR diff. Why it stops here: public input, single-shot, idempotent, no inbound connection.
{"name":"changelog-draft","lane":"free_model","reason":"disposable, non-sensitive, single-shot"}
Exit condition: the diff stops being public. Then Branch 2 fires and the job moves to controlled, no matter how cheap leaf B is.
Leaf C — free_server
Example: a watcher that receives a repo webhook, queues the event, and replies later. Why it stops here: it is addressable and long-running; the model call is incidental.
{"name":"repo-webhook","lane":"free_server","reason":"long-lived or addressable workload"}
Exit condition: if the watcher becomes part of an availability promise, a free server is the wrong instrument. Move it to infrastructure you own.
Leaf D — controlled
Example: redacting customer identifiers from support tickets before anything else touches them. Why it stops here: sensitive input plus contractual review.
{"name":"ticket-redaction","lane":"controlled","reason":"sensitive or contractual: do not route to a shared free tier"}
Exit condition: there is none that a cheaper endpoint can satisfy. This leaf exists to say no.
The artifact: a router you can run in one command
route_job.py has no dependencies and makes no network calls. It encodes the five branches as five early returns, in the order written above.
#!/usr/bin/env python3
"""route_job.py - classify an agent job into a placement lane. Python 3.9+, stdlib only."""
from __future__ import annotations
from dataclasses import dataclass, asdict
from enum import Enum
import json, sys
class Lane(str, Enum):
LOCAL_ONLY = "local_only"
FREE_MODEL = "free_model"
FREE_SERVER = "free_server"
CONTROLLED = "controlled"
class DataClass(str, Enum):
PUBLIC = "public"
INTERNAL = "internal"
SENSITIVE = "sensitive"
@dataclass(frozen=True)
class JobSpec:
name: str
data_class: DataClass
deterministic: bool
idempotent: bool
needs_inbound_endpoint: bool
wall_clock_minutes: int
requires_pinned_model: bool
contractual_review: bool
def route(job: JobSpec):
if job.deterministic: # Branch 1
return Lane.LOCAL_ONLY, "decidable without a model; keep it off the network"
if job.data_class is DataClass.SENSITIVE or job.contractual_review: # Branch 2
return Lane.CONTROLLED, "sensitive or contractual: do not route to a shared free tier"
if job.requires_pinned_model: # Branch 3
return Lane.CONTROLLED, "pinned model required; free tiers move underneath you"
if job.needs_inbound_endpoint or job.wall_clock_minutes > 15: # Branch 4
return Lane.FREE_SERVER, "long-lived or addressable workload"
if not job.idempotent: # Branch 5
return Lane.CONTROLLED, "non-idempotent: the cost of a duplicate is unbounded"
return Lane.FREE_MODEL, "disposable, non-sensitive, single-shot"
def routing_record(job: JobSpec) -> dict:
lane, reason = route(job)
record = asdict(job)
record["data_class"] = job.data_class.value
record["lane"] = lane.value
record["reason"] = reason
return record
if __name__ == "__main__":
with open(sys.argv[1], encoding="utf-8") as handle:
jobs = [JobSpec(**{**raw, "data_class": DataClass(raw["data_class"])})
for raw in json.load(handle)]
for job in jobs:
print(json.dumps(routing_record(job), sort_keys=True))
Steps to reproduce:
- Save the file, and write a fixture
jobs.jsoncontaining the four examples from the leaves above. - Run
python route_job.py jobs.json | jq -c '{name, lane, reason}'. - Compare the four output lines against the expected records printed in each leaf. A mismatch is a branch-order disagreement, not a bug — decide which order you actually meant.
Expected output:
{"name":"disk-report","lane":"local_only","reason":"decidable without a model; keep it off the network"}
{"name":"changelog-draft","lane":"free_model","reason":"disposable, non-sensitive, single-shot"}
{"name":"repo-webhook","lane":"free_server","reason":"long-lived or addressable workload"}
{"name":"ticket-redaction","lane":"controlled","reason":"sensitive or contractual: do not route to a shared free tier"}
Pin every branch with a test, not a review
A decision tree that lives only in prose drifts. The file below pins the branch order. Run it with python test_route_job.py; the names also work under pytest.
# test_route_job.py
from route_job import JobSpec, DataClass, Lane, route
BASE = dict(deterministic=False, idempotent=True, needs_inbound_endpoint=False,
wall_clock_minutes=3, requires_pinned_model=False, contractual_review=False)
def make(**overrides):
return JobSpec(**{**BASE, **overrides})
def test_deterministic_never_leaves_the_machine():
lane, _ = route(make(name="a", data_class=DataClass.PUBLIC, deterministic=True))
assert lane is Lane.LOCAL_ONLY
def test_sensitive_beats_convenience():
lane, reason = route(make(name="b", data_class=DataClass.SENSITIVE))
assert lane is Lane.CONTROLLED and "sensitive" in reason
def test_pinned_model_beats_free_tier():
lane, _ = route(make(name="c", data_class=DataClass.PUBLIC, requires_pinned_model=True))
assert lane is Lane.CONTROLLED
def test_addressable_job_gets_a_server():
lane, _ = route(make(name="d", data_class=DataClass.PUBLIC, needs_inbound_endpoint=True))
assert lane is Lane.FREE_SERVER
def test_long_job_gets_a_server():
lane, _ = route(make(name="e", data_class=DataClass.PUBLIC, wall_clock_minutes=45))
assert lane is Lane.FREE_SERVER
def test_non_idempotent_never_lands_on_a_shared_model():
lane, _ = route(make(name="f", data_class=DataClass.PUBLIC, idempotent=False))
assert lane is Lane.CONTROLLED
def test_disposable_public_single_shot():
lane, _ = route(make(name="g", data_class=DataClass.PUBLIC))
assert lane is Lane.FREE_MODEL
if __name__ == "__main__":
for name, fn in sorted(globals().items()):
if name.startswith("test_") and callable(fn):
fn()
print("ok", name)
Two properties matter more than coverage. First, the assertions use is, so renaming a lane fails loudly instead of quietly changing meaning. Second, the precedence tests — test_sensitive_beats_convenience and test_pinned_model_beats_free_tier — fail the moment somebody reorders the branches. That is the point.
Turn routing records into a table you can argue with
Write one routing record per dispatch and append them to a JSONL file. Then:
jq -r '[.lane, .name] | @tsv' routing.jsonl | sort | uniq -c | sort -rn
Keep the counts next to a small ledger. Fill the cells from your own logs; do not paste numbers you have not observed.
| Column | Why it is there |
|---|---|
job |
joins the routing record to the unit of work |
lane |
makes drift visible when a job silently changes placement |
reason |
the branch that fired — the only field that survives a rewrite |
retries |
idempotency failures show up here first |
quota_observed |
what the endpoint actually reported, not what the docs said |
review_flag |
who has to look before this job moves again |
Limitations, and who should not use this
The tree is a policy encoding, not evidence about any provider. It has not been validated against your workload, and both thresholds — wall_clock_minutes > 15 and the public/internal split — are placeholders to replace with numbers from your own logs.
Do not adopt this approach if:
- The workload is regulated, or the data class cannot be assigned by a named owner.
- You need a contractual availability or residency guarantee. A free tier is not that, whatever today's terms say.
- Your jobs need pinned model revisions. Branch 3 will keep sending them elsewhere, correctly.
- You cannot write one routing record per dispatch. Without the ledger, the tree is just an opinion.
Also note that a free server is a placement, not an SLA. If the watcher in Leaf C sits on the critical path of a customer promise, label a free host as a prototype and plan the move before the promise is made.
What to do next
Before you move any job to a cheaper lane, run the fixture, pin the branches with the tests, and record the lane you chose. Then revisit the ledger monthly. The interesting column is not retries; it is reason, because that is where a changed assumption becomes visible — and a changed assumption is the only thing that should move a job between leaves.
If you want somewhere to run the free_model and free_server leaves while you build the ledger, MonkeyCode is an open-source project with free model access and a free server option. Start with the repository, run the fixture above against four of your own jobs, and compare your lanes to the expected records before you move anything real.
Top comments (0)