Our engine never sees your warehouse password
datahashi

Query Runner protocol

HTTP contract between the datahashi engine and Query Runner.

Updated

Query Runner runs parameterized SQL for the engine. It also streams rows for rollup builds and reads database schemas.

Query Runner never compiles queries. It never makes catalog or grant decisions. It runs only the SQL that its access policy returns, never the original request text.

sequenceDiagram
    participant E as datahashi engine
    participant R as Query Runner
    E->>R: POST /v1/sql { request_id, tenant_id, sql, args, source }<br/>Authorization: Bearer runner-key
    Note over R: 1. Check key hash and body size<br/>2. Check physical policy<br/>3. Execute on warehouse
    R-->>E: HTTP 200<br/>JSON or NDJSON

Authentication

Every /v1/* request requires a Bearer token:

Authorization: Bearer <unhashed-runner-key>

The runner hashes the token with SHA-256. It matches the hash against keys[].sha256. A missing or unknown key is 401 with "error": "unauthorized".

/healthz needs no key.

1. Run SQL (POST /v1/sql)

Runs one parameterized statement against a named warehouse.

Request schema

{
  "request_id": "req_01J",
  "tenant_id": "org_01J",
  "source": "main_warehouse",
  "sql": "SELECT region, SUM(amount) AS revenue FROM public.orders WHERE status = $1 GROUP BY region ORDER BY region LIMIT $2",
  "args": ["completed", 100],
  "timeout_ms": 30000,
  "max_rows": 10000,
  "stream": false
}
Parameter Required Description
request_id Yes Chosen by the engine. Echoed in the response and the audit line.
tenant_id Yes Written to the log only. The runner does not authorize tenants.
sql Yes One statement. The policy may rewrite it. Only the rewritten SQL runs.
source When the runner has more than one warehouse Must be a warehouse this key may use.
args No JSON null, number, string, or bool. Bound in order. PostgreSQL uses $1, $2.
timeout_ms No Capped by policy.statement_timeout. The smaller value wins.
max_rows No Capped by policy.max_rows. The smaller value wins.
stream No true answers as NDJSON.

Unknown request fields are ignored.

Buffered response (stream: false)

{
  "request_id": "req_01J",
  "columns": [
    { "name": "region", "type": "text" },
    { "name": "revenue", "type": "numeric" }
  ],
  "rows": [
    ["na", "45200.00"],
    ["emea", "38100.00"]
  ],
  "row_count": 2,
  "truncated": false,
  "duration_ms": 14
}

columns is a list of {name, type} objects. There is no parallel types array. Timestamps are RFC3339 strings. Decimals are strings. Bytes are base64.

truncated is true when max_rows cut the cursor. Do not treat those rows as the full answer.

Streaming response (stream: true)

Rollup builds set stream to true. The runner writes rows as it reads them. Caps are max_stream_rows and stream_timeout. A request max_rows or timeout_ms can only tighten those caps.

The content type is application/x-ndjson. The first line is the header. Each middle line is one row. The last line is the trailer.

{"request_id":"req_01J","columns":[{"name":"region","type":"text"},{"name":"revenue","type":"numeric"}]}
["na","45200.00"]
["emea","38100.00"]
{"row_count":2,"truncated":false,"duration_ms":38}

A warehouse error before the header is a normal error response. After the header, the body ends with {"request_id","error","message"}. A body with no trailer and no error line is not a complete answer.

2. Read schema (POST /v1/schema)

Reads tables and columns. The key’s policy hides denied tables and columns. They are omitted, not listed and blocked.

Request schema

{
  "request_id": "req_01J",
  "tenant_id": "org_01J",
  "source": "main_warehouse",
  "schemas": ["public"]
}

schemas defaults to the warehouse’s configured list.

Response schema

{
  "request_id": "req_01J",
  "tables": [
    { "schema": "public", "name": "orders" }
  ],
  "columns": [
    { "schema": "public", "table": "orders", "name": "id", "type": "bigint", "nullable": false },
    { "schema": "public", "table": "orders", "name": "amount", "type": "numeric", "nullable": false }
  ]
}

Tables do not nest their columns. The response has no primary_key field. An engine with no introspection query returns 501 and "error": "unsupported_backend".

3. Health and ping

Method Path Key Result
GET /healthz No Process is up. Does not touch the warehouse.
GET /v1/readyz?source=<name> Yes Warehouse ping. Same pause gate as /v1/sql.
POST /v1/ping Yes Same check as readyz. The body keeps the key out of the URL.

GET /healthz returns {"status":"ok","enabled":true}. A paused runner still returns 200. There is no GET /v1/ping.

source is required when the runner has more than one warehouse. POST /v1/ping body fields are request_id, tenant_id, and optional source.

Condition Status error
Warehouse answers 200 none. Body is {"status":"ok"}.
Paused 503 runner_paused
Bad key 401 unauthorized
Warehouse down 503 warehouse_unreachable

Local admin listener

The admin port defaults to 127.0.0.1:9090. It has no key check. Bind it to loopback or a private network. The engine never calls these routes.

Endpoint Method Function
/admin/v1/pause POST Set the kill switch in memory until resume, reload, or restart.
/admin/v1/resume POST Clear the in-memory pause.
/admin/v1/status GET {enabled, sources, keys, in_flight}. No secrets.
/admin/v1/logs?n=100 GET Last n audit records.
/admin/v1/reload POST Reload files and swap the snapshot. A bad file keeps the previous snapshot. Reload clears an in-memory pause.

listen, admin_listen, TLS, and the log path need a process restart. Warehouses, keys, policies, and allow_cidrs apply on reload. Pools for warehouses that left the snapshot are dropped.

Error codes

Errors are one flat object. error is a string token, not an object.

{
  "request_id": "req_01J",
  "error": "policy_denied",
  "message": "access to table 'public.employees' denied by runner policy"
}

policy_denied also adds grain, object, and rule.

HTTP Status error Cause
400 Bad Request bad_request Missing request_id, tenant_id, or sql, or invalid JSON
401 Unauthorized unauthorized Missing or unknown runner key
403 Forbidden forbidden Caller IP is outside allow_cidrs. /healthz is exempt.
403 Forbidden forbidden_statement Not a single SELECT or WITH
403 Forbidden policy_denied A table, field, or value rule rejected the statement
403 Forbidden unpoliced_backend_denied Non-PostgreSQL warehouse and the policy lacks allow_unpoliced_backends: true
404 Not Found unknown_source source is not a warehouse, or it is not in the key’s sources. The two look the same.
413 Payload Too Large payload_too_large Body exceeds max_request_bytes (default 1 MiB)
429 Too Many Requests busy policy.concurrency is full
500 Server Error internal Anything else. The message never contains the DSN.
501 Not Implemented unsupported_backend /v1/schema on an engine with no introspection. /v1/sql is unaffected.
503 Service Unavailable runner_paused Kill switch is on
503 Service Unavailable warehouse_unreachable Driver open or ping failed, or a non-timeout driver error. A SQL syntax error looks the same.
504 Gateway Timeout timeout Statement timeout or context deadline

The engine remaps some of these for its own callers. policy_denied becomes 403 runner_policy_denied. runner_paused and warehouse_unreachable become 503 runner_unavailable with Retry-After: 30. unauthorized, unknown_source, and forbidden become 502 runner_misconfigured.

Next: REST API · Environment variables.