Agent-First PSQL v0.12.0: The Host You Named

by Agent-First Kit Contributors

A refused SSH setting used to fall through to a direct connection, reaching whatever answered on the local port instead of the database behind SSH. A lost transaction used to drop its queued statements into autocommit. Both now stop where they are. Plus a streamed one-shot query that could deadlock, a pipe that could stop reading stdin, and SSH options cut to an allowlist.

The previous release was about a database telling you something did not happen when it did. This one is about afpsql doing something you did not ask for — connecting somewhere else, or running a statement outside the transaction it was written for — and about the places where it could simply stop moving.

The host you named, or nothing

When a connection goes over SSH, afpsql decides first whether it needs the SSH stdio bridge. If that decision failed — because an SSH setting was refused — the error was dropped and the attempt fell through to the ordinary direct connect. With the host and port from the DSN. On the local machine.

So a target that was supposed to be a database behind a jump host could instead reach whatever happened to answer on localhost:5432: a developer’s own PostgreSQL, a tunnel left over from yesterday, another project’s container. The query ran, against the wrong database, and nothing said so.

A refused SSH setting now ends the connection attempt with a connection error. The SSH destination, the container-over-SSH path, kubectl pod names and the remote runtime word all go through the same validation, so none of them can smuggle an option in as a leading -.

The bridge also stops accepting connection options it cannot honour. It carries the wire protocol over the ssh child’s stdin and stdout, so no local socket is ever opened, and connect_timeout, tcp_user_timeout, the keepalives family, target_session_attrs and load_balance_hosts configured nothing. connect_timeout=5 looked set and bounded nothing. They are rejected by name now, before the bridge starts.

SSH options are an allowlist

Explicit SSH options used to be passed through, which made the ordinary entrypoint a way to hand ssh a ProxyCommand or an identity path. They are limited to ConnectionAttempts, ConnectTimeout, Port, ProxyJump, ServerAliveCountMax, ServerAliveInterval and TCPKeepAlive. Everything else belongs in ~/.ssh/config, where SSH already knows how to read it.

Custom container runtimes follow the same reasoning: the ordinary entrypoint uses a fixed set, and only an administrator-locked profile can select another.

A lost transaction stays lost

An agent sends begin, then several statements, then commit — often all at once, without waiting for each answer. If the connection died inside that transaction, the session cleared its transaction state and went back to autocommit. The statements still in the queue then ran, one by one, each committing on its own, on a fresh connection. Half a transaction, applied.

The same happened when begin itself failed: the writes queued behind it ran in autocommit.

A failed begin or a lost transaction now leaves the session in a failed transaction state. Every query on that session is rejected without reaching PostgreSQL, and a new begin is refused, until rollback settles it. A commit settles it too, reporting transaction_lost_rolled_back. Both that code and commit_outcome_unknown — a write whose fate needs checking before anything else happens — are now listed in the reference’s error codes, where they had been missing.

Never rerun a write for its output

A truncated UPDATE … RETURNING has already committed. The skill and reference used to suggest rerunning with --stream-rows to get the full result, which for a write means doing it again. They now say plainly: choose --stream-rows before the first write when the full result matters, and after truncation use a separate SELECT — which may not be able to reconstruct the old values, and that is the honest answer.

The atomic write example also opens its session read-write, which the example previously forgot.

Not stopping

A streamed one-shot query could deadlock. The CLI ran the query to completion and only then drained its output. With --stream-rows, the query sends batches into a bounded channel; once it filled, the query waited for the drain and the drain waited for the query. Both now run together.

The pipe could stop reading stdin. Admitting work to a session waited for space in that session’s worker queue, so a busy session blocked the reader — including the cancel that would have freed it. Each queue now has 64 slots and a full one rejects new work immediately with a retryable invalid_request; cancellation and shutdown are always read.

A configuration patch reserves room in every affected queue before it changes anything, so it applies to all of them or none. An accepted begin keeps the configuration its permission check saw, even if a later patch changes the session before it runs.

Shutdown is bounded. EOF and close share one five-second deadline across draining workers and closing database clients. Work still waiting at that point is cancelled locally. The close acknowledgement does not claim that an unfinished write did or did not happen — that still needs checking against the database.

A cancel is sent once. A successful cancel token used to be followed by a pg_cancel_backend fallback connection anyway. The fallback is now only for when the token fails. A driver whose server stops answering mid-query is also released on close instead of being held open waiting for a reply that will not come.

Smaller things

The reference and design docs named --*-secret-env and --*-secret-config flags that no longer exist. They describe what the CLI actually accepts: --dsn, --conninfo and --password, each a literal, env:NAME, or file:PATH#DOT_PATH.

The test script gained a minimal mode that builds without default features and checks both binaries’ help and version discovery, and integration tests can run against a private native PostgreSQL cluster instead of a container.

Breaking changes

Getting it

$ brew install agentfirstkit/tap/afpsql
$ cargo install agent-first-psql