Decision records for a solo project
Mullet now keeps eleven short decision records beside its code, each a context, a decision and its consequences. Why a one-person project bothers, what they cover, and how they’re kept in step with the settings reference.
For most of Mullet’s life the reasons behind its design lived in two places: commit messages and my memory. Neither helps someone reading the code. A commit message explains a change, not a rule, and it drops out of view as soon as the next commit lands. My memory is worse. So Mullet 2.3.0 added docs/adr/, a folder of short decision records, and I wrote down the main choices, most of them already made.
The format
Each record is one decision. The folder’s README gives the template:
# NNNN. Title
Status: Accepted | Superseded by NNNN
Context: the problem and the forces on it.
Decision: what Mullet does, with the real names and defaults.
Consequences: what this makes easy, what it costs, what to watch.
Files are numbered in the order they’re written, and a number is never reused. When a decision changes, it gets a new record, and the old one’s status becomes “Superseded by” the new number. Nothing is edited out of the history.
The phrase that does the most work is in the Decision line: the real names and defaults. A record that says “spending is capped” is an opinion. One that says budget.envelope.daily and .weekly cap everything paid, in UTC windows, worked out in mullet/jobs/budget.py, can be checked against the code, and visibly goes wrong when the code changes.
The eleven
| No. | Decision |
|---|---|
| 0001 | Cost comes from the provider’s own responses |
| 0002 | One shared spending envelope plus per-rung limits |
| 0003 | Toolsets for delegated models are a per-provider setting |
| 0004 | mullet serve needs a token and refuses foreign Host and Origin |
| 0005 | Editor, dev container, shell hook and package-manager files are protected |
| 0006 | A crashed gate or missing verdict pauses the job |
| 0007 | The sandbox sits behind a backend interface; bubblewrap is the default |
| 0008 | The planner states dependencies, one-way doors and open questions |
| 0009 | The reviewer gives findings and reads the repo’s own review standards |
| 0010 | Silent workers are stopped and finished ones are given a short grace window |
| 0011 | Docker and Podman run each command in a hardened, throwaway container |
The newer ones have posts of their own: the sandbox interface (0007), the task plan (0008), the reviewer (0009), the silent worker (0010) and the container backend (0011). Four of the older ones show what a record adds to a post.
0001 exists because the bill didn’t match the ledger. The post tells the story. The record states the rule that came out of it: a cost the provider states is used as is, unpriced tokens get a fallback price set high on purpose, and an attempt’s recorded cost never falls below what the provider’s responses say. Someone changing the usage meter needs the rule, not the story.
0002 says why spending limits are one shared envelope and not only a cap per job: several jobs and providers can run in a day, and a cap on one job doesn’t stop their total from growing. It also says plainly that nothing is limited until a value is set.
0003 records why providers other than Claude’s own get a safe set of tools by default, and why giving one a terminal has to be a deliberate edit (safe by default).
0006 records why a crashed gate pauses a job instead of judging it. A bug in my checks says nothing about the worker’s change. Treating a crash as a pass lets bad work through, and treating it as a fail wastes attempts and can bar a good model (gates, not a model’s word).
Consequences are the useful part
Context and Decision are easy to write. Consequences is where a record earns its place, because that is where the cost goes.
0008 admits that the planner’s new after lists are checked against the order steps run in but don’t change it: they don’t reorder steps or run them in parallel, and whether the new planner prompt reduces the failures it was written for needs new ledger figures to tell. 0011 admits that the sandbox image’s labels are not a signature, and a deliberately forged image would pass. Neither is the kind of thing that makes it into a README, and both are exactly what a contributor would trip over.
Who reads them
Contributors, I hope. Me in six months, certainly. And the coding agents that work on the repo: AGENTS.md, the file written for them, points them at docs/adr/. An agent about to “simplify” the pause on a crashed gate into a plain failure is the reader I most want to stop, and a record that says why is a cheap way to try.
Keeping them in step
The settings reference, docs/CONFIGURATION.md, says what each setting does. The records say why. The two point at each other: the reference’s Sandbox section names 0007 and 0011, its Jobs section names 0010, and it ends with a link to the folder. Each record names the settings it concerns, so a search for a setting’s name finds its record as well as its documentation.
That is all done by hand. No test checks that a record still matches the code or the reference, and I’ve already broken one of my own rules: the README says a record is 25 lines at most, and 0007 runs past that. The two sandbox records are by far the longest, because the guarantees they describe are long. I’d rather have a long, exact record than a short, vague one, but it’s a rule I wrote and haven’t kept.
Worth it on your own?
Yes, because they cost so little. Most of Mullet’s records are under 250 words. A short one takes minutes, and it saves the conversation where someone, or something, asks why it works this way, or quietly undoes it.
The records are in docs/adr/ in Mullet 2.3.0. The code is at https://github.com/tomgould/mullet-public.
More on Mullet
-
Cheap models only for the work that is safe to be wrong about
Mullet tries free and cheap models first only on steps its gates can judge, and keeps risky and one-way steps for Claude and its reviewer. Why…
-
Mullet from Python
Mullet 2.3.0 adds mullet.api, so a script or CI can plan, approve, run and merge coding jobs without the CLI. It was built by splitting the CLI’s…
-
A container backend stricter than the usual Docker recipe
Mullet 2.3.0 can run a job’s commands in Docker: one throwaway container per command, no network, no capabilities, secrets on stdin and a lifeline…
-
Putting the sandbox behind an interface
Mullet’s bubblewrap sandbox now sits behind a backend interface, with contract tests every backend must pass. Before the change went in, 15,552…
More writing
-
A Markdown version of every page, for AI agents
Why every page on this site now has a Markdown version for coding agents, the three ways an agent finds it, what goes into it, and how it stays out…
-
A TV remote from the command line
Pairing a Linux computer with a Google TV over the protocol phone remotes use, what the six-character code on the screen really checks, and why a…
-
The packages I kept on a Google TV
The other half of decluttering a TCL Google TV: a keep-list of 35 packages that debloat lists remove or that only look disposable, how each one got…
-
Letting an agent drive the TV
The rules a coding agent worked under while it changed a TV we were still watching: only changes that can be undone, small batches with a person…