company_dns

V4 SQL endpoint: accepting SQL over HTTP, and which Rust crate to build on

Status: Phase 1 built (2026-10-05), under test. The endpoint, profiles (credentials and rules files), Basic Auth, per-dataset contexts, global limits, tests and a local test script exist (v4/crates/server/src/sql_endpoint.rs, v4/scripts/sql-try.sh); phase 2 (per-profile limits and the rest) is not started. Earlier status follows. Decided (2026-10-05). The section 7 decisions are made (HTTP only, per-dataset, vectors hidden, off by default behind a config flag, caps configurable). Added the same day: credentialed access only, strict for mediumroast.io, and the feature is declared experimental (section 5a). Section 5 is rewritten to match. Owner: michael.hay@mediumroast.io Scope: add a V4 endpoint that takes a SQL statement as input and returns rows from the data V4 already holds (the SIC-family feather tables and the EDGAR catalog). Preference stated up front: reuse an existing Rust crate rather than write our own. This document says what exists, what is actually reusable, and what is left for us to build whichever way we go.


1. What V4 has today

So the engine, the data and the HTTP layer exist. What is missing is a controlled way to let a caller’s SQL reach the engine.

2. What “an endpoint that takes SQL” could mean

Three different shapes. They need different crates and I would not conflate them:

Shape Caller Transport Typical use
A. HTTP JSON: POST /V4.0/sql with {"sql": "..."}, rows back as JSON (envelope) curl, notebooks, the web UI, anything HTTP/JSON ad-hoc exploration, scripts, the Explorer UI
B. Arrow Flight SQL (gRPC) ADBC / JDBC / ODBC Flight drivers, DuckDB/Polars/pandas via ADBC gRPC, Arrow batches bulk, typed, fast
C. Postgres wire protocol psql, DBeaver, Tableau, Metabase, any Postgres driver TCP :5432 style BI tools and SQL clients people already own

A fits “an endpoint” in the sense the rest of V4 uses the word (a route in the OpenAPI doc, behind the same rate limiter and envelope). B and C are separate listeners, not routes.

3. Candidate crates (researched 2026-10-05)

Versions and dates are from docs.rs / lib.rs on 2026-10-05; I did not build any of them.

3.1 datafusion-postgres (shape C)

3.2 arrow-flight with the flight-sql feature (shape B)

3.3 datafusion-dft (shapes A and B, as a product)

3.4 datafusion-server (shape A)

3.5 ROAPI (shape A)

3.6 DataFusion itself

4. Conclusion on reuse

There is no crate that gives us shape A as a library. The two HTTP-SQL projects are standalone servers on old DataFusion versions. But shape A does not need one: DataFusion already does the hard parts (parsing, planning, execution, the read-only guard). What we write is a thin axum handler of roughly 100 to 200 lines plus safety limits. That is the “use the existing module” answer for A: the module is DataFusion’s sql_with_options; we do not need another crate.

For B and C real crates exist and are worth using, but each is its own listener, its own auth story and its own deployment surface, and C is blocked on the DataFusion version mismatch today.

5. Design (shape A, HTTP/HTTPS only)

POST /V4.0/sql, body {"dataset": "sic" | "edgar", "sql": "SELECT ...", "limit": 1000}.

  1. Per-dataset, no cross-dataset joins. dataset is required and selects one query context:
    • sic: the US SIC table plus the other registered systems (Japan SIC, NACE, ISIC). They share one context today, so joins across systems work inside this dataset.
    • edgar: edgar_catalog. Each dataset gets its own dedicated read-only query context, separate from the one the existing endpoints use, so exposure rules cannot affect them.
  2. Vector columns hidden. The query contexts register views of the tables with every vector_* column removed (for example by dropping columns on the DataFrame before into_view; the exact call is a build-time check). The existing contexts keep the vectors for the similarity endpoints. A consequence: SQL callers cannot run array_distance; semantic search stays on the dedicated endpoints.
  3. Read-only guard: sql_with_options with DDL, DML and statements all off, one statement per request (reject ;-separated batches). URL/file tables stay disabled (enable_url_table is never called).
  4. Off by default behind a config flag. COMPANY_DNS_SQL_ENABLED (default false). When off, the route is not registered, so it returns 404 and does not appear in the OpenAPI document; when on, startup logs the effective limits. Matches how the other settings are read (COMPANY_DNS_DATA_DIR etc.).
  5. Configurable limits, defaults as agreed, env var names proposed: | Setting | Env var | Default | |—|—|—| | Default rows returned | COMPANY_DNS_SQL_DEFAULT_ROWS | 1,000 | | Hard row ceiling (per-request limit is clamped to it) | COMPANY_DNS_SQL_MAX_ROWS | 10,000 | | Statement timeout | COMPANY_DNS_SQL_TIMEOUT_SECS | 10 | | Query memory pool | COMPANY_DNS_SQL_MEMORY_MB | 256 | | Concurrent SQL requests | COMPANY_DNS_SQL_MAX_CONCURRENT | 4 (my addition; say if unwanted) | These are the server-wide defaults and ceilings (section 5a); a profile may be given lower values, or higher up to the ceilings. Invalid values fail startup with a clear message rather than silently falling back. Row cap is enforced server-side (stop reading the stream at the cap) and truncation is reported as a limitation entry, as the match endpoint does. Timeout wraps collection with tokio::time::timeout. The memory pool is set on the query context’s runtime. Max SQL text length comes from the existing request-body limit layer.
  6. Rate limiting, in two layers (decided 2026-10-06): SQL goes through the same limiter as the lookups (the User-Agent tiers and a profile’s rate_limit grant, the same buckets) but without the trusted-Origin bypass, and the profile’s own SQL limits apply on top. See section 5a.
  7. Response: V3 envelope with columns (name and Arrow type), rows, row_count, truncated, and a limitation naming the DataFusion SQL dialect/version.
  8. Tests: guard tests (INSERT, CREATE, COPY, SET, multi-statement, SELECT * FROM 'file' all rejected); vector columns absent from both datasets; row cap and clamp; timeout; flag off gives 404; env parsing (defaults, overrides, invalid).
  9. UI: none. Curl and the Swagger/Redoc pages are enough.

5a. Access control, limits, and experimental status

Principle: this endpoint can consume real CPU and memory, so unlike the lookup routes it is closed by default and credential-only. Nobody gets in because of who they appear to be; they get in because they hold a credential the operator issued.

It is the existing secret mechanism, generalised (correction)

An earlier draft of this section read as if it introduced a new secret store. It does not. V4 already has the pattern: a runtime secret (MEDIUMROAST_SHARED_SECRET) sourced from each environment’s own sealed K8s Secret, never baked into the image, never in the repo, distinct per tier (v4-deployment.md sections 2 and 3.4; v4-security-hardening.md 3.5). What is new is that more than one party needs credentials, each with its own limits and permissions. Decided 2026-10-05: this is a general access mechanism for V4, not an SQL-specific one. SQL is its first new consumer; mediumroast.io’s existing rate-limit trust is expressed in the same mechanism.

Today General mechanism
one secret, one identity (mediumroast.io), trust = skip the limiter one file with a section per profile: secret plus what that profile may do
env var from a sealed K8s Secret the same secret store, delivered as a mounted file (the credentials file)
a bypass only per-feature grants: rate-limit treatment, SQL access, and whatever comes later

mediumroast.io is one profile. Open-source users define their own.

Three access levels (decided 2026-10-05)

Access is a ladder, and each rung buys a better experience:

Level Who What they get
1. Anonymous no User-Agent, or one that fails the format check the draconian rate-limit tier (a nudge to identify yourself)
2. Self-identifying a valid User-Agent such as YourApp/1.0 (contact@example.com) the normal rate-limit tier. This existing mechanism stays: it is how a well-behaved caller gets a better experience without any credential.
3. Authenticated HTTP Basic Auth with a profile from the credentials file the profile’s grants: lower or no rate limits, and enhanced access such as SQL over REST

What the plan replaces is only the secret carried in the User-Agent (the rolling hourly HMAC, secret_ua.rs), because the credentials file with Basic Auth does that job better. The User-Agent format check and the normal/draconian tiering are unchanged. The profile’s rate_limit grant (phase 2) is what moves an authenticated caller up from level 2: a higher quota, or none, set per profile.

What the existing gates do and why SQL cannot reuse them

The data routes are gated by a self-identifying User-Agent, a tiered per-IP limiter, and two bypasses that skip the limiter (rate_limit.rs, trusted_origin.rs, secret_ua.rs):

For SQL: Origin/Referer are ignored entirely. The route is inside the same rate limiter as the lookups (so the User-Agent tiers and a profile’s rate_limit grant apply, sharing the same buckets) with the trusted-Origin bypass switched off for it, and the profile’s own SQL limits apply on top. An earlier build put the route outside the limiter to avoid the Origin bypass; that left SQL with no request-rate limit and no User-Agent tiering, and was corrected 2026-10-06.

Profiles: a credentials file and a rules file (decided 2026-10-06)

An earlier build kept everything for a profile (hash, grants, limits) in one mixed JSON file. Decided 2026-10-06 to split by what each file holds, so the secret file is tiny and the policy file is not secret and can be reviewed, versioned and shipped like any config:

  1. Credentials, like /etc/passwd: COMPANY_DNS_CREDENTIALS_FILE, one profile:sha256-of-token line per profile, # comments allowed (v4/credentials.example). The only secret file, and it holds only hashes.
    • Kubernetes: a SealedSecret mounted as a file, one per tier, so staging and dev never hold prod’s. Plain Docker/Compose/Swarm: a Docker secret (a file under /run/secrets/). Local development: a gitignored file. Never in the image or the repo.
  2. Rules: COMPANY_DNS_RULES_FILE, optional JSON with no secrets, so it can live in a ConfigMap or in git (v4/rules.example.json): a defaults section and per-profile overrides. A profile starts from the defaults; an override changes only the settings it names. Inheritance: objects merge key by key, a value or a list replaces, null removes an inherited setting, and bypass and a quota (requests_per_minute/burst) are alternatives, so naming one replaces an inherited other. A profile with no entry gets the defaults; an entry with no credential is a startup error.
    {"defaults": {"rate_limit": {"requests_per_minute": 300, "burst": 30}},
     "profiles": {
       "mediumroast.io": {"rate_limit": {"bypass": true},
                          "sql": {"datasets": ["sic", "edgar"], "limits": {"max_rows": 500}}},
       "partner":        {"rate_limit": {"requests_per_minute": 600}}}}
    

    (Fake values, shape only; real numbers are the operator’s.)

Order of precedence for any number: server-wide limits (env, protect the host) clamp the rules (defaults, then the profile’s overrides), which apply to each profile. Both files are read once at startup; a change means a restart, and a bad file stops the server.

Two sets of limits

  1. Server-wide limits: protect the pod and the cluster. Not secret, so they live in the environment’s ConfigMap/env, one value per tier: total concurrent SQL queries per pod, total query memory pool per pod, a total SQL request budget, and the ceilings for rows and timeout. They are sized against the real pod: prod is 4 replicas, requests 256Mi / 100m, limits 1Gi / 500m (k8s/prod/deployment.yaml), shared with every other route. So:
    • the query context’s DataFusion parallelism is capped well below the CPU limit so one query cannot starve lookups on the same pod;
    • the memory pool plus the resident data and baseline must fit under the 1Gi limit with headroom (the earlier 256 MB default is a starting point, not a promise; staging’s capacity check decides);
    • request budgets are per pod = aggregate divided by replicas, the same convention as rate_limit.rs (per_pod, REPLICA_COUNT), including its known drift if the replica count changes without updating the constant.
  2. Per-profile limits: defaults plus per-profile override. Defaults come from configuration; each profile’s sql.limits may override: rows, timeout, concurrency, requests per minute, memory share. A profile override is always clamped by the server-wide limits, so a mistake in the rules can restrict a profile but can never exceed what the hosting system can take.

Enforcement order per request: authenticate the profile, check it is granted SQL and the dataset, check its rate and concurrency, check the server-wide budget, then run with the smaller of the profile and server-wide caps.

Audit trail

One structured log line per request: profile id, dataset, hash and length of the SQL (text only at debug), rows, duration, outcome. Failed authentication is logged with source IP and counted. Secrets and tokens are never logged.

Experimental declaration

5b. Where the other plans change (the general solution)

5c. The capacity test that sets the server-wide limits

Yes, this is a test, not a guess. The limits protect the pod, so they should come from measuring a real pod. On staging (2 replicas, same image):

  1. Baseline: memory and latency of normal lookups with no SQL.
  2. Run deliberately heavy SQL (large scans, sorts and self-joins on the EDGAR catalog, many concurrent queries) while a steady lookup load runs.
  3. Raise concurrency, the memory pool and the parallelism cap until lookups degrade or memory approaches the pod limit; back off, and record the largest settings that keep lookup latency within an agreed margin of baseline.
  4. Set the server-wide limits at a safe fraction of that, per tier; prod gets the same method on its own sizing (4 replicas). Pass criteria (decided 2026-10-05). Measured on staging with the SQL limits at their intended values, under steady lookup load plus the heaviest SQL the limits allow:
  5. Lookup p95 is no more than 25% above the no-SQL baseline, and p99 no more than 50% above.
  6. Zero 5xx responses and zero timeouts on lookups during the run.
  7. Peak pod memory stays under about 70% of the pod limit (1Gi in prod), with no out-of-memory kills or restarts.
  8. SQL cannot hold the pod above its CPU limit long enough to throttle lookups (in practice covered by criterion 1).
  9. A query that exceeds the row cap, timeout or memory pool fails cleanly with the intended error; it does not crash the pod or hang.
  10. A soak of about 30 minutes shows no slow memory growth, and memory returns to baseline after the SQL load stops. If any criterion fails, lower the server-wide limits (concurrency, memory pool, parallelism cap) and repeat. The settings that pass become the staging values; prod uses the same method on its 4-replica sizing.

6. Risks and open concerns

7. Decisions (2026-10-05)

  1. Shape: HTTP/HTTPS only. No Flight SQL, no Postgres protocol for now.
  2. Tables: per dataset (sic, edgar), no combined context.
  3. Vector columns: hidden from SQL.
  4. Access: off by default behind a config flag.
  5. Caps: 1,000 default / 10,000 max rows, 10 s, 256 MB, all configurable.

  6. Access (2026-10-05): credential-only; one general credentials file plus a rules file (defaults and overrides; a section per profile) delivered like the existing secret (sealed K8s Secret, Docker secret, or gitignored local file); mediumroast.io is one profile with strict limits set by the operator; open-source users create their own; experimental in all documentation (section 5a).
  7. Limits (2026-10-05): two sets, server-wide (pod/cluster protection, per tier, split per replica) and per-profile (defaults plus overrides, always clamped by the server-wide set).
  8. Credential (2026-10-05): HTTP Basic Auth (profile id and a generated token; only the token’s SHA-256 is stored). Rolling HMAC and Digest were considered and not chosen.
  9. General solution (2026-10-05): one credentials/rules pair serves every feature, and mediumroast.io’s rate-limit trust becomes a profile grant. No legacy secret-in-User-Agent path (the self-identifying User-Agent tier stays), because nothing depends on it yet.

  10. Concurrency cap (2026-10-05): kept; tried out in testing with mediumroast.io.
  11. Reload (2026-10-05): profiles are read at startup only; changing them means a restart (rolling the pods). Simplicity, as this is an open-source example.
  12. Capacity test pass criteria (2026-10-05): as listed in section 5c.
  13. <NAME>_FILE convention (2026-10-06): dropped. It was designed when the one secret was an env var; now the only secret is the credentials file, which is already a mounted file, and no secret env var remains. Revisit only if one appears.

Still open: only the real server-wide limit values, which come from running the capacity test on staging (section 5c).

8. Phases

Decided 2026-10-05: the endpoint first, per-profile limits afterwards.

Phase 1: the SQL endpoint, with one global set of limits.

Phase 2: profiles and limits.

Not planned: Flight SQL (3.2) and Postgres wire (3.1), kept for reference if bulk or BI-tool users appear.

9. Sources