---
title: "HeliosProxy Configuration Reference"
---

# HeliosProxy Configuration Reference

Complete reference for HeliosProxy (`heliosdb-proxy`) configuration. HeliosProxy uses
TOML for its configuration file. The authoritative parser is `ProxyConfig` (and its
section structs) in `src/config.rs`; every key documented here is a real field of that
type. Keys, sections, and defaults are kept in sync with the code — if a section is not
listed here, it is not part of `ProxyConfig` and is silently ignored on load (see
[Unknown Keys](#unknown-keys-are-warned-not-rejected-by-default)).

---

## Usage

```bash
# Start with a configuration file
heliosdb-proxy --config /etc/heliosproxy/config.toml

# Start with command-line arguments (no config file)
heliosdb-proxy \
  --listen 0.0.0.0:6432 \
  --admin 127.0.0.1:9090 \
  --primary db-primary:5432 \
  --standby db-standby-1:5432 \
  --standby db-standby-2:5432

# Override log level
heliosdb-proxy --config config.toml --log-level debug

# Emit JSON-structured logs
heliosdb-proxy --config config.toml --json-logs
```

---

## Command-Line Arguments

| Argument | Default | Description |
|----------|---------|-------------|
| `--config`, `-c` | *(none)* | Path to TOML configuration file. |
| `--listen`, `-l` | `0.0.0.0:5432` | Client (PostgreSQL-wire) listen address. |
| `--admin` | `127.0.0.1:9090` | Admin API listen address. Loopback by default — see [Admin API Security](#admin-api-security). |
| `--primary` | *(none)* | Primary node `host:port`. |
| `--standby` | *(none)* | Standby node `host:port` (repeatable). |
| `--tr` | `true` | Enable Transaction Replay. Takes an optional value: `--tr false` or `--tr=false` turns it off; a bare `--tr` means `true`. |
| `--log-level` | `info` | Log level: `trace`, `debug`, `info`, `warn`, `error`. |
| `--json-logs` | `false` | Emit logs in JSON format. |

There is also an `install skills` subcommand (`--target claude|codex|both`,
`--symlink`, `--force`, `--dry-run`) for installing the bundled agent skills.

When `--config` is provided, the file drives the whole configuration. Command-line node
arguments (`--primary`, `--standby`) build an in-memory config only when no config file
is supplied.

### Signals

- **SIGHUP** — live configuration reload.
- **SIGUSR2** — graceful drain for a zero-downtime binary handoff, bounded by
  `shutdown_drain_timeout_secs` (env override `HELIOS_DRAIN_TIMEOUT_SECS`).
- There is **no** SIGTERM / Ctrl-C handler: SIGTERM terminates the process immediately.

---

## Environment-Variable Substitution

Before the file is parsed as TOML, HeliosProxy expands environment-variable references
in the raw text (`ProxyConfig::from_file` → `substitute_env`):

| Syntax | Meaning |
|--------|---------|
| `${NAME}` | Replaced with the value of environment variable `NAME`. If `NAME` is **unset**, startup fails fast with an error naming the variable — the literal is never left in the output. |
| `${NAME:-default}` | Replaced with `NAME` if set, otherwise the literal `default` text (which may be empty; it runs up to the first `}`). |

```toml
listen_address = "${HELIOS_LISTEN:-0.0.0.0:5432}"
admin_token    = "${ADMIN_TOKEN}"          # fails at startup if ADMIN_TOKEN is unset

[branch]
admin_password = "${PGPASSWORD}"

# max_connections = ${POOL_MAX:-100}       # unquoted numeric substitution is valid TOML
```

Rules and limits:

- `NAME` must match `[A-Za-z_][A-Za-z0-9_]*`.
- Substitution is **in place**, so an unquoted `${POOL_MAX:-100}` becomes the bare
  token `100` (valid TOML) and a quoted `"${X:-y}"` becomes `"y"`.
- **Env lookup only.** No shell is spawned and nothing is evaluated — only the env
  lookup plus the `:-` default operator run.
- **Comment handling is line-level.** A line whose first non-whitespace byte is `#` (a
  full-line comment) is copied verbatim and never substituted, so commented `${VAR}`
  examples in the reference configs included with HeliosProxy do not trigger the unset-variable error. A
  trailing `#` comment on a value line is **not** treated as a comment.
- **Trust boundary.** Substitution is textual and runs *before* the TOML parse, so a
  substituted value containing a quote or newline can inject arbitrary TOML structure.
  Environment variables and the config file are operator-controlled, which makes this
  acceptable — but do **not** feed an untrusted-party-controlled environment variable
  into a `${...}` reference.

### Real environment variables

There is **no** generic `HELIOS_PROXY_*` "override any value" system. Only one runtime
environment variable is consulted directly by the proxy:

| Variable | Effect |
|----------|--------|
| `HELIOS_DRAIN_TIMEOUT_SECS` | Overrides `shutdown_drain_timeout_secs` at runtime (SIGUSR2 drain bound). |

Everything else is configured through the file (optionally via `${VAR}` substitution
above) or the command-line arguments.

---

## Unknown Keys Are Warned, Not Rejected (By Default)

`ProxyConfig` does **not** use `deny_unknown_fields`. Any TOML key that is not a
recognized `ProxyConfig` field — top-level *or nested inside a known section* — is
parsed-and-ignored, and each one is logged once at startup as a warning, with its full
dotted path:

```
WARN unknown config key '<key>' ignored (not part of ProxyConfig)
WARN unknown config key 'cache.l1_max_byts' ignored (not part of ProxyConfig)
```

This makes silent doc-drift and typos visible without breaking pre-existing configs.
Historical example blocks such as `[ha]`, `[logging]`, `[metrics]`, `[routing]`,
`[distribcache]`, `[graphql]`, `[[tenants]]`, and `[[schema_routes]]` are **not** part of
`ProxyConfig` — they will be warned and ignored. Their real counterparts are documented
below (`lag_routing`, `multi_tenancy`, `graphql_gateway`, `schema_routing`, …).

Detection covers every nesting depth (built on `serde_ignored`, not a hand-maintained
whitelist), so a typo inside an otherwise-valid section — e.g. `[cache] l1_max_byts = 1`
instead of `max_result_bytes`, or `[journal] fsyncc = "commit"` instead of `fsync` — is
caught exactly like an unknown top-level section. Set `strict_config = true` to turn
every one of these warnings into a hard startup failure instead (see below).

---

## Top-Level Options

```toml
listen_address = "0.0.0.0:5432"
admin_address  = "127.0.0.1:9090"
# admin_token          = "..."     # bearer token for the admin API (see below)
# admin_allow_insecure = false
# strict_config        = false     # reject settings this build cannot deliver
tr_enabled     = true
tr_mode        = "session"
tr_read_functions = []
write_timeout_secs        = 30
optimize_unnamed_parse    = true
shutdown_drain_timeout_secs = 60
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `listen_address` | string | `"0.0.0.0:5432"` | Address/port for PostgreSQL client connections. *(Required in a config file.)* |
| `admin_address` | string | `"127.0.0.1:9090"` | Address/port for the admin HTTP API. Loopback by default. *(Required in a config file.)* |
| `admin_token` | string | *(none)* | Bearer token required on every admin endpoint except liveness probes. See [Admin API Security](#admin-api-security). |
| `admin_allow_insecure` | bool | `false` | Explicit opt-in to expose the admin API on a non-loopback address **without** a token. |
| `strict_config` | bool | `false` | Opt-in strict configuration: startup fails when an enabled subsystem was not compiled into the binary (e.g. `[cache] enabled = true` without the `query-cache` feature) or when an unknown config key is present, at any nesting depth (an unknown top-level section, or a typo'd key inside a known section such as `[cache] l1_max_byts`). Default `false` keeps the historical warn-and-continue behaviour. `GET /capabilities` reports the per-subsystem compiled/enabled/wired state. |
| `tr_enabled` | bool | `true` | Master switch for Transaction Replay, which is part of the default build. When `false`: the write journal stops recording, `tr_mode` is forced to `none`, and `POST /api/replay` returns `503`. *(Required in a config file.)* |
| `tr_mode` | string | `"session"` | Transaction Replay mode: `none`, `session`, `select`, `transaction`. *(Required in a config file.)* |
| `tr_read_functions` | array of string | `[]` | Read re-execution policy extension. In-session replay re-executes an interrupted read on an unknown outcome only when every function it calls is a PostgreSQL built-in known to be side-effect-free; list additional provably pure functions (unqualified names, case-insensitive) here. Reads calling anything else, quoted-identifier calls, `SELECT … INTO`, and sequence functions are classified as opaque and never re-executed. |
| `write_timeout_secs` | u64 | `30` | Seconds to buffer writes during failover before returning an error — and the **single deadline for a whole session recovery**: waiting for a primary, connect/auth, session-state restore and replay all draw on it, instead of each having its own timeout. |
| `optimize_unnamed_parse` | bool | `true` | Skip re-forwarding an identical unnamed extended-protocol `Parse` a backend already holds, synthesizing `ParseComplete` locally. A kill-switch for drivers that depend on the redundant round trip. |
| `shutdown_drain_timeout_secs` | u64 | `60` | How long a SIGUSR2 binary-handoff drain keeps serving in-flight connections before dropping them. Runtime override: `HELIOS_DRAIN_TIMEOUT_SECS`. |

The sections `[pool]`, `[load_balancer]`, `[health]`, and at least one `[[nodes]]` entry
are also required in a config file (they have no serde defaults). Every other section
listed below is optional and defaults to disabled/off.

### Transaction Replay Modes (`tr_mode`)

`tr_mode` governs **in-session failover**: what a live client session experiences
when its backend connection fails while a request is in flight (write error /
timeout, read error, EOF, reset) or dies while idle. It needs no cargo feature —
Transaction Replay is in the default build — but `tr_enabled = false` forces the
effective mode to `none` and disables the operator-driven `POST /api/replay`
engine at the same time. Two fault phases are distinguished:
**not-delivered** (the request never reached the backend — it certainly did not
run) and **outcome-unknown** (written, then the connection failed before
`ReadyForQuery`).

| Mode | Behaviour on a backend fault |
|------|-------------|
| `none` | One `ErrorResponse` (SQLSTATE `57P01`, naming the failed node) + `ReadyForQuery`, then the client connection is closed. |
| `session` (default) | The client connection stays open. The proxy waits for a healthy primary (`write_timeout_secs`), reconnects (startup parameters re-sent), replays the session's tracked `SET`/`RESET` statements, and re-prepares named prepared statements lazily. A not-delivered statement issued **outside** an explicit transaction is re-executed transparently. Anything else gets ONE error — `57P01` (not delivered inside a transaction) or `08007 transaction_resolution_unknown` — the client-visible transaction is aborted (`ROLLBACK` and retry; other statements get `25P02` until then) and the session continues on the new primary. |
| `select` | `session`, plus: an outcome-unknown **read** (`SELECT`/`SHOW`/`VALUES`/read-only `WITH`/`COPY … TO`) is re-executed transparently outside a transaction, **but only when every function it calls is known side-effect-free** — a PostgreSQL built-in on the allowlist or a name you listed in `tr_read_functions`. A read calling a user-defined function, `nextval`, `pg_notify`, `set_config`, an advisory lock, a quoted-identifier call, or using `SELECT … INTO` is opaque: it returns `08007` and is never run twice. A **read-only** explicit transaction is replayed from its `BEGIN` first and the interrupted statement re-run inside it. |
| `transaction` | `select`, plus: an **uncommitted** explicit transaction is replayed from its `BEGIN` on the new primary (recorded simple-protocol text / raw extended-protocol batches) and the in-flight statement is re-executed inside it. Replay is **verified**: each statement's original response frames were digested as the client saw them (within `[limits] tr_max_observation_bytes`) and the replacement backend's responses are digested the same way — any divergence rolls the replay back and returns `40001`, so a replay that would silently continue on different rows fails loudly instead. A failed replayed statement produces the same `40001`. A `COMMIT`/`END`/`PREPARE TRANSACTION`/`COMMIT PREPARED` whose outcome is unknown is **never** retried (`08007`). Still opt-in: verification catches divergent *results*, it does not make `now()` or a `RETURNING` serial reproduce its first value. |

Hard rules in every mode: a write whose outcome is unknown is never re-executed
except as part of `transaction` mode's replay of an uncommitted transaction (the
original died uncommitted with the old backend); a `COMMIT` with unknown outcome
is never retried; `SAVEPOINT`/`RELEASE`/`ROLLBACK TO` are ordinary replayed
statements; a `COPY` in progress at the fault → `08006` and the connection is
closed. A transaction is marked non-replayable (so `transaction` degrades to
`session` for it) when it exceeds `[limits] tr_max_replay_statements` /
`tr_max_replay_bytes`, enters the failed state, contains a `COPY`, or one of
its statements was transformed by query-rewrite / multi-tenancy, runs at
`SERIALIZABLE` or `REPEATABLE READ` (explicitly, via `SET TRANSACTION`, or
inherited from a tracked `default_transaction_isolation` — no replay can
reproduce that snapshot), or produced a response too large to have been digested
within `[limits] tr_max_observation_bytes`.

Session `SET` tracking is transactional: a `SET` inside a transaction takes effect
in the restore set only when that transaction commits, `ROLLBACK TO SAVEPOINT`
discards the ones made after the savepoint, and `RESET`/`RESET ALL`/`DISCARD ALL`
inside a transaction are deferred to commit. Variables are keyed by name, so
repeating a `SET` reuses its slot; the cap `tr_max_session_set_statements` counts
**distinct variables**, and exceeding it refuses the failover with `08006` rather
than re-homing a session with incomplete state. It covers simple-protocol
statements only — extended-protocol `SET`s as sent by JDBC, SQL-level `PREPARE`,
session temp tables and cursors are not restored. (Extended-protocol *named
prepared statements* are a separate mechanism: those are re-prepared lazily on the
replacement backend.)

**Backend credentials.** Re-homing a session means opening a *fresh* backend
connection. In pass-through auth mode the proxy never sees the client's
password (SCRAM is designed for that), so a fresh connection can only be
completed against a backend that does not challenge (trust); a challenging
backend fails the recovery immediately with `08006` ("the proxy could not
authenticate to the replacement primary"). To fail over onto password-protected
(SCRAM-SHA-256 / MD5 / cleartext) backends, make the proxy the auth boundary:
`[auth] mode = "scram"` with a **plaintext** `auth_file` entry for the user —
the proxy then authenticates the client itself and uses the same secret as a
SCRAM client towards every backend connection it opens (this also removes the
former "scram mode needs a trust backend" restriction on the first connection).

Counters on `/metrics` and `/metrics/prometheus`: `tr_failovers_total`,
`tr_statements_reexecuted_total`, `tr_transactions_replayed_total`,
`tr_replay_failures_total`, `tr_unknown_outcome_errors_total`,
`tr_replay_cap_exceeded_total`, `tr_session_set_cap_exceeded_total`.

---

## Admin API Security

The admin API runs privileged operations (arbitrary SQL via `/api/sql`, forced failover
via `/api/chaos`, migration cutover, branch `CREATE`/`DROP DATABASE`, replay/shadow
against operator-chosen targets), so its exposure is guarded at startup:

- **Default bind is loopback** (`127.0.0.1:9090`). A fresh install is safe.
- If `admin_address` parses to a **non-loopback** IP **and** `admin_token` is unset
  **and** `admin_allow_insecure` is `false`, the proxy **refuses to start** with a
  descriptive error. Fix it by setting `admin_token`, binding to `127.0.0.1`, or
  setting `admin_allow_insecure = true` (only when you front the admin port with your
  own authenticating proxy / network policy).
- When `admin_token` is set, every admin endpoint requires
  `Authorization: Bearer <token>` except the liveness probes.

Startup validation also rejects `health.check_interval_secs = 0` (a zero interval would
panic the health-check timer and silently stop probing).

---

## Pool Mode (`[pool_mode]`)

Controls Session/Transaction/Statement pooling.

```toml
[pool_mode]
mode = "transaction"
max_pool_size = 100
min_idle = 10
idle_timeout_secs = 600
max_lifetime_secs = 3600
acquire_timeout_secs = 5
reset_query = "DISCARD ALL"
prepared_statement_mode = "track"
skip_clean_reset = false
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `mode` | string | `"session"` | Pooling mode: `session`, `transaction`, `statement`. |
| `max_pool_size` | u32 | `100` | Maximum backend connections per node. |
| `min_idle` | u32 | `10` | Minimum idle connections to maintain. |
| `idle_timeout_secs` | u64 | `600` | Close idle connections after this many seconds. |
| `max_lifetime_secs` | u64 | `3600` | Recycle connections after this many seconds. |
| `acquire_timeout_secs` | u64 | `5` | Max seconds to wait when acquiring from the pool. Also bounds how long a new backend connection keeps retrying when PostgreSQL refuses it at `max_connections` (SQLSTATE 53300): each retry first closes one idle pooled connection to that node, and only after this timeout does the client receive the 53300. Size the backend's `max_connections` for the pool plus the expected burst of new client logins, since every pass-through login opens its own backend connection. |
| `reset_query` | string | `"DISCARD ALL"` | SQL run when a connection returns to the pool. |
| `prepared_statement_mode` | string | `"disable"` | Prepared-statement handling: `disable`, `track`, `named`. |
| `skip_clean_reset` | bool | `false` | Transaction/Statement pooling only: park a connection that provably touched **no** session state (no `SET`/GUC, temp table, prepared statement, `LISTEN`, advisory lock, …) *without* running `reset_query`, saving a round-trip per clean transaction. Classification is conservative — a misclassification only ever costs an unnecessary reset, never leaks state. Intended for autocommit / simple-protocol workloads. |

### Pooling Modes

| Mode | Returns To Pool | Best For |
|------|-----------------|----------|
| `session` | When the client disconnects (1:1 client↔backend). | Prepared statements, long-running sessions, legacy apps. |
| `transaction` | After `COMMIT`/`ROLLBACK`. | Web apps, microservices, connection-starved environments. |
| `statement` | After each statement. | Simple read-heavy workloads without multi-statement transactions. |

### Prepared Statement Modes

| Mode | Behavior |
|------|----------|
| `disable` | Not tracked. Safest for transaction/statement pooling. |
| `track` | Track PREPARE/DEALLOCATE and recreate on a new backend connection. |
| `named` | Protocol-level named statements. Compatible with session pooling. |

---

## Connection Pool (`[pool]`)

Core per-node connection pool. *(Required section.)*

```toml
[pool]
min_connections = 2
max_connections = 100
idle_timeout_secs = 300
max_lifetime_secs = 1800
acquire_timeout_secs = 30
test_on_acquire = true
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `min_connections` | usize | `2` | Minimum connections per node. |
| `max_connections` | usize | `100` | Maximum connections per node. Must be ≥ `min_connections`. |
| `idle_timeout_secs` | u64 | `300` | Close connections idle longer than this. |
| `max_lifetime_secs` | u64 | `1800` | Maximum connection lifetime before recycling. |
| `acquire_timeout_secs` | u64 | `30` | Max wait for a connection from the pool. |
| `test_on_acquire` | bool | `true` | Health-check a connection before handing it out. |

---

## Load Balancer (`[load_balancer]`)

*(Required section.)*

```toml
[load_balancer]
read_strategy = "round_robin"
read_write_split = true
latency_threshold_ms = 100
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `read_strategy` | string | `"round_robin"` | Read routing strategy (see below). |
| `read_write_split` | bool | `true` | Route writes to the primary, reads to standby/replica nodes. |
| `latency_threshold_ms` | u64 | `100` | Latency above which a node is treated as unhealthy for routing. |

### Routing Strategies

| Strategy | Description |
|----------|-------------|
| `round_robin` | Rotate through nodes equally. |
| `weighted_round_robin` | Rotate proportionally to each node's `weight`. |
| `least_connections` | Route to the node with the fewest live sessions currently attached to it. |
| `latency_based` | Route to the node with the lowest measured health-probe latency. |
| `random` | Pick a node at random. |
| `power_of_two` | Sample two eligible nodes and pick the one with fewer attached sessions, breaking ties on measured latency. Cheaper and more herd-resistant than global least-connections. |

All strategies are applied to the healthy, enabled standbys after circuit-breaker and
lag exclusion; a strategy is only ever asked to choose among eligible nodes. The daemon
honors `[load_balancer] read_strategy` on the read path.

---

## Health Checks (`[health]`)

*(Required section.)*

```toml
[health]
check_interval_secs = 5
check_timeout_secs = 3
failure_threshold = 3
success_threshold = 2
check_query = "SELECT 1"
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `check_interval_secs` | u64 | `5` | Interval between probes. **Must be ≥ 1** (0 is rejected at startup). |
| `check_timeout_secs` | u64 | `3` | Max wait for a probe response. |
| `failure_threshold` | u32 | `3` | Consecutive failures before marking a node unhealthy. |
| `success_threshold` | u32 | `2` | Consecutive successes before a node marked unhealthy is considered healthy again. |
| `check_query` | string | `"SELECT 1"` | Health-check query. Used by the daemon only when `[health] user` is set; the default credential-less probe is a PostgreSQL `SSLRequest` liveness check. |
| `user` / `password` / `database` | string / string / string | *(none)* | Optional credentials for the daemon's health and lag probes. When set, the daemon connects and runs `check_query` (and, for standbys, WAL-position lag probes) instead of the credential-less protocol probe. |

---

## Topology (`[topology]`)

Authoritative primary tracking. Optional; absent = `provider = "static"`,
which preserves the historical behaviour (the configured `role = "primary"` node whose
health check passes is the primary).

```toml
[topology]
provider = "static"          # static | postgres | patroni
poll_interval_secs = 2
lease_timeout_secs = 10
user = "postgres"
# password = "${HELIOS_PROXY_TOPOLOGY_PASSWORD}"
database = "postgres"
# patroni_endpoints = ["http://10.0.0.1:8008", "http://10.0.0.2:8008"]
# patroni_request_timeout_ms = 2000
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `provider` | string | `"static"` | `static`: configured roles + the daemon health checker decide the primary. `postgres`: poll `pg_is_in_recovery()` on every configured node and treat the non-recovering node as the primary. `patroni`: poll the Patroni REST API (`GET /cluster`) and treat the cluster's running leader as the primary. `postgres` and `patroni` require the `postgres-topology` cargo feature — a config that requests them on a build without that feature is rejected at startup. |
| `poll_interval_secs` | u64 | `2` | Seconds between provider polls. **Must be ≥ 1** (0 is rejected at startup). |
| `lease_timeout_secs` | u64 | `10` | How long a provider observation stays authoritative without a refresh. When the provider cannot be reached, the write path stops after at most this long instead of proceeding on stale knowledge. **Must be ≥ 1**. |
| `user` | string | `"postgres"` | User for the provider's own probe connections. |
| `password` | string | *(none)* | Password for the probe user, if required. |
| `database` | string | `"postgres"` | Database the probe connects to. |
| `patroni_endpoints` | list of URLs | `[]` | Patroni REST API base URLs (`http(s)://host:8008`), tried in order each poll; the first that answers is used. **Required** for `provider = "patroni"`; each must start with `http://` or `https://`. |
| `patroni_request_timeout_ms` | u64 | `2000` | Timeout for one Patroni REST request. **Must be ≥ 1**. |

When a provider is configured it is **authoritative for the write path**: writes go only
to the provider's leader (which must be an enabled `[[nodes]]` entry), the answer waits
rather than falling back to a configured role while no leader is known, and
`GET /topology` reports the leader plus an `authoritative` block with its authority
epoch (incremented on every observed leader change), `valid`, and the remaining lease.
This is how a real promotion moves the write destination without hand-editing
`proxy.toml` roles.

**Fencing limits.** A proxy-side epoch/lease cannot fence a client that connects
directly to a database node: the proxy can only refuse to route, and it fails closed once
the lease expires (`valid: false`, writes wait). Preventing two sides of a partition from
both accepting writes requires enforcement by the database (synchronous replication and
promotion fencing) or by exclusive network/backend control. The proxy never advertises
zero-loss (RPO 0) for an asynchronous replica that has not acknowledged the last commit —
configure synchronous replication at the database if that is your requirement.

---

## Nodes (`[[nodes]]`)

One entry per backend. At least one node with `role = "primary"` is required.

```toml
[[nodes]]
host = "db-primary.internal"
port = 5432
role = "primary"
weight = 100
enabled = true
name = "primary-1"

[[nodes]]
host = "db-standby-1.internal"
port = 5432
role = "standby"
weight = 100
enabled = true
name = "standby-1"
```

| Key | Type | Default | Required | Description |
|-----|------|---------|----------|-------------|
| `host` | string | — | Yes | Backend hostname or IP. |
| `port` | u16 | — | Yes | PostgreSQL-protocol port. |
| `http_port` | u16 | `8080` | No | Port of the backend's HTTP SQL endpoint. Used only by the admin `POST /api/sql` route, which forwards to `http://<host>:<http_port>/api/sql`; unused for backends without one, such as plain PostgreSQL. |
| `role` | string | — | Yes | `primary`, `standby`, or `replica`. |
| `weight` | u32 | — | Yes* | Load-balancing weight. |
| `enabled` | bool | — | Yes* | Whether the node is routable. Toggleable at runtime via the admin API. |
| `name` | string | *(none)* | No | Human-readable name for logs/metrics/admin. |

\* `weight` and `enabled` have no serde default — supply them explicitly per node.

### Node Roles

| Role | Description |
|------|-------------|
| `primary` | Read/write node. All writes and transaction-control statements route here. At least one required. |
| `standby` | Promotable standby. Eligible for failover; receives reads when `read_write_split` is on. |
| `replica` | Read-only replica. Not promotable; receives reads only. |

---

## TLS (`[tls]`)

Optional TLS termination for client connections. Omit the whole section to disable.

```toml
[tls]
enabled = true
cert_path = "/etc/heliosproxy/server.crt"
key_path = "/etc/heliosproxy/server.key"
ca_path = "/etc/heliosproxy/ca.crt"
require_client_cert = false
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | — | Enable TLS for client-facing connections. |
| `cert_path` | string | — | PEM server certificate path. |
| `key_path` | string | — | PEM private key path. |
| `ca_path` | string | *(none)* | CA cert for client-certificate verification. |
| `require_client_cert` | bool | — | Require a valid client certificate. |

---

## Query Cache (`[cache]`)

In-process query-result cache. Only active with the `query-cache` feature and
`enabled = true`. It is **off by default** and stays opt-in: invalidation is
commit-aware for everything that goes through the proxy (below), but cached results are
not keyed by session state, and writes that bypass the proxy are bounded only by
`ttl_secs` — see the two caveats at the end of this section before enabling it.

```toml
[cache]
enabled = true
ttl_secs = 300
max_result_bytes = 1048576
max_cacheable_response_bytes = 4194304
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | `false` | Serve read SELECT results from the L1/L2 cache. |
| `ttl_secs` | u64 | `300` | Time-to-live for cached results. |
| `max_result_bytes` | usize | `1048576` | Largest single result to cache; larger results bypass. |
| `max_cacheable_response_bytes` | usize | `4194304` (4 MiB) | Ceiling on the transient buffer used to capture a response while it streams. A response that grows past it is marked non-cacheable and the buffer is released; the client always receives the full, unmodified response either way. Must be `>= 1` (a `0` is rejected at startup, since it would make every response uncacheable while still looking like a working cache). Sits above the 1 MiB `max_result_bytes` default so it never masks that knob, and it governs the shared capture used by **both** the query cache and the edge cache. |

**Invalidation is commit-aware on both protocols.** Every cached result records
the write generation of the tables it read, taken before its backend fetch, and every
hit, from L1 or L2, is served only while no write to those tables has happened since.
Writes are recognised from what the backend actually executed (the same capture that
feeds the transaction journal), on the simple and the extended protocol alike:

- a successful autocommit write moves its tables' generation before the
  `ReadyForQuery` that acknowledges it reaches the client, so no read that starts
  afterwards can hit an older entry;
- inside an explicit transaction the tables are staged (other sessions cannot see the
  rows yet, and a session in a transaction is never served from the cache) and moved
  when the backend reports the commit, before the client sees it, so an entry a
  concurrent reader refilled from pre-commit data is refused; a rollback drops the
  stage;
- a result whose tables were written while it was being fetched is not stored;
- a write whose tables cannot be read from its text — DDL, `TRUNCATE`, `COPY ... FROM`,
  `EXECUTE`, `CALL`, `DO` — invalidates the whole cache.

Not covered: writes that do not pass through this proxy (another proxy, a direct
connection, a trigger or function writing a table other than the one named in the
statement), and two-phase commit (`PREPARE TRANSACTION` / `COMMIT PREPARED`: the
prepared transaction's tables are not invalidated when it is committed). Such changes
become visible when the entry's `ttl_secs` expires; keep the TTL short, or leave the
cache off, for tables written that way.

**Why it stays opt-in: cache identity is the SQL, the database, the user and the branch — not session state.**
Two sessions of the same user that differ only in `SET ROLE`, `search_path`, or a custom
setting read by row-level-security policies (for example `app.tenant_id` set per request
on a shared connection user) can be served each other's cached result. Leave the cache
off for workloads whose results depend on such session state. The proxy's own
`[multi_tenancy]` filtering is safe: it rewrites the SQL per tenant before the cache
lookup.

---

## Lag-Aware Routing (`[lag_routing]`)

Replica-lag-aware routing + read-your-writes. Only enforced with the `lag-routing`
feature and `enabled = true`.

```toml
[lag_routing]
enabled = true
ryw_window_ms = 500
max_lag_bytes = 0
require_known_lag = false
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | `false` | Enable lag-aware read routing + read-your-writes. |
| `ryw_window_ms` | u64 | `500` | Reads within this many ms of a write in the same session pin to the primary (read-your-writes). 0 disables the window. |
| `max_lag_bytes` | u64 | `0` | Exclude a standby when its measured replication lag exceeds this many bytes. 0 = no lag-based exclusion. |
| `require_known_lag` | bool | `false` | Strict freshness: when `true`, a standby whose lag was never measured is excluded instead of being treated as fresh. Lag is measured only when `[health] user` is configured and this feature is enabled. |

---

## Routing Hints (`[routing_hints]`)

SQL-comment routing hints (`/*helios:route=primary*/`). Only honored with the
`routing-hints` feature and `enabled = true`.

```toml
[routing_hints]
enabled = true
strip_hints = true
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | `false` | Parse and honor `/*helios:...*/` hints; an applied hint overrides default verb routing (but never a plugin `Block`). |
| `strip_hints` | bool | `true` | Remove the hint comment from SQL before forwarding to the backend. |

---

## Rate Limiting (`[rate_limit]`)

Token-bucket + concurrency limiting. Only enforced with the `rate-limiting` feature and
`enabled = true`.

```toml
[rate_limit]
enabled = true
default_qps = 1000
default_burst = 2000
max_concurrent = 0
key_by = "user"
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | `false` | Enforce rate limits. |
| `default_qps` | u32 | `1000` | Sustained queries/sec per bucket. |
| `default_burst` | u32 | `2000` | Token-bucket depth (burst) per bucket. |
| `max_concurrent` | u32 | `0` | Max concurrent in-flight queries per bucket (0 = engine default). |
| `key_by` | string | `"user"` | Bucket key: `user`, `client_ip`, `database`, `global`. |

---

## Circuit Breaker (`[circuit_breaker]`)

Per-node circuit breaker. Only enforced with the `circuit-breaker` feature and
`enabled = true`.

```toml
[circuit_breaker]
enabled = true
failure_threshold = 5
open_secs = 10
success_threshold = 3
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | `false` | Trip failing backends out of rotation. |
| `failure_threshold` | u32 | `5` | Consecutive failures that open a node's circuit. |
| `open_secs` | u64 | `10` | How long a circuit stays open before a half-open probe. |
| `success_threshold` | u32 | `3` | Successful probes required to close a half-open circuit. |

---

## Operational Limits (`[limits]`)

Session/protocol safety bounds and relay timeouts. Each key was previously a
compiled-in constant; the defaults reproduce those constants exactly, so an
absent `[limits]` block is byte-for-byte unchanged. All values are resolved once
at startup. `validate()` rejects `0` for the safety bounds (where a `0` would
disable a bound rather than mean anything useful) and caps every `*_secs` key at
one year (`31536000`), which would otherwise overflow the connect-time deadline.
The two opt-in client bounds are the deliberate exceptions: `max_client_connections`
and `client_idle_timeout_secs` both use `0` to mean *unlimited / disabled*, which is
their default.

```toml
[limits]
max_cancel_keys = 100000
startup_timeout_secs = 30
backend_write_timeout_secs = 30
backend_read_timeout_secs = 30
client_write_timeout_secs = 60
reprepare_timeout_secs = 15
max_prepared_statements = 8192
max_prepared_bytes = 67108864
max_pending_bytes = 67108864
max_backend_frame_bytes = 104857600
backend_response_timeout_secs = 0
tr_max_observation_bytes = 1048576
max_total_idle_backend_conns = 8192
pool_reap_interval_secs = 30
replay_deadline_secs = 300
max_client_connections = 0
client_idle_timeout_secs = 0
client_admission_wait_secs = 0
gateway_pool_max_idle = 16
tr_max_replay_statements = 1000
tr_max_replay_bytes = 4194304
tr_max_session_set_statements = 256
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `max_cancel_keys` | usize | `100000` | Capacity of the query-cancellation key map (`BackendKeyData` → backend address); at capacity the oldest entries are FIFO-evicted. |
| `startup_timeout_secs` | u64 | `30` | Deadline for the pre-auth startup exchange (TLS negotiation + startup/authentication); bounds slow-loris handshakes. |
| `backend_write_timeout_secs` | u64 | `30` | Timeout for a single backend write on the forward path. |
| `backend_read_timeout_secs` | u64 | `30` | Timeout for a single backend read on the relay path (paired with `backend_write_timeout_secs`; a slow-but-healthy read is not itself a fault). |
| `client_write_timeout_secs` | u64 | `60` | Timeout for a single client write, so a wedged client cannot pin a proxy task (and its backend connection) forever. |
| `reprepare_timeout_secs` | u64 | `15` | Timeout for the out-of-band re-prepare exchange performed on a backend connection switch. |
| `max_prepared_statements` | usize | `8192` | Per-session cap on distinct named prepared statements. |
| `max_prepared_bytes` | usize | `67108864` | Per-session cap on aggregate bytes retained in the statement registry (64 MiB). |
| `max_pending_bytes` | usize | `67108864` | Per-session cap on the un-flushed extended-protocol `pending` buffer (64 MiB). |
| `max_backend_frame_bytes` | usize | `104857600` | Cap on the declared length of one backend response frame on every streaming relay; a header above it or below the 4-byte minimum closes the backend as malformed (100 MiB, the frontend message cap). Bounds a frame, not a result set. |
| `backend_response_timeout_secs` | u64 | `0` | Whole-response deadline for one backend response on the streaming relays, first read to `ReadyForQuery`. `backend_read_timeout_secs` re-arms per read and cannot catch a slow drip inside one response; this can. `0` disables (prior behaviour). |
| `tr_max_observation_bytes` | usize | `1048576` | In-session TR: response bytes hashed per recorded statement into an observation digest, re-verified when the transaction is replayed; divergence rolls the replay back with `40001`. A response over the cap has no verifiable digest, so its transaction becomes non-replayable. |
| `max_total_idle_backend_conns` | usize | `8192` | Global ceiling on idle backend-pool connections across all `(node,user,db)` identities. Only consumed with the `pool-modes` feature; parsed-and-ignored otherwise. |
| `pool_reap_interval_secs` | u64 | `30` | How often the idle-connection reaper runs. |
| `replay_deadline_secs` | u64 | `300` | Overall wall-clock deadline for one operator time-window replay (`POST /api/replay`). `query_timeout` bounds each statement; this bounds the run. On expiry the replay stops at the current statement and reports `deadline_exceeded` + partial progress. `0` disables the overall deadline. Max `31536000`. |
| `max_client_connections` | usize | `0` | Ceiling on concurrently-served client connections on the PG-wire listener. `0` = unlimited. Excess connections are refused with SQLSTATE 53300 (see `client_admission_wait_secs`) and counted in `/metrics` as `connections_rejected`; cancel requests are never refused. Applied at startup; a SIGHUP change is ignored with a warning. |
| `client_idle_timeout_secs` | u64 | `0` | Close an authenticated session that sits this many seconds between statements, with SQLSTATE `57P05`. `0` = wait forever (historical behaviour). A session idle **inside an open transaction** is terminated too (PostgreSQL's separate `idle_in_transaction_session_timeout` is not implemented). Applied at startup. |
| `client_admission_wait_secs` | u64 | `0` | Bounded fair-admission queue: when `max_client_connections` is reached, a new connection waits up to this many seconds for a permit before being refused with 53300. `0` refuses immediately. Health/admin traffic is unaffected (health probes hold no client slot; admin is a separate listener). Applied at startup. |
| `gateway_pool_max_idle` | usize | `16` | Idle authenticated backend connections kept per identity by each non-PG-wire gateway (HTTP SQL, MCP, GraphQL) for reuse. `0` disables pooling (each gateway request dials a new backend connection). Gateways have separate pools so a session policy (e.g. MCP read-only) cannot leak across interfaces. Applied at startup. |
| `tr_max_replay_statements` | usize | `1000` | In-session TR (`tr_mode = select\|transaction`): cap on statements recorded per explicit transaction for failover replay. Over the cap the transaction is marked non-replayable (`transaction` degrades to `session` for it; `tr_replay_cap_exceeded_total`). |
| `tr_max_replay_bytes` | usize | `4194304` | In-session TR: cap on bytes (statement text / raw extended-protocol frames) recorded per explicit transaction (4 MiB). Same degradation as above. |
| `tr_max_session_set_statements` | usize | `256` | In-session TR (`tr_mode != none`): cap on DISTINCT session variables whose latest `SET` is replayed onto the replacement backend (a later `SET` of the same variable replaces the earlier one; `RESET name` removes it; `RESET ALL`/`DISCARD ALL` clear all). Over the cap tracking stops (`tr_session_set_cap_exceeded_total`) and a subsequent failover is refused with `08006` rather than re-homing the session with incomplete state. |

---

## Recovery Journal (`[journal]`)

Retention and durability of the Transaction Replay recovery journal — the
committed-transaction log behind `POST /api/replay`. Journaling itself follows the
top-level `tr_enabled`; this section only bounds what is kept and where. Every key
defaults to the value it had as a compiled-in constant, and an absent `[journal]`
block keeps the journal in process memory only. `validate()` rejects `0` for every
cap (a `0` would disable a safety bound), an unknown `fsync` value, and
`fsync_interval_ms = 0` under `fsync = "interval"`.

```toml
[journal]
max_active_transactions = 50000
max_entries_per_transaction = 10000
max_bytes_per_transaction = 67108864
max_committed_transactions = 50000
max_committed_bytes = 268435456
max_statement_bytes = 1048576
dir = ""
segment_bytes = 67108864
retain_bytes = 1073741824
fsync = "interval"
fsync_interval_ms = 100
writer_queue = 4096
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `max_active_transactions` | usize | `50000` | Cap on **open** (uncommitted) transaction journals across all sessions; the oldest are evicted at the cap. Only bites when many sessions hold long transactions. |
| `max_entries_per_transaction` | usize | `10000` | Statements journaled per transaction. Beyond it the transaction is kept but marked *incomplete* (committed-history replay refuses it rather than apply it partially). |
| `max_bytes_per_transaction` | usize | `67108864` | Bytes (statement text + parameters) journaled per transaction (64 MiB). Same incomplete marking. |
| `max_committed_transactions` | usize | `50000` | **Committed** transactions retained in memory for replay, oldest evicted first. Also the number reloaded from `dir` at startup. |
| `max_committed_bytes` | usize | `268435456` | Bytes of committed transactions retained in memory (256 MiB). |
| `max_statement_bytes` | usize | `1048576` | A single statement longer than this (1 MiB) is journaled without its text and marks its transaction incomplete — bounds per-statement memory on the capture path. |
| `dir` | string | `""` | Directory of the durable segment store. Empty = memory only: **nothing survives a restart**. When set, every committed transaction is appended to `journal-<first-commit-seq>.log` segments there and the newest `max_committed_transactions` are reloaded at startup with commit numbering continuing after the highest recovered sequence. An unwritable directory fails startup. The segments hold statement text and bound parameter values of committed writes, unencrypted: protect the directory like the database's own data files. Records are CRC-checked and postcard-encoded (record format `HJ02`); a directory in the older pre-release format `HJ01`, which no released version wrote, is refused at startup with an explicit error rather than truncated. |
| `segment_bytes` | u64 | `67108864` | Bytes after which the current segment is closed and a new one started (64 MiB). |
| `retain_bytes` | u64 | `1073741824` | Total on-disk bytes to keep; the oldest whole segments beyond it are deleted (1 GiB). |
| `fsync` | string | `"interval"` | When appended records reach stable storage: `"commit"` = `fdatasync` after every record, `"interval"` = group `fdatasync` every `fsync_interval_ms`, `"none"` = leave it to the OS page cache. The data path never waits on disk under any policy (see `writer_queue`). |
| `fsync_interval_ms` | u64 | `100` | Group-`fsync` period for `fsync = "interval"`. |
| `writer_queue` | usize | `4096` | Bounded queue between the data path and the segment writer thread. A full queue never stalls a client: the record is dropped and counted (reported as `coverage.dropped_transactions` on replay responses). |

What the journal records is described in [transaction-replay.md](/docs/proxy/transaction-replay/#recovery-journal-and-committed-history-replay).

---

## Query Analytics (`[analytics]`)

Fingerprinting, per-query stats, slow-query log, pattern detection. Only active with the
`query-analytics` feature and `enabled = true`.

```toml
[analytics]
enabled = true
slow_query_ms = 1000
max_fingerprints = 10000
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | `false` | Record per-query statistics and slow-query log. |
| `slow_query_ms` | u64 | `1000` | Queries slower than this are added to the slow-query log. |
| `max_fingerprints` | u32 | `10000` | Maximum distinct query fingerprints to track. |

---

## Anomaly Detection (`[anomaly]`)

Tunables for the in-process anomaly detector (SQL-injection patterns, failed-auth
bursts, per-tenant rate spikes, novel-query shapes). The section is parsed on
every build for config round-tripping, but the detector is only active with the
`anomaly-detection` feature. The defaults reproduce the prior hardcoded behavior
exactly, so an absent `[anomaly]` block changes nothing.

The detector is built **once at startup**, so changing `[anomaly]` requires a
restart — a SIGHUP config reload does not rebuild it.

```toml
[anomaly]
rate_window_secs = 60
spike_z_threshold = 3.0
auth_window_secs = 60
auth_critical_count = 10
auth_warning_count = 5
event_buffer_size = 1024
emit_novel_queries = true
max_seen_fingerprints = 100000
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `rate_window_secs` | u64 | `60` | Rolling window for the per-tenant rate EWMA, seconds. Must be `>= 1`. |
| `spike_z_threshold` | f64 | `3.0` | Minimum z-score before a rate spike fires. Must be finite and `> 0`. |
| `auth_window_secs` | u64 | `60` | Window for failed-auth (credential-stuffing) bursts, seconds. Must be `>= 1`. |
| `auth_critical_count` | u32 | `10` | Failures inside the auth window that escalate to Critical. Must be `>= 1`. |
| `auth_warning_count` | u32 | `5` | Failures inside the auth window that escalate to Warning. Must be `<= auth_critical_count`. |
| `event_buffer_size` | usize | `1024` | Maximum events kept in the in-memory ring buffer. Must be `>= 1`. |
| `emit_novel_queries` | bool | `true` | Emit first-seen query fingerprints as informational events; set `false` on high-churn workloads. |
| `max_seen_fingerprints` | usize | `100000` | Upper bound on the novel-query fingerprint set before it is cleared (bounds memory on high-cardinality SQL). Must be `>= 1`. |

---

## Query Rewriting (`[query_rewrite]`)

Rules-engine SQL rewriting. Only active with the `query-rewriting` feature and
`enabled = true`.

```toml
[query_rewrite]
enabled = true

[[query_rewrite.rules]]
match_table = "orders"
append_where = "deleted_at IS NULL"

[[query_rewrite.rules]]
match_regex = "^SELECT \\* FROM events"
add_limit = 1000
```

`[query_rewrite]` keys:

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | `false` | Apply the rewrite rules on the query path. |
| `rules` | array | `[]` | Ordered rewrite rules (`[[query_rewrite.rules]]`). |

Each `[[query_rewrite.rules]]` entry (first matching transformation is applied):

| Key | Type | Description |
|-----|------|-------------|
| `match_table` | string | Apply to queries referencing this table. |
| `match_regex` | string | Apply to queries matching this regex. |
| `replace_table_with` | string | Rewrite `match_table` → this table name. |
| `append_where` | string | Append `AND <expr>` to the WHERE clause. |
| `add_limit` | u32 | Add `LIMIT n` to an unbounded query. |

---

## Multi-Tenancy (`[multi_tenancy]`)

Per-tenant row isolation via injected predicates. Only active with the `multi-tenancy`
feature and `enabled = true`.

```toml
[multi_tenancy]
enabled = true
identify_by = "application_name"
tenant_column = "tenant_id"
tenant_tables = ["orders", "invoices"]
tenants = ["acme", "globex"]
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | `false` | Enforce per-tenant row isolation. |
| `identify_by` | string | `"application_name"` | Connection attribute naming the tenant: a startup parameter name (e.g. `application_name`, `user`) or the literal `database`. |
| `tenant_column` | string | `"tenant_id"` | The row-level tenant column injected into queries. |
| `tenant_tables` | array | `[]` | Tables that get the tenant filter injected; others pass through. |
| `tenants` | array | `[]` | Known tenant ids. |

---

## Schema Routing (`[schema_routing]`)

Route analytical (OLAP) queries to a dedicated node. Only active with the
`schema-routing` feature and `enabled = true`.

```toml
[schema_routing]
enabled = true
analytics_node = "analytics-1"
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | `false` | Route aggregations / GROUP BY / window-function queries to a node. |
| `analytics_node` | string | `""` | `name` of the node analytical queries route to. |

---

## Authentication (`[auth]`) and HBA Rules (`[[hba]]`)

Client-side authentication mode and pg_hba-style admission.

```toml
[auth]
mode = "scram"
auth_file = "/etc/heliosproxy/userlist.txt"

[[hba]]
action = "allow"
user = "all"
database = "all"
address = "10.0.0.0/8"

[[hba]]
action = "reject"
user = "all"
database = "all"
address = "all"        # trailing rule = default-deny
```

`[auth]`:

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `mode` | string | `"passthrough"` | `passthrough` relays client auth to the backend; `scram` makes the proxy terminate SCRAM-SHA-256 against `auth_file`. |
| `auth_file` | string | *(none)* | Path to a pgbouncer-style user list (`user:secret`, secret = plaintext or a `SCRAM-SHA-256$...` verifier). Required when `mode = "scram"`. |

`[[hba]]` rules are evaluated in order; the first rule whose `user`, `database`, and
`address` all match decides the outcome. If **no** rule matches, the connection is
admitted (add a trailing `reject … all/all/all` for default-deny):

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `action` | string | — | `allow` or `reject`. |
| `user` | string | `"all"` | Matching PostgreSQL user, or `all`. |
| `database` | string | `"all"` | Matching database, or `all`. |
| `address` | string | `"all"` | `all`, a bare IP, or a CIDR (e.g. `10.0.0.0/8`, `::1/128`). |

---

## WASM Plugins (`[plugins]`)

Plugin subsystem (a single `[plugins]` table, **not** an array of `[[plugins]]`). Only
consumed with the `wasm-plugins` feature; strictly opt-in.

```toml
[plugins]
enabled = true
plugin_dir = "/etc/heliosproxy/plugins"
hot_reload = false
memory_limit_mb = 64
timeout_ms = 100
max_plugins = 20
fuel_metering = true
fuel_limit = 1000000
kv_max_value_bytes = 65536
kv_max_keys_per_plugin = 1024
kv_max_plugins = 256
kv_max_total_bytes = 67108864
# trust_root = "/etc/heliosproxy/plugin-keys"
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | `false` | Enable the plugin subsystem. |
| `plugin_dir` | string | `"/etc/heliosproxy/plugins"` | Directory scanned for `.wasm` plugins at startup. |
| `hot_reload` | bool | `false` | Watch `plugin_dir` and reload plugins on change. |
| `memory_limit_mb` | usize | `64` | Memory limit per plugin instance. |
| `timeout_ms` | u64 | `100` | Execution timeout per hook call. |
| `max_plugins` | usize | `20` | Maximum concurrently-loaded plugins. |
| `fuel_metering` | bool | `true` | Enable per-call CPU-cycle (fuel) metering. |
| `fuel_limit` | u64 | `1000000` | Fuel units allowed per hook call when metering is on. |
| `kv_max_value_bytes` | usize | `65536` | Max bytes for a single plugin-KV key OR value (via `kv_set` or `PUT /admin/kv/<plugin>/<key>`); `0` = unlimited. A write past this cap is rejected (`kv_set` returns `-1`; the admin endpoint returns `413`). |
| `kv_max_keys_per_plugin` | usize | `1024` | Max distinct keys per plugin KV namespace; `0` = unlimited. Overwriting an existing key never trips the cap. |
| `kv_max_plugins` | usize | `256` | Max distinct plugin KV namespaces that may exist at once; `0` = unlimited. Bounds how many `<plugin>` namespaces `PUT /admin/kv/<plugin>/<key>` can create, so a token-holder cannot exhaust memory by writing to unboundedly-many namespace names. Writing to an already-present namespace never trips the cap; deleting a namespace's last key frees its slot. |
| `kv_max_total_bytes` | usize | `67108864` (64 MiB) | Max TOTAL retained bytes across ALL plugin KV namespaces (each entry's key + value bytes plus each live namespace's name bytes); `0` = unlimited. The single backstop that bounds the whole KV footprint regardless of the per-axis product `kv_max_plugins × kv_max_keys_per_plugin × kv_max_value_bytes` (which can otherwise reach tens of GiB), so a token-holding `PUT /admin/kv/<plugin>/<key>` caller cannot drive the proxy to an OOM. A write past this cap is rejected (`kv_set` returns `-1`; the admin endpoint returns `413`). |
| `trust_root` | string | *(none)* | Ed25519 trust-root directory. When set, every `.wasm` requires a sidecar `.sig` verifying against a `*.pub` in this directory; when omitted, signatures are not checked. |

---

## MCP Agent Gateway (`[mcp]`) and Agent Contracts (`[[agent_contracts]]`)

Native MCP server exposing `query` / `list_tables` / `explain` tools. Disabled by
default.

```toml
[mcp]
enabled = true
listen_address = "127.0.0.1:9092"
backend_host = "127.0.0.1"
backend_port = 5432
backend_user = "postgres"
# backend_password = "..."
# backend_database = "app"
read_only = true
# contract = "reporting-agent"
auth_token = "${MCP_TOKEN}"
query_timeout_ms = 30000
max_concurrent_requests = 64

[[agent_contracts]]
id = "reporting-agent"
read_only = true
allowed_verbs = ["SELECT"]
allowed_tables = ["orders", "invoices"]
require_limit = true
max_rows = 1000
```

`[mcp]`:

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | `false` | Serve the MCP JSON-RPC endpoint. |
| `listen_address` | string | `"127.0.0.1:9092"` | HTTP listen address for MCP. |
| `backend_host` | string | `"127.0.0.1"` | Backend the tool SQL runs against. |
| `backend_port` | u16 | `5432` | Backend port. |
| `backend_user` | string | `"postgres"` | Backend user. |
| `backend_password` | string | *(none)* | Backend password. |
| `backend_database` | string | *(none)* | Backend database. |
| `read_only` | bool | `true` | Refuse write/DDL — agents get a read-only surface. |
| `contract` | string | *(none)* | Name of an `[[agent_contracts]]` entry to enforce on every tool call. |
| `auth_token` | string | *(none)* | Bearer token required on every MCP request. Absent = open, so **set this for any non-loopback deployment** — MCP exposes SQL and must not be anonymous off localhost. |
| `query_timeout_ms` | u64 | `30000` | Per-`tools/call` backend execution timeout, milliseconds. Was a hardcoded 30 s. Must be > 0. |
| `max_concurrent_requests` | usize | `64` | Maximum `tools/call` requests admitted concurrently; beyond this the gateway answers a JSON-RPC error (code `-32000`) instead of queuing indefinitely. `initialize`/`ping`/`tools/list` and notifications are never gated. Must be > 0. |

Each `[[agent_contracts]]` entry (scoped grants, referenced by `id` from `[mcp] contract`):

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `id` | string | — | Identifier matched against the agent. |
| `read_only` | bool | `true` | Reject write/DDL statements. |
| `allowed_verbs` | array | *(none)* | If set, only these SQL verbs are allowed (upper-case). |
| `allowed_tables` | array | *(none)* | If set, only these tables may be referenced. |
| `denied_tables` | array | `[]` | Tables that may never be referenced (takes precedence over allow). |
| `require_predicate_on` | array | `[]` | Predicates that must be present when a named table is touched. |
| `require_limit` | bool | `false` | Require a LIMIT on SELECTs. |
| `max_rows` | u64 | *(none)* | Suggested/enforced row cap. |

---

## HTTP SQL Gateway (`[http_gateway]`)

Neon-serverless-driver-compatible `POST /sql` endpoint. Disabled by default.

```toml
[http_gateway]
enabled = true
listen_address = "127.0.0.1:9093"
backend_host = "127.0.0.1"
backend_port = 5432
backend_user = "postgres"
# backend_password = "..."
# backend_database = "app"
auth_token = "${HTTP_GW_TOKEN}"
query_timeout_ms = 30000
max_concurrent_requests = 64
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | `false` | Serve the HTTP SQL gateway. |
| `listen_address` | string | `"127.0.0.1:9093"` | HTTP listen address. |
| `backend_host` | string | `"127.0.0.1"` | Backend host. |
| `backend_port` | u16 | `5432` | Backend port. |
| `backend_user` | string | `"postgres"` | Backend user. |
| `backend_password` | string | *(none)* | Backend password. |
| `backend_database` | string | *(none)* | Backend database. |
| `auth_token` | string | *(none)* | Optional Bearer token required on requests. |
| `query_timeout_ms` | u64 | `30000` | Per-request backend execution timeout, milliseconds. Was a hardcoded 30 s. Must be > 0. |
| `max_concurrent_requests` | usize | `64` | Maximum `/sql` requests admitted concurrently; beyond this the gateway answers `503 Service Unavailable` with a `Retry-After` header instead of queuing indefinitely (`/health` is never gated). Must be > 0. |

---

## GraphQL Gateway (`[graphql_gateway]`)

GraphQL-to-SQL gateway on a separate HTTP listener. Only active with the
`graphql-gateway` feature and `enabled = true`.

```toml
[graphql_gateway]
enabled = true
listen_address = "0.0.0.0:9091"
backend_host = "127.0.0.1"
backend_port = 5432
backend_user = "postgres"
# backend_password = "..."
# backend_database = "app"
# auth_token = "..."
query_timeout_ms = 30000
max_concurrent_requests = 64

[[graphql_gateway.tables]]
name = "orders"
columns = ["id", "customer_id", "total", "created_at"]
```

`[graphql_gateway]`:

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | `false` | Serve the GraphQL gateway. |
| `listen_address` | string | `"0.0.0.0:9091"` | HTTP listen address. |
| `backend_host` | string | `"127.0.0.1"` | Backend host. |
| `backend_port` | u16 | `5432` | Backend port. |
| `backend_user` | string | `"postgres"` | Backend user. |
| `backend_password` | string | *(none)* | Backend password. |
| `backend_database` | string | *(none)* | Backend database. |
| `auth_token` | string | *(none)* | Optional Bearer token required on requests. |
| `tables` | array | `[]` | Tables exposed as GraphQL types (`[[graphql_gateway.tables]]`). |
| `query_timeout_ms` | u64 | `30000` | Per-request backend execution timeout, milliseconds. Was a hardcoded 30 s. Must be > 0. |
| `max_concurrent_requests` | usize | `64` | Maximum GraphQL requests admitted concurrently; beyond this the gateway answers `503 Service Unavailable` with a `Retry-After` header instead of queuing indefinitely (`/health` is never gated). Must be > 0. |

Each `[[graphql_gateway.tables]]`: `name` (string) and `columns` (array of strings).

---

## Traffic Mirror (`[mirror]`)

Continuously mirror a sampled share of live (simple-query) writes to a secondary
backend, off the client hot path. Disabled by default; the on-ramp to a PG→Nano
migration mirror.

```toml
[mirror]
enabled = true
sample_rate = 1.0
writes_only = true
queue_size = 10000
backend_host = "127.0.0.1"
backend_port = 5432
backend_user = "postgres"
# backend_password = "..."
# backend_database = "app"
# source_host / source_port / source_user / source_password / source_database
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | `false` | Mirror eligible statements to the secondary. |
| `sample_rate` | f64 | `1.0` | Fraction of eligible statements to mirror (`0.0`–`1.0`). |
| `writes_only` | bool | `true` | Mirror only write/DDL statements; `false` mirrors all simple queries. |
| `queue_size` | usize | `10000` | Bounded queue depth; when full, statements are dropped (and counted) rather than blocking. |
| `backend_host` | string | `"127.0.0.1"` | Mirror-target host. |
| `backend_port` | u16 | `5432` | Mirror-target port. |
| `backend_user` | string | `"postgres"` | Mirror-target user. |
| `backend_password` | string | *(none)* | Mirror-target password. |
| `backend_database` | string | *(none)* | Mirror-target database. |
| `source_host` / `source_port` / `source_user` / `source_password` / `source_database` | — | localhost / `5432` / `postgres` / none / none | Source (primary) connection used by `POST /api/migration/snapshot` to bootstrap the secondary. |

---

## Edge / Geo Proxy (`[edge]`)

Two-region result caching. A `home`-role proxy is authoritative (routes writes, caches
reads, broadcasts SSE invalidations); an `edge`-role proxy serves reads from a local
cache and forwards misses/writes to the home. Disabled by default. Parsed on every
build, but `enabled = true` requires the `edge-proxy` compile-time feature (validation
rejects it otherwise).

```toml
[edge]
enabled = true
role = "edge"                       # "home" (default) or "edge"
home_url = "https://home-proxy:9090"  # edge: home admin base URL
auth_token = "${EDGE_HOME_TOKEN}"     # edge: home admin bearer
allow_insecure_home_url = false
default_ttl_secs = 60
max_entries = 10000
max_total_bytes = 268435456
max_edges = 32
liveness_window_secs = 120
subscribe_gc_secs = 30
region = "eu-west"
edge_id = "edge-a"
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | `false` | Master switch; requires the `edge-proxy` feature to enable. |
| `role` | string | `"home"` | `home` (authoritative) or `edge` (cache-first). |
| `home_url` | string | `""` | *(edge)* Home proxy admin base URL the edge subscribes to. Required for `role = "edge"`. |
| `auth_token` | string | `""` | *(edge)* Home admin bearer for the invalidation subscription. When set, `home_url` must be `https://` unless `allow_insecure_home_url = true`. |
| `allow_insecure_home_url` | bool | `false` | *(edge)* Allow presenting `auth_token` to a plain-http `home_url` (private links only). |
| `default_ttl_secs` | u64 | `60` | Default TTL for cache entries when the home supplies none. Must be ≥ 1 when edge is enabled. |
| `max_entries` | usize | `10000` | Cache entries before LRU eviction. |
| `max_total_bytes` | usize | `268435456` | Aggregate retained-payload budget: response bodies plus per-entry overhead. On insert the cache evicts LRU entries until the retained payload fits. `0` disables the budget (entry count x per-entry ceiling then bounds memory). |
| `max_edges` | usize | `32` | *(home)* Maximum simultaneously-registered edges. |
| `liveness_window_secs` | u64 | `120` | *(home)* Edges not seen within this window are GC-pruned. Keep comfortably above ~45s. Must be ≥ 1 when edge is enabled. |
| `subscribe_gc_secs` | u64 | `30` | *(home)* Registry GC sweep cadence. Must be ≥ 1 when edge is enabled. |
| `region` | string | `""` | *(edge)* Region label reported when subscribing. |
| `edge_id` | string | `""` | *(edge)* Stable registration id (empty → `edge-<pid>`). |

An `edge`-role proxy also requires at least one `[[nodes]]` entry pointing at the home's
PG-wire listener (its data plane), and cannot be combined with `[cache] enabled = true`
(the query-result cache does not receive edge invalidations).

---

## Instant Branch Databases (`[branch]`)

Provision `CREATE DATABASE <branch> TEMPLATE <base>` clones through the proxy. Disabled
by default.

```toml
[branch]
enabled = true
backend_host = "127.0.0.1"
backend_port = 5432
admin_user = "postgres"          # a role with CREATEDB
admin_password = "${PGPASSWORD}"
admin_database = "postgres"      # maintenance DB for CREATE/DROP DATABASE
base_database = "postgres"       # default template when a request omits `base`
```

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | `false` | Enable branch-database provisioning. |
| `backend_host` | string | `"127.0.0.1"` | Backend host. |
| `backend_port` | u16 | `5432` | Backend port. |
| `admin_user` | string | `"postgres"` | Role with CREATEDB privilege. |
| `admin_password` | string | *(none)* | Password for `admin_user`. |
| `admin_database` | string | `"postgres"` | Maintenance database to issue CREATE/DROP DATABASE against. |
| `base_database` | string | `"postgres"` | Default template database when a request omits `base`. |

---

## Configuration Validation

At startup the proxy validates the loaded config and refuses to start if:

1. No backend nodes are configured.
2. No node has `role = "primary"`.
3. `pool.max_connections < pool.min_connections`.
4. `health.check_interval_secs = 0`.
5. `admin_address` is non-loopback but `admin_token` is unset and
   `admin_allow_insecure = false` (see [Admin API Security](#admin-api-security)).
6. `edge.enabled = true` without the `edge-proxy` feature, or an `edge` role missing its
   `home_url`, or its zero-value timing knobs, or combined with `[cache]`.

Invalid configurations produce a descriptive error and a non-zero exit code. Unknown
top-level keys are **warned**, not rejected (see
[Unknown Keys](#unknown-keys-are-warned-not-rejected-by-default)).

---

## Complete Example

Ready-to-use examples live in `config/proxy.example.toml`, `config/proxy.full.toml`,
`config/proxy.postgres.toml`, and the working `scripts/regress/*.toml` files.

```toml
# HeliosProxy configuration example.

listen_address = "0.0.0.0:6432"
admin_address  = "127.0.0.1:9090"
# admin_token  = "${ADMIN_TOKEN}"
tr_enabled     = true
tr_mode        = "session"
write_timeout_secs = 30

[pool_mode]
mode = "transaction"
max_pool_size = 100
prepared_statement_mode = "track"
skip_clean_reset = true

[pool]
min_connections = 5
max_connections = 100
idle_timeout_secs = 300
max_lifetime_secs = 1800
acquire_timeout_secs = 30
test_on_acquire = true

[load_balancer]
read_strategy = "least_connections"
read_write_split = true
latency_threshold_ms = 50

[health]
check_interval_secs = 5
check_timeout_secs = 3
failure_threshold = 3
success_threshold = 2
check_query = "SELECT 1"

[[nodes]]
host = "db-primary.internal"
port = 5432
role = "primary"
weight = 100
enabled = true
name = "primary"

[[nodes]]
host = "db-standby-1.internal"
port = 5432
role = "standby"
weight = 100
enabled = true
name = "standby-1"

[tls]
enabled = false
cert_path = "/etc/heliosproxy/server.crt"
key_path = "/etc/heliosproxy/server.key"
require_client_cert = false
```

---

## See Also

- [Architecture](/docs/proxy/architecture/)
- [Feature Flags](/docs/proxy/feature-flags/)
- [Admin API Reference](/docs/proxy/admin-api/)

---

## Transaction Replay: the re-executable read subset

`tr_mode = select` and `transaction` re-execute an interrupted statement on the
replacement backend only when its outcome on the failed backend cannot have had a
side effect. The proxy decides lexically, without a catalog round trip:

- Statement shape: `SELECT`, `VALUES`, `TABLE`, `SHOW`, `EXPLAIN` (without
  `ANALYZE`), `COPY … TO`, or a `WITH` whose CTEs contain no `INSERT`/`UPDATE`/
  `DELETE`/`MERGE`.
- No `INTO` (creates a table).
- Every syntactic function call — an identifier followed by `(` outside strings,
  quoted identifiers, dollar quotes and comments — must be a PostgreSQL built-in
  from the proxy's side-effect-free list (aggregates, string/math/date/JSON/array
  functions, `pg_sleep`, `random`, `now`, `current_setting`, size/introspection
  helpers) or listed in `tr_read_functions`. Schema qualification is ignored
  (`pg_catalog.lower(...)` is `lower`).
- A call through a quoted identifier (`"MyFn"(...)`) is never eligible: its
  case is opaque to the policy.

Everything else — user-defined functions, `nextval`/`setval`/`currval`/`lastval`,
`pg_notify`, `set_config`, `pg_advisory_*`, `txid_current`, `setseed`, large-object
and replication functions — makes the statement opaque: on an unknown outcome the
client receives SQLSTATE `08007` and the proxy does not run it again. Inside an
explicit transaction an opaque statement also marks the transaction as containing
a possible write, so `select` mode will not replay it. Nondeterministic but
side-effect-free calls are allowed because an unknown-outcome autocommit read
published nothing to the client before the fault.

### Session state across a failover

`SET`/`RESET` issued through the simple query protocol are tracked per session and
replayed, in order, onto the replacement backend when the session re-homes. The
tracking is transactional, mirroring PostgreSQL: a `SET` inside an explicit
transaction takes effect for restore only when that transaction commits; `ROLLBACK`
discards it; `ROLLBACK TO SAVEPOINT` discards the `SET`s made after the savepoint
(`RELEASE` keeps them); `RESET name`, `RESET ALL` and `DISCARD ALL` inside a
transaction are likewise deferred until commit. `SET LOCAL`, `SET TRANSACTION` and
`SET CONSTRAINTS` are transaction-scoped and never tracked. Variables are identified
by name (`SET TIME ZONE` is `timezone`, `SET SCHEMA` is `search_path`, `SET NAMES`
is `client_encoding`), so repeated `SET`s of one variable cost one slot. Extended-
protocol `SET`s, SQL-level `PREPARE`, temporary tables and cursors are not restored.
(Extended-protocol *named prepared statements* are re-prepared lazily on the
replacement backend — a separate mechanism from this restore set.)

### Replay verification and the recovery deadline

While a transaction is recorded for `tr_mode = transaction` replay, the proxy hashes
each statement's response frames (`RowDescription`, `DataRow`, `CommandComplete`,
`EmptyQueryResponse`) up to `tr_max_observation_bytes`. When the transaction is
replayed on the replacement backend, each response is hashed the same way; if any
digest differs — the data the client already saw is not what the new backend would
have returned — the replay is rolled back and the client receives `40001` for the
interrupted statement. Transactions opened at `SERIALIZABLE` or `REPEATABLE READ`
(explicitly, or through `default_transaction_isolation`) are never replayed: a
replay cannot reproduce the old snapshot.

One deadline, `write_timeout_secs`, bounds the whole recovery: waiting for a
healthy primary, connecting and authenticating, restoring session `SET`s, replaying
the transaction and re-executing the interrupted statement.
