Skip to main content

Architecture

HaltSQL answers one question: may this caller run this exact SQL string? It takes three inputs (a SQL string, a compiled policy, and a request context) and returns a verdict. Everything else in the repository is a way to load those inputs, deliver that answer, or act on it.

This page is the map. The other pages in this section zoom in:

PageWhat it covers
Verification pipelineOne verify call, end to end
Parserlibpg_query, the worker pool, and the typed AST
AnalyzerScopes, rule families, and how reasons are collected
Tenancy proofWhat HaltSQL must prove before it touches a tenant table
Policies and configHow documents become a compiled policy
Deployment modesLibrary, CLI, sidecar, and proxy side by side
Proxy internalsThe PostgreSQL wire-protocol firewall
Server internalsThe HTTP sidecar
Policy lifecycleDrafting, testing, signing, and rolling out policies
AssuranceHow the guarantees are tested

The core idea​

Haltsql.verify is a pure function of its three inputs. It does not open a socket, read a file, or look at the clock except to report elapsed_us. That is what makes it cheap to embed, easy to test, and safe to run in parallel.

Components​

The repository is an OCaml (dune) monorepo. Libraries live in lib/, executables in bin/, and client code in clients/ and integrations/.

The verification core​

These libraries are all you need to verify SQL. None of them does network I/O.

ComponentLibrary nameRole
lib/corehaltsql.coreShared types: Ast (the typed SQL AST), Policy (compiled policy), Context, Catalog, Reason (stable reason codes), Verdict (result and its JSON form). Depends only on yojson.
lib/pgqueryhaltsql.pgqueryThin C binding to the vendored libpg_query (Postgres 17 grammar). Parse to JSON and fingerprint, on a pool of big-stack worker threads. See Parser.
lib/parsehaltsql.parsePg_parse: size and depth limits, then converts libpg_query's JSON into the typed Ast, failing closed on anything it does not map.
lib/analyzerhaltsql.analyzerApplies the policy to one Ast.stmt: scopes and name resolution, the rule families, and tenancy proofs. Depends only on haltsql.core. See Analyzer.
lib/loadhaltsql.loadReads YAML or JSON documents (Policy, Config, Catalog, TestSuite), validates them, and compiles a Policy.t with its sha256: version. See Policies and config.
lib/haltsqlhaltsqlThe public facade. Re-exports the modules above and defines verify (in lib/haltsql/haltsql.ml) plus Suite_runner for policy test suites.

Notice what the analyzer does not depend on: the parser. It only ever sees the typed AST. That keeps the rule code independent of libpg_query's JSON shape.

Delivery surfaces​

ComponentBinaryRole
bin/haltsqlhaltsqlThe CLI: verify, explain, validate, convert, schema, test, config show, version, lint-policy, policy draft, rls, bundle. See cli.md.
bin/server (+ bin/server/lib)haltsql-serverHTTP sidecar: POST /v1/verify, GET /v1/policies, health, readiness, and /metrics. Adds authentication, audit, and telemetry around Haltsql.verify. See Server internals and http-api.md.
bin/proxy + lib/pgwirehaltsql-proxyPostgreSQL wire-protocol (v3) firewall. Clients connect to it as if it were Postgres; every simple Query and every Parse goes through Haltsql.verify, and the proxy binds tenancy parameters itself. See Proxy internals and proxy.md.

Supporting libraries​

ComponentLibrary nameUsed byRole
lib/authhaltsql-server.authserverCaller authentication: JWT/JWK, OIDC discovery and JWKS caching, mTLS identities, and merging the authenticated context with the request body context.
lib/telemetryhaltsql-server.telemetryserverOpenTelemetry (OTLP/HTTP JSON) exporter for traces and metrics, with a bounded non-blocking queue.
lib/cloudhaltsql.cloudCLI, serverAudit hash chain, S3 and GCS audit sinks, and secret resolvers for Vault, AWS Secrets Manager, and GCP Secret Manager.
lib/bundlehaltsql.bundleCLI, serverSigned policy bundles: canonical JSON, Ed25519 signatures, digest checks. See policy-bundles.md.
lib/rlsgenhaltsql.rlsgenCLIGenerates role, grant, and row-level-security DDL that mirrors a policy, and checks a live database for drift. See rls-generation.md.
lib/catalog_toolshaltsql.catalog_toolsCLICatalog snapshots from a live Postgres (schema pull), PII metadata importers, and the starter policy drafter. See schema-and-pii.md.

Clients and integrations​

DirectoryWhat it is
clients/pythonPython SDK: sync and async clients, guarded execution for psycopg 3, asyncpg and SQLAlchemy, and a local CLI verifier.
clients/typescript@haltsql/client for Node.js, with guardedTransaction and guardedQuery for node-postgres.
clients/goGo client with GuardedQueryTx and GuardedQuery for database/sql.
clients/javacom.haltsql:haltsql-client with JdbcGuard for PgJDBC.
clients/mcphaltsql-mcp, a Model Context Protocol server that gives an agent verified, read-only SQL access.
clients/testvectorsShared request and verdict vectors that every client is tested against.
integrations/langchain, integrations/llamaindex, integrations/vannaAgent framework wrappers built on the Python SDK.

The clients talk to haltsql-server over HTTP. They all follow the same rules: fail closed, check that the verdict's sql_sha256 matches the SQL they sent, and execute exactly the verified string with bind_params. See integrations.md.

Dependency graph​

The arrows point from a component to what it depends on (taken from the dune files).

Two things this graph guarantees:

  • The core has no network code. haltsql.core, haltsql.parse, haltsql.analyzer, and haltsql.load pull in no HTTP or TLS libraries. Network code lives in the supporting libraries, which the core never imports.
  • Every surface uses the same verifier. The CLI, the sidecar, and the proxy all call the same Haltsql.verify. A policy that passes haltsql test in CI behaves the same way in the sidecar and the proxy.

Where HaltSQL sits in your stack​

HaltSQL sits between the code that produces SQL (an LLM agent) and the code that runs it. There are two ways to place it.

Advisory: library, CLI, or sidecar​

Your application asks HaltSQL, then acts on the answer. This is the most common setup.

The context (for example tenant_id) comes from your authenticated session, never from the model. The SDKs implement this loop for you.

Enforcing: the wire-protocol proxy​

When you cannot change the code that runs the SQL (a BI tool, an unmodified driver), put haltsql-proxy in front of the database. The proxy authenticates each client, maps it to a policy and context, verifies every statement, and only forwards what is allowed.

In both setups, run the SQL as a restricted database role with row-level security as a second layer. haltsql rls generate can produce that DDL from the same policy. See defense-in-depth.md and Deployment modes.

Design principles​

It never rewrites SQL​

HaltSQL returns allow or deny. It never adds a WHERE clause, injects a LIMIT, or rewrites a table name. A rewriter would have to be correct for every query it touches, and a subtle bug would silently change what runs. A verifier only has to be correct about what it allows, and its mistakes show up as visible denials. The caller runs the exact string that was verified, and sql_sha256 in the verdict lets anyone check that.

(The proxy fills in tenancy parameter values at Bind time, using the values from the verdict. It does not change the SQL text.)

It never connects to the database to verify​

Verification uses only the policy, the optional catalog snapshot inside it, and the context. There is no round trip to Postgres. That keeps latency in microseconds, keeps the verifier free of database credentials, and makes a verdict reproducible offline. Schema knowledge comes from a snapshot taken ahead of time by haltsql schema pull and committed next to the policy. The tools that do talk to a database (schema pull, rls check, and the proxy's upstream connection) are separate from the verification path.

It fails closed​

Anything HaltSQL does not fully understand is denied:

  • SQL the converter does not map becomes an explicit Unsupported node, which the analyzer denies with E_UNSUPPORTED. See Parser.
  • A statement kind outside the policy is E_STATEMENT_KIND.
  • A column it cannot resolve unambiguously is E_COLUMN_UNRESOLVED. It never guesses.
  • Any exception inside verify becomes a single E_INTERNAL denial. verify is total: it always returns a verdict.
  • Rule code matches AST constructors without wildcard arms, so adding a construct to the AST is a compile error until every rule handles it.

See ADR-0002.

Reason codes are stable​

Every denial carries one or more reasons with a machine-readable code such as E_TENANT_MISSING. Codes are part of the public API: never renamed, never reused. Messages and hints can improve between releases; codes cannot. Agents and dashboards match on code. See reason-codes.md and ADR-0004.

It is deterministic​

For the same SQL, policy, and context, allowed, the reason codes, tables, columns, and bind_params are always the same. The analyzer keeps all its state in a per-call accumulator (lib/analyzer/env.ml) and has no global mutable state, so parallel calls cannot interfere. Only elapsed_us varies. The policy's version is a content hash, so a logged verdict names exactly the policy that produced it.

It uses the real Postgres parser​

HaltSQL parses with libpg_query, which is the actual PostgreSQL grammar extracted into a C library. A hand-written grammar would drift from the server, and any difference between how HaltSQL and Postgres read a string is a potential bypass. See ADR-0001.