Airforge / Field note
When another system reads the output
What does a caller learn from a command that succeeds and prints nothing?
What does a caller learn from a command that succeeds and prints nothing?
Try to answer without peeking at the implementation. Maybe the query matched nothing. Maybe it matched plenty and the result went somewhere else. Or the process died before writing a byte. A person facing that ambiguity pokes around until the picture resolves. A script facing it has to guess.
The guess is the uncomfortable part. Most command-line tools are written as if a human were the only reader, then get wired into schedulers, agent runtimes, retry wrappers, and CI jobs that branch on whatever comes out.
The empty line that forces a second read
Here is the version of this we kept hitting while building Axial. An agent asks the tracker for blocked urgent issues. stdout comes back empty. Exit code 0. Now what?
The agent cannot distinguish “no such issues” from “output lost” from “filter silently ignored”, so it issues a follow-up read. That is one extra round trip per query, purely to resolve an ambiguity the command created. Duplicated work is born in exactly these guesses.
The shell already hands every command three distinct signals. POSIX treats the exit status as an input to subsequent shell behavior, and the GNU coding standards give noninteractive error messages their own required form. Collapsing those signals into one throws away structure a downstream system needed.
The accidental API
This deserves a name. The moment another system branches on your output, you have an accidental API: an interface you shipped without ever deciding to. Hyrum’s Law says the same thing from the consumer’s side. Given enough users, every observable behavior of your system will be depended on by somebody.
Once your output is an API, formatting is behavior, because a changed byte changes someone’s branch. Silence turns into a bug, because an unwritten result forces the caller to infer. Whatever ambiguity you leave does not vanish. It moves downstream, into the first caller that has to guess.
Exit codes are a routing table
Axial’s side of this fits in one diagram:
┌─ 0 ─▶ read stdout, continue from the result
├─ 2 ─▶ the call was malformed: fix it before retrying
command ──────┼─ 3 ─▶ the target does not exist: repair the reference
├─ 4 ─▶ another actor got there first: pick different work
└─ 1 ─▶ infrastructure failed: back off, alert a human
(Exit 5 exists too, for waits that time out. A story for another note.)
Each branch demands a different next action. That is what earns an outcome its own code. Retrying a malformed call is wasted motion. Retrying a conflict is how you trample a colleague’s claim.
What should “nothing found” print?
An empty result is still a result, so Axial prints one:
0 issues for status:blocked priority:urgent
The line is small and it closes the branch. The command ran and the filters applied; the set is simply empty. No second read required.
What does a write hand back?
Every mutation echoes the state it produced:
created PLAT-1 [todo] p:high @codex #backend: Fix vector search
updated PLAT-14 status: in_progress -> done
The caller continues from the echoed result instead of re-fetching the record it just wrote. Humans benefit too: the log now holds a compact account of what each command claims to have done.
How does a caller tell failures apart?
Every failure is one line on stderr with a stable code:
error[NOT_FOUND]: issue PLAT-99 does not exist
A shell can branch on the numeric status while a supervising process records the named code, and a person can still just read the message.
A conflict means something specific: the world moved while you were deciding.
error[CONFLICT]: PLAT-1 already claimed by codex
That exits 4, and only conflicts exit 4. The caller can pick different work or wait for the lease, without parsing prose to decide whether a retry is sensible.
The error text is half the story. If the claim underneath is implemented as a read followed by a write, two actors can observe the same unclaimed issue and both proceed. Axial’s claim is a single conditional update, and the losing caller receives the exit 4. Precise wording cannot repair a racy mechanism.
Who keeps the contract true?
Prose drifts when nothing checks it. Someone improves punctuation. A new command ships an empty string because nobody discussed the empty case. Each change looks harmless in review.
So Axial pairs its written specification with golden tests. The spec states invocation, stdout, stderr, and exit status for the success, empty, and failure paths. The golden files record those observations as executable examples. The pairing changes what a review asks: which callers now observe a different protocol, and did the spec approve that?
Versioning follows the same boundary. The storage schema and the output protocol change for different reasons, so they carry separate version numbers.
The implication
You almost certainly own an accidental API already. Anything a script or an agent consumes qualifies: a CLI, a webhook body, an event payload, a generated file. Before the next change to one of these, write down four answers. Who owns each channel? What does empty print? Which failures require different caller behavior? What executes to prove all of this stays true?
The bar is lower than it sounds: know which of your characters carry behavior, and guard those.
If something branches on your output, you already shipped an API.