Agent-First PSQL v0.9.1: The Errors That Are Not Yours
v0.9.0 made every illegal invocation an exit-2 rejection that says «rewrite your command line». v0.9.1 is about the two failures where the caller has nothing to rewrite: a handler that reads an argument its own shape never declared, which used to become an empty string that looked like a value someone passed, and an output sink that could not be opened, which used to be reported as a usage error. Both are exit 1 with a code of their own, and a new test drives all 23 shapes through their handlers so the first one is caught before it ships. Rejections also stopped quoting the token back, so a psql-style `-d<DSN>` typo can no longer echo its password into a logged error event. Picks up Agent-First Data 0.31.0.
v0.9.0 compiled the CLI from a closed registry: an invocation runs only when it
matches one of 23 registered shapes, and everything else is exit 2 with a
cli_* code. Exit 2 is a precise thing to say to an agent. It means the
command line is wrong, rewrite it and try again — and because the registry
decides it from argv alone, before any config, filesystem, or network I/O, the
same argv always gets the same answer.
That is the right answer when the caller made the mistake. v0.9.1 is about the failures where the caller did not, and where afpsql was answering as if they had.
The empty string that looked like a value
A closed registry has two halves. One is the shape: which arguments a command accepts, which combinations are legal. The other is the handler: the function that reads the resolved values and builds a query out of them.
Nothing checked that the two halves agreed. A handler that asked for an argument id its own shape never declared got this:
fn required_string(invocation: &ResolvedInvocation, id: &str) -> String {
invocation.required(id).as_str().unwrap_or_default().to_string()
}
unwrap_or_default() on a missing id is "". Not a crash, not an error — an
empty string, handed onward to build a query. And an empty table name coming
out of a handler is indistinguishable from an empty table name that a caller
actually typed. The tool would go on to fail, eventually, somewhere else,
reporting something about the world rather than about itself.
Twenty-three shapes and eighteen handlers, paired by hand. The pairing was correct — but nothing said so, and nothing would have said so if it stopped being correct.
Calling every shape
Agent-First Data 0.31.0 adds call_every_combination(): it synthesizes an
invocation for every declared shape, runs it through the handler bound to that
shape, and puts the invocation in a strict-read mode where asking for an
undeclared id names the combination and the id instead of handing back a
default.
afpsql now runs it as a test:
#[test]
fn every_combination_reads_only_ids_its_shape_declares() {
let app = match registry().bind_actions(actions()) {
Ok(app) => app,
Err(error) => panic!("handlers must cover every action: {error}"),
};
app.call_every_combination();
}
This is safe to run in a unit test for a reason worth stating: afpsql’s
handlers only project. They read resolved values and build a Mode value.
None of them opens a connection, and the query they assemble is never sent. The
side-effect-free handler is what makes exhaustive dispatch cheap to verify —
if the handlers did the work themselves, this test would need a database.
At runtime the same misread is now cli_invocation_invalid at exit 1, carrying
the combination and the argument id and the hint this is a defect in the
program, not in the command; report it. It names ids, never values, so it
stays sayable even when the arguments hold secrets.
Exit 2 is a claim about argv
The second failure was the output sink. --stdout-file and --stderr-file
point the process streams at files; if the file cannot be opened, afpsql used
to emit invalid_request at exit 2.
That put a filesystem outcome inside a status code that means your command
line is malformed. The command line is fine. It resolved to a legal shape, and
whether the sink opens depends on a directory that argv can only name, not
control. Meanwhile afhttp and afpay already had a code for exactly this —
output_setup_failed, exit 1 — and afpsql was the one spore in the kit
answering differently.
v0.9.1 moves it, and pins it with the test that would have caught the drift:
assert_eq!(out.status.code(), Some(1));
assert!(String::from_utf8_lossy(&out.stdout).trim().is_empty());
assert_eq!(event["error"]["code"], "output_setup_failed");
The empty-stdout assertion is the load-bearing one. The failure is that a stream has nowhere to go; announcing it on that stream would be reporting a fire by mail.
Both codes now appear in the packaged CLI reference and in the binary. That is the actual invariant behind this release: the reference is generated, so a dependency upgrade can add a paragraph describing behavior this tool never had. Documentation that describes an unimplemented code is worse than no documentation, because an agent will branch on it.
The rejection that quoted your password back
One more, inherited from the upgrade and worth its own paragraph.
afpsql has no short arguments — psql’s -c, -U, -d exist only inside
--mode psql, where the compatibility translator handles them. In canonical
mode a leading -x is simply unregistered, and v0.9.0 said so like this:
"message": "unknown argument `-dpostgres://user:hunter2@host/db`"
Short options carry their values inline. So the one token shape that is most likely to hold a secret was the one being quoted back verbatim, into an error event that gets logged. v0.9.0’s own reference promised that no message quotes a raw value, including secrets. This was the hole in it.
v0.9.1 classifies instead of quoting:
{"error":{"code":"cli_unknown_argument","message":"unknown short argument",
"hint":"run `afpsql --help` and choose one registered combination"}}
Nothing was lost. error.code and hint are what an agent branches on; the
token was never part of the decision, only part of the log. A long argument
still gets named in its rejection, because a long flag’s name is fixed by
argv’s grammar and everything after the = is dropped before the message is
built.
Upgrading
v0.9.1 is a drop-in for v0.9.0. Three observable changes, all in failure paths:
- A failed
--stdout-file/--stderr-fileisoutput_setup_failedat exit 1, notinvalid_requestat exit 2. - An unregistered short-form token reports
unknown short argumentinstead of echoing the token. Thecli_unknown_argumentcode is unchanged. cli_invocation_invalidat exit 1 exists for a defect in afpsql itself. You should never see it; if you do, it is a bug report, not a usage error.
Nothing about connections, queries, permissions, transports, or the pipe protocol changed.