All docs
Docs · Data & state
Outlets
A stack can read and write the database its owner already runs — the CRM, the ticket table, the orders schema — through an outlet: a named connection the stack declares once, with the credential held by the chassis. The op writes its own SQL with every value bound as a parameter; the chassis owns the pool, bounds rows, bytes and time, and hands the op JSON. The stack never sees a DSN, a password or a socket.
An inlet lets an external system speak into a stack; an outlet lets a stack speak out to one. PostgreSQL is the first driver.
Authoring
One file per outlet under the reserved OUTLETS/ subtree:
OPS/<stack>/
OUTLETS/
crm.yaml where to connect, and the ceilings driver: postgres
secret: CRM_DSN # the NAME of a tenant secret; its value is a postgres:// DSN
access: read # read (default) refuses outlet://crm/exec; write allows it
max_rows: 500 # tightens the node ceiling, never raises it
timeout: 5000 # ms; caps every call on this outlet
egress: relay # direct | relay; omitted = the node's --outlet-egress The declaration is code: it deploys with txco apply beside the ops that
use it. The credential is not — set it once, as a secret, scoped to the
stack or to the whole tenant:
txco auth tenant secrets set --stack support CRM_DSN # prompts for the value Several stacks may declare secret: CRM_DSN and share the tenant’s
credential; give one of them a stack-scoped CRM_DSN with a narrower
database role and it silently uses that instead. Nothing in the ops
changes.
Calling
WHEN @web.req.url.query.email.0 != ""
EXEC "outlet://crm/query"
WITH sql = "SELECT id, name, plan FROM customers WHERE email = $1 LIMIT 10",
args = &array(@web.req.url.query.email.0),
into = "_crm" query runs the statement in a READ ONLY transaction; exec (on an
outlet declared access: write) may change rows and runs in an explicit
transaction. Both take sql, args (positional binds for $1, $2 …), into (default _outlet, private to the flow) and timeout.
A read lands at into:
{
"_crm": {
"ok": true,
"outlet": "crm",
"columns": [
"id",
"name",
"plan"
],
"rows": [
{
"id": 123,
"name": "Alice",
"plan": "team"
}
],
"count": 1
}
} columns gives SELECT order, which object keys don’t. A write returns {"ok": true, "outlet": "crm", "rows_affected": 1}, plus columns, rows and count when the statement has a RETURNING clause.
Every failure is data at the same path, and the op still merges:
{
"_crm": {
"ok": false,
"outlet": "crm",
"error": {
"code": "txco_outlet_timeout",
"message": "outlet operation timed out"
}
}
} Gate the next op on ._crm.ok == true. A missing path compares as false,
so gate an error responder on ._crm.error.code != "" rather than on ok == false, or it fires on requests where the lookup never ran.
| code | meaning |
|---|---|
txco_outlet_not_declared | the outlet isn’t in the stack’s declaration |
txco_outlet_missing_secret | the named secret isn’t set for this stack or tenant |
txco_outlet_invalid_request | an argument the driver can’t bind, a bad into, or exec on a read outlet |
txco_outlet_result_too_large | the row or byte ceiling was crossed; no rows are returned, and an exec rolled back |
txco_outlet_outcome_unknown | the connection was lost while a write was committing; verify before retrying |
txco_outlet_connect_failed | DNS, TLS, refused, or blocked by the egress policy |
txco_outlet_auth_failed | the database rejected the credentials |
txco_outlet_timeout | deadline passed, pool wait included |
txco_outlet_constraint | constraint violation (SQLSTATE class 23) |
txco_outlet_query_failed | any other database error; sqlstate included |
txco_outlet_unavailable | shutdown, failover, too many connections |
Messages are fixed strings; the database’s own error text, which names hosts and users, never reaches the envelope.
Polling
outlet://<name>/query may LOOP:
every failure is data, each pass pays the dispatch fuel plus the rows it
returns, and the whole loop runs under the op’s deadline.
EXEC "outlet://crm/query"
WITH sql = "SELECT id FROM exports WHERE job = $1 AND done = true",
args = &array(.job), into = "_export"
LOOP EVERY "2s" UNTIL ._export.count == 1 MAX 30 A loop merges every pass, and the merge appends arrays: rows from every
pass accumulate (empty polls add nothing), and so do the entries of columns. Read ok and count — scalars, so the last pass wins — and
treat rows as everything the poll saw.
outlet://<name>/exec is not admitted to LOOP: a pass ending in txco_outlet_outcome_unknown must not be followed by another attempt at
the same write. Repeat a write with an explicit next op.
Arguments and types
args accepts null, booleans, strings, numbers, and a homogeneous array
of those — so WHERE id = ANY($1) takes a list of any length. An integral
number binds as an integer; nulls are allowed anywhere in an array.
Results map deterministically, with no silent precision loss: int8 beyond
±2^53 and numeric arrive as strings, json/jsonb as the JSON value, bytea as base64, dates and timestamps as strings (2006-01-02, RFC 3339
in UTC for timestamptz), arrays as arrays, uuid/inet as strings.
What apply does
Nothing connects at apply time: a database that is unreachable right now must never block a deploy. What apply checks it checks from source alone, and the chassis checks it again before activation:
- every
OUTLETS/<name>.yamlparses and names a driver built into the chassis; - every
EXEC "outlet://…"names an outlet its own stack declares; outlet://<name>/execis refused on an outlet declaredaccess: read;sqlis a string literal in the op — not a path, not&concat(...). Values reach the statement only throughargs, and the statement a stack runs against an external system is readable in its source;- one statement per call, and the leading verb matches the operation
(
SELECT/WITHforquery;INSERT/UPDATE/DELETEalso forexec; never DDL).
That last check is lint, not the boundary. txco lint runs the same checks
offline.
Runtime model
querycannot mutate. It runs inBEGIN READ ONLY, so the database refuses a write even insideWITH x AS (INSERT … RETURNING …) SELECT …, and the row stays unchanged.execeither committed or it didn’t. The statement runs in an explicit transaction: begin, execute, read the returned rows, check them against the ceilings, commit. A ceiling crossed by the returned rows rolls back, sook: falsemeans the chassis did not commit. The one case it can’t settle — a connection lost whileCOMMITis in flight — istxco_outlet_outcome_unknown, which the chassis never retries.- A ceiling is an error, never a prefix. A result over the row or byte
ceiling comes back with no rows at all, so nothing can be mistaken for a
complete answer. Put
LIMITin the SQL. - The database role is the boundary. The role behind the DSN decides what an outlet can reach; give it only the tables and verbs the stack needs.
- Every dial goes through the egress policy. The default
privatepolicy blocks loopback, private and link-local address space, so a DSN cannot point the chassis at internal services.txco devruns the policyopenso a local database works; a self-hostedtxco serveneeds--egress-allow-cidrsto reach a database on its LAN. - Pools are per node, and idle at zero. Nothing opens until an op
runs; a pool closes after
--outlet-idle-closeunused. A database sees roughly nodes ×--outlet-pool-max-connsconnections. Rotating the secret or redeploying the declaration switches to a new pool without a restart. The driver’s execution mode works behind transaction-mode poolers; prefer the provider’s pooler. - Egress: direct or relayed. By default a connection leaves from the
node that runs the op. With
egress: relay(or a node whose--outlet-egressisrelay) the node asks one of its configured relays on the fleet’s private network to make the connection, so it originates from the relay’s address — the one a customer allowlists. Only the dial changes: the node still resolves the host, checks the address against the egress policy, owns the pool and runs the statement, and TLS runs end to end through the relay (usesslmode=verify-fullso the certificate is checked against the DSN hostname). A relay is asked for an exact ip:port, never a hostname. A node with no relays configured answers such an outlet withtxco_outlet_connect_failed. When the relays live on a private network the host isn’t on,--outlet-egress-wg-confignames a standard WireGuard configuration (a peer config issued byfly wireguard create, or any client config) and the chassis runs the tunnel itself, in process, without a kernel interface or root. - The trace records the call, not the data. Outlet, driver, operation,
a fingerprint of the statement, duration, rows, bytes and the error code
— never the SQL text, the values, the rows or the DSN. It also says
whether the dial was
directorrelay.
Limits
| knob | default | what it bounds |
|---|---|---|
--outlet-max-rows | 500 | rows per result (a declaration’s max_rows clamps under it) |
--outlet-max-bytes | 1 MiB | bytes per result, measured while rows stream |
--outlet-pool-max-conns | 4 | connections per outlet per node (minimum is always 0) |
--outlet-pool-idle | 60s | how long an unused connection stays open |
--outlet-idle-close | 5m | how long an unused pool stays open |
--outlet-pg-exec-mode | describe-exec | pgx execution mode: describe-exec (a describe round trip per statement, never stale, safe behind any pooler) or cache-describe (one round trip; a schema change under a cached statement fails once) |
--outlet-egress | direct | what a declaration without egress gets: direct or relay |
--outlet-egress-relays | none | SOCKS5 relays (ip:port on the fleet’s private network, or on the tunnel) for relay, tried in order |
--outlet-egress-wg-config | none | a wg-quick file the chassis brings up in-process (userspace WireGuard, no root) to reach the relays by their tunnel addresses |
Calls run under the ordinary per-op timeout (WITH timeout and the
declaration’s timeout can only lower it). Rows returned are charged fuel
per MiB, like notebook reads.
A worked example lives at examples/outlet-postgres/: a lookup by email,
a probe that runs without a database (the missing secret comes back as
data), and a README for running it against a real one.