Design
Problem
AI agents call SQL through shell tooling, but classic terminal-first clients are not ideal for automated workflows:
- Output is human-formatted, not protocol-stable.
- Process-per-query overhead wastes latency and connection setup.
- Large
SELECT *workloads need predictable streaming. - Text interpolation patterns are easy to misuse and unsafe by default.
afpsql is an agent runtime for PostgreSQL: structured protocol, persistent
connections, safe parameter binding, and Agent-First Data naming everywhere.
Product Boundary
afpsql has one runtime interface (Agent-First Data protocol). psql mode is only a CLI
argument translation layer.
Non-goals:
- no runtime protocol fork for
psql mode - no table/text output compatibility
- no
psqlmeta-command compatibility (\\d,\\x,\\timing, …) - no text-template variable substitution semantics
Architecture is two CLI frontends -> one canonical agent-first execution core.
Execution layering:
handleris protocol orchestration onlyDbExecutoris the DB adapter boundary- default adapter uses
tokio-postgres+deadpool-postgres
Core Principles
- SQL-native protocol events.
- Agent-First Data naming conventions for fields and flags.
- Parameter binding for dynamic values.
- Structured errors with machine-readable codes.
- Large-result streaming as first-class behavior.
psql modeperforms argument translation only.- production SQL execution path has no test failpoint semantics.
- No SQL-text heuristics for runtime behavior.
- Protocol events use stdout only; stderr is not a protocol channel.
Protocol Shape
Input commands:
querycancelconfigpingclose
Output events:
resultresult_startresult_rowsresult_endsql_errorerrorconfigpongcloselog
Parameter Binding (Required for Dynamic Values)
When values are dynamic, clients should use $N placeholders and params.
{"code":"query","id":"q1","sql":"select * from users where id = $1","params":[123]}
Validation rules:
- Placeholder count must match
paramslength (validated from prepared-statement metadata, not by scanning SQL text). - Invalid shape returns
error_code: "invalid_params". - Server-type conversion failures return
error_code: "invalid_params".
Unsupported by design:
:name-style interpolation- raw text expansion in SQL templates
CLI Binding Forms
The agent-first CLI uses one canonical binding form:
--param 1=value --param 2=value(repeatable)
CLI parsing translates this form into canonical protocol params array.
psql mode translation may accept numeric -v bindings:
afpsql --mode psql -c "select * from t where id = $1" -v 1=42
Translation rule: only numeric variable names are allowed; non-numeric names are rejected because interpolation is unsupported.
Modes
CLI mode
Single query execution and structured output.
Two parsers are available:
- agent-first parser (default)
psqlparser (--mode psql) -> translated into agent-first request
Pipe mode (--mode pipe)
Long-lived JSONL session on stdin/stdout:
- persistent process
- reusable DB sessions/pools
- concurrent in-flight queries
- id-based correlation
Connection Model (Agent-First)
Connection may be supplied by:
dsn_secretconninfo_secret- discrete fields:
host,port,user,dbname,password_secret
Optional environment fallback uses agent-first names:
AFPSQL_DSN_SECRETAFPSQL_CONNINFO_SECRETAFPSQL_HOSTAFPSQL_PORTAFPSQL_USERAFPSQL_DBNAMEAFPSQL_PASSWORD_SECRET
Standard PostgreSQL fallback names are also supported (lower precedence):
PGHOSTPGPORTPGUSERPGDATABASE
Resolution precedence:
- request/session explicit fields
- translated CLI flags (agent-first or
psql mode) - environment fallback
- built-in defaults
Large Result Strategy
Runtime decision rule
Row/command behavior is decided from PostgreSQL statement metadata after prepare:
- statement has result columns -> row result path (
resultorresult_*) - statement has no result columns -> command path
afpsql must not parse SQL text (keyword scanning, placeholder text scans, etc.)
to decide execution/output behavior.
Enforcement:
- Clippy
disallowed_methodsis enabled at crate level. - Clippy
disallowed_macrosis enabled at crate level. clippy.tomlbans:str::split_whitespace(prevent SQL keyword scanning in runtime decisions)std::eprintln(prevent protocol diagnostics from leaking to stderr)
Inline path
Return one result if payload is below both limits:
inline_max_rowsinline_max_bytes
Streaming path
When stream_rows=true:
- emit
result_startwith column metadata - read PostgreSQL rows incrementally instead of buffering the full result
- emit repeated
result_rowsbatches - emit
result_endwith totals intrace
Batch controls:
batch_rowsbatch_bytes
If streaming is off and limits are exceeded, return:
error_code: "result_too_large"
Error Taxonomy
sql_error
PostgreSQL execution failure. Include sqlstate and SQL diagnostics.
error
Client/protocol/runtime failures. Always include:
error_codeerrorretryabletrace
Runtime diagnostics:
- each query can emit structured
code: "log"diagnostics to stdout when enabled:query.resultquery.errorquery.sql_error
Agent-First Data Rules
- Agent-First Data field suffix semantics (
duration_ms,payload_bytes,_secret, …) - long-form self-describing CLI flags
- CLI/output dispatch via
agent_first_data::cli_parse_output+cli_output - CLI parse errors via
agent_first_data::build_cli_error(structuredcode:"error") - redaction on
_secretfields in config/log output
Exit Codes (CLI)
0: success (resultorresult_*)1:sql_errororerror2: invalid CLI arguments
MVP Scope
query/config/cancel/ping/closeresult+result_*streaming- connection resolution model above
- parameter binding (
params)
Future:
- prepared statement caching
- transaction workflow commands (
begin/commit/rollback) COPYstreamingLISTEN/NOTIFYbridge