Agent-First PSQL v0.9.1: The Errors That Are Not Yours

by Agent-First Kit Contributors

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:

Nothing about connections, queries, permissions, transports, or the pipe protocol changed.