# skaidb — complete reference for LLM agents

This single file contains everything an LLM needs to manage a skaidb
deployment and to write software that fully uses it. It is self-contained;
the per-topic docs (SEARCH.md, TIMESERIES.md, VECTOR.md, CLUSTERING.md,
GRAFANA.md, UI.md, QUERY_SYNTAX.md) go deeper but are not required.

**What skaidb is:** a distributed, schema-less, SQL-speaking database in a
single static binary. One engine serves relational rows (LSM storage),
full-text search (embedded Tantivy, BM25), time-series (Gorilla-compressed
samples with PromQL), and vector search (HNSW). Leaderless replication on a
consistent-hash ring; quorum reads/writes, hinted handoff (in-memory →
per-replica on-disk spill; self-expiring: `cluster.hint_max_age_secs`
default 86400, per-replica `cluster.hint_max_disk_mb` cap default 1024,
departed-replica logs deleted, drops counted on
`skaidb_cluster_hints_expired_total` — anti-entropy covers what expired
hints carried), read repair,
anti-entropy, online resharding. Two binaries ship: `skaidb` (server) and
`skaidbsh` (network SQL shell + admin client).

---

## 1. Connecting

| Surface | Default port | Auth | Use for |
|---|---|---|---|
| Binary protocol (drivers, `skaidbsh`) | 7000 | SCRAM | sessions, prepared statements, batched executemany, pipelining, streaming |
| REST `POST /query` | 7080 | HTTP Basic | one-shot SQL from anything that speaks HTTP |
| ES-compatible REST subset | 7080 | HTTP Basic | existing ES clients / log shippers |
| Prometheus `remote_write` + `/api/v1/*` | 7080 | HTTP Basic | metrics ingest + Grafana |
| Web UI `/ui` | 7080 | HTTP Basic (login form) | humans: overview, SQL console, data inventory, admin |
| `GET /metrics`, `/health`, `/ready`, `/status` | 7080 | none (`/status` optionally `MONITOR`) | probes/scrapers (read-only, secret-free) |

Readiness contract: `/health` = process liveness, always 200 while the
listener serves. `/ready` = "will serve reads and accept writes" — 503
(body names every reason) while the engine is unavailable, the node is
shedding writes (memory overload, IWM state 2) or disk-blocked (state
3). `/ready?strict=1` additionally reports transitional states a
rolling operation should wait out: resharding migration, resync
backfill, and heavy maintenance rebuilds (search catch-up, index or
vector backfill). WAL replay happens inside engine open, before the
listener binds, so a replaying node fails the connect itself. Gate
rolling upgrades on `?strict=1` plus drained hints (`/status` peers[]
`hints_pending`). `skaidbsh wait-ready [--strict] [--timeout <secs>]`
polls it for scripts (exit 0 = ready).

Version discovery (all three report the same string): `skaidb --version`
prints one line `skaidb <semver> (<git-sha>)`; `GET /status` carries a
top-level `"version"`; `/metrics` labels `skaidb_build_info` with
`version`/`git_sha`.
| MQTT broker (3.1.1 + 5.0, TCP/TLS/WebSocket) | 1883 / 8883 | username/password | IoT clients (Home Assistant, zigbee2mqtt, Tasmota, …); off by default (`[mqtt] enabled`) |
| Internode | 7100 | configurable | cluster traffic (not for clients) |

HTTP Basic verification is **memoized**: the SCRAM stored-key
derivation is deliberately expensive (15k PBKDF2 iterations), and Basic
auth is stateless, so without the cache every request would pay it
(~46 ms of hashing for a `SELECT 1` that otherwise takes ~1 ms). The
cache holds a pure
(password, salt, iterations) → key mapping and the check still compares
against the account's current verifier, so a password change takes effect
immediately. High-rate REST users (Grafana scraping `/api/v1/query`, log
shippers on the ES subset) pay no hashing tax per call.

A **failed login never reveals whether the account exists**: an unknown
username is answered with the same message as a wrong password
(`authentication failed`) and runs the same key derivation against a decoy
credential, so neither the wording nor the wall clock distinguishes the two.
SCRAM server nonces come from the OS CSPRNG, and each credential carries a
random salt drawn when the password is set (stored inside the verifier, so
it replicates with it and survives restarts) — existing credentials keep
authenticating unchanged and pick up a random salt at the next
`ALTER USER … PASSWORD`.

```bash
# SQL over REST — body is plain SQL, or JSON with an optional session db:
curl -u user:pass -X POST http://node:7080/query -d "SELECT * FROM t LIMIT 5"
curl -u user:pass -X POST http://node:7080/query \
     -d '{"sql":"SELECT * FROM t","db":"mydb"}'
# Response: {"columns":[...],"rows":[[...],...]} | {"affected":n} | {"ok":true}
# | {"error":"..."} (HTTP 400)
# Optional "consistency": "one" | "quorum" | "all" overrides the defaults
# for this request. Reads at "one" answer from the coordinator's local
# replica — bounded and fast (an indexed ORDER BY ... LIMIT n reads n rows),
# may lag an in-flight write by a beat.

# Bulk JSON document upsert (overwrites on primary key). Optional
# "consistency": "one" | "quorum" | "all" overrides the write default for
# this request — bulk loaders use "one" so the ack never waits on the
# slowest replica (replication still reaches every replica via the async
# tail; hints + anti-entropy backstop).
curl -u user:pass -X POST http://node:7080/insert \
     -d '{"db":"mydb","table":"t","rows":[{"id":"a","x":1}],"consistency":"one"}'
# Response: {"inserted":n} | {"error":"..."}

# Shell (nearest-node selection, failover, discovers peers via /status):
skaidbsh --host node --port 7000 --rest-port 7080 [--user u --password p]
# One-shot with machine output: --format csv (RFC-4180, header row, NULL =
# empty cell) or --format json (NDJSON, NULL fields omitted). Row data ONLY
# on stdout — DDL/mutation acks suppressed, errors on stderr — so
#   skaidbsh … -e "SELECT …" --format csv > out.csv   composes cleanly.
skaidbsh … -e "SELECT id, v FROM app.t WHERE v > 5" --format json
```

Key facts an agent must know:
- **One statement per call.** No multi-statement bodies. `;` is allowed as a
  terminator but does not chain statements.
- **The REST gateway is stateless**: `USE db` does not persist between
  calls. Pass `{"sql": ..., "db": "mydb"}` per request, or qualify names
  (`mydb.orders`). Binary-protocol sessions do keep `USE` state.
- **REST row results stream** (chunked JSON, ~64 KiB at a time): no
  response-size cap and no response-sized buffer. Request bodies over
  64 MiB → 413; sockets carry 30 s read / 60 s write timeouts, so a stalled
  client can't pin a handler. Bulk WRITES belong on the binary protocol.
- **String literals use single quotes** (`'ada'`, escape by doubling:
  `'O''Brien'`). **Double quotes are identifiers** (`"weird name"`). Sending
  `"text"` where a string is expected is a common LLM error.
- Rows are schema-less documents: any field not present reads as `NULL`;
  there is no column DDL to manage.

**Python driver** (`drivers/python`, DB-API 2.0 / PEP 249, pure standard
library, no dependencies). `apilevel = "2.0"`, `threadsafety = 1` (threads
may share the module, **not** a connection), `paramstyle = "qmark"` (`?`
placeholders). `__all__` exports exactly: `connect`, `Connection`,
`ConnectionPool`, `pool`, `Cursor`, `Error`, `DatabaseError`,
`OperationalError`, `ProgrammingError`, `Consistency`, `apilevel`,
`threadsafety`, `paramstyle`. **There is no `Client` class in Python** —
`Client::connect_*` elsewhere in this file is the **Rust** driver
(`crates/skaidb-driver`). `InterfaceError` exists but is not exported.

`skaidb.connect(...)` keywords (the auth keyword is **`user`**, not
`username`):

| Keyword | Default | Meaning |
|---|---|---|
| `host` / `port` | `"localhost"` / `7000` | single endpoint |
| `user` / `password` | `"anonymous"` / `""` | SCRAM credentials |
| `consistency` | `Consistency.QUORUM` | `ONE`=0, `QUORUM`=1, `ALL`=2; also accepts the name as a string |
| `timeout` | `10.0` | default for both dial and reads |
| `connect_timeout` / `read_timeout` | `None` | override each half of `timeout` independently — a read timeout can sit above the server's statement timeout without also slowing dial failures |
| `database` | `None` | issues `USE <db>` as part of connecting |
| `seeds` | `None` | `["h1:7000", "h2", ...]`; entries may carry their own port, else `port` |
| `tls` | `False` | TLS for the binary port; **also implied** by passing `tls_ca` or `tls_insecure` |
| `tls_ca` | `None` | CA file verifying the server cert (the cluster `ca.crt`); without it the system trust store is used |
| `tls_insecure` | `False` | skip verification entirely — **dev only** |
| `tls_server_name` | `"skaidb"` | SNI / SAN to verify |

```python
import skaidb

conn = skaidb.connect(seeds=["n1:7000", "n2:7000", "n3:7000"],
                      user="app", password="secret", database="mydb",
                      tls=True, tls_ca="ca.crt")
cur = conn.cursor()
cur.execute("INSERT INTO people (id, name, tags, meta) VALUES (?, ?, ?, ?)",
            (1, "ada", ["math", "eng"], {"team": {"name": "core"}}))
cur.execute("SELECT id, name FROM people WHERE id IN (?)", ([1, 3],))
for row in cur:                      # or cur.fetchall() / fetchone() / fetchmany(n)
    print(row)
```

- **Seeds are tried in shuffled order** — skaidb is leaderless, any seed
  serves any request, and shuffling stops a client fleet from all opening
  on the first entry. Failover applies at connect time.
- **Typed binding, no SQL literal form needed**: a `list`/`tuple` binds as
  an Array, a `dict` as a nested Document (keys must be strings, else
  `ProgrammingError`). Binding an array to `IN (?)` is the set-membership
  idiom. `datetime` → Timestamp, `uuid.UUID` → Uuid.
- **Cursor**: `execute` (returns the cursor, so `.execute(...).fetchall()`
  chains), `executemany`, `fetchone`/`fetchmany`/`fetchall`, iteration,
  `set_consistency`, `close`, context manager. `Connection.execute` is a
  shortcut that makes a cursor for you. `executemany` prepares once and
  ships every parameter row in **one round-trip** (`ExecuteBatch`), falling
  back to a per-row loop for unpreparable statements or a server that
  predates the opcode; a row with the wrong parameter count raises
  `ProgrammingError` naming the row index. Server-side, plain-INSERT
  batches (no `ON CONFLICT`) are SPLICED into multi-row statements in
  500-row chunks — one write-path traversal per chunk instead of per row —
  so `executemany` is the genuinely fast bulk-insert path, not just a
  round-trip saver; other statements keep per-row execution.
- **`Connection.stream(sql, consistency=None)`** yields rows one at a time
  over `OP_QUERY_STREAM`, holding one chunk instead of the whole result —
  for exports and large scans. It takes **no parameters** (the opcode
  carries SQL text), and the connection is busy until the generator is
  exhausted or closed, so run nothing else on it meanwhile. Against a
  server too old to know the opcode it raises `ProgrammingError`.
- **Transactions are SQL, not methods**: `conn.commit()` is an accepted
  no-op (each statement autocommits) and `conn.rollback()` **raises**
  `OperationalError`. Use `BEGIN` / `COMMIT` / `ROLLBACK` as statements on
  one connection — see §3.
- **Pooling**: `skaidb.pool(maxsize=10, **connect_kwargs)` → `ConnectionPool`;
  every `connect` keyword passes through, so pooled connections inherit seed
  failover. Use the `.connection()` context manager (`getconn`/`putconn`
  underneath); connections are validated on checkout/checkin and discarded
  if a transport error left them broken. The pool is thread-safe even though
  a single connection is not.
- **TLS**: node certificates carry a single SAN, `DNS:skaidb`, which is
  never the address you dial — hence `tls_server_name` defaulting to
  `"skaidb"`. The driver verifies the cert against that name, so dialing by
  IP is fine; HTTP clients that verify hostnames themselves need
  `--resolve skaidb:7443:<ip>` to reach the REST port.
- **Install**: **not published on PyPI** — `pip install skaidb` does not
  work. From a checkout: `pip install ./drivers/python`, a path dependency
  (uv `[tool.uv.sources]`), or vendor the `skaidb/` package next to your
  code — it is one pure-stdlib module with zero dependencies.

Every other driver (`go`, `java`, `nodejs`, `php`, `ruby`, `dotnet`) speaks
the same binary protocol and the same SQL; see `drivers/<lang>/README.md`
and `docs/HOWDOI.md` for per-language equivalents of the above.

---

## 2. Data model, types, expressions

- **Table** = documents keyed by a declared primary key (single or
  composite). `CREATE TABLE t (PRIMARY KEY (id))` is the whole schema.
- **Types** (dynamic): `null`, `bool`, `int64`, `float64`, `decimal`,
  `string`, `bytes`, `uuid`, `timestamp` (Unix ms), `array`, `document`
  (nested). SQL literals exist for int, float, string, bool, null,
  constant arrays (`[0.1, -0.2]`), and constant objects
  (`{name: 'ada', addr: {city: 'x'}}` — quote reserved-word keys:
  `{'from': 1}`); the other types arrive via stored data or bound params.
  `SET meta.addr = {…}` replaces that whole sub-document; dotted-path `SET`
  updates one scalar leaf.
- **Paths**: dotted paths reach nested fields everywhere —
  `address.city`, in projections, WHERE, GROUP BY, ORDER BY, UPDATE SET,
  and index declarations.
- **Duration literals**: `250ms 15s 5m 2h 30d 1w` — integers in ms, usable
  wherever an integer is (`WHERE ts >= now() - 1h`).
- **Operators** (rising precedence): `OR`; `AND`; `NOT`; comparisons
  `= != <> < <= > >=`; `IS [NOT] NULL`; postfix `[NOT] IN (v, ...)` /
  `[NOT] BETWEEN lo AND hi` / `[NOT] LIKE|ILIKE pat`; `+ -`; `* /`;
  unary `-`; parens. Three-valued logic: `NULL` comparisons are unknown.
  `in/between/like/ilike` are contextual (still valid column names).
- **`BETWEEN`**: inclusive range, sugar for `>= lo AND <= hi`; literal
  bounds join index/PK range pushdown like the two comparisons would.
- **`LIKE` / `ILIKE`**: exact substring/prefix match (`%` any run, `_` one
  char, no escape); `ILIKE` folds case. Non-string operands → unknown, not
  an error. Residual filter (no index acceleration) — complements analyzed
  `MATCH()` word search; same scan-budget caveat as `IN` on large scans.
- **`IN` / `NOT IN`**: `x IN (a, b, c)` set membership (≥1 element; `IN ()`
  errors). An array-valued element is flattened, so `WHERE id IN (?)` bound
  to `[1,2,3]` tests membership in that set — the "fetch these N ids"
  pattern, and the native replacement for the old `$in`→OR-chain. Array
  columns match by containment (like `=`). **PK-pinned `IN` is a point-read
  set**: every PK column pinned by `=`/literal-`IN` → one point read per
  candidate key (≤1000; composite keys cross-multiply), replica-routed on a
  cluster (EXPLAIN: `point-read set`). Non-PK / `NOT IN` shapes stay a
  residual filter and can hit the scan budget on large unindexed scans.
- **Scalar functions**: `now()` (statement start, timestamp),
  `if(cond, then [, else])` + `nullif(a, b)` — CONDITIONAL AGGREGATES in
  one pass, since COUNT ignores NULLs:
  `count(if(status >= 400, 1, NULL))` beside `count(*)` gives an error
  count and a total in ONE scan (else defaults to NULL; a NULL/non-boolean
  condition takes the else branch rather than erroring),
  strings `lower(s)` `upper(s)` `trim(s)` `length(s)` (chars; array →
  elements) `substr(s, start [, len])` (1-based) `concat(a, b, ...)` (NULLs
  skipped) `starts_with(s, p)` `ends_with(s, p)`, numbers `abs(n)`
  `floor(x)` `ceil(x)` `round(x)` (float → int); all deterministic (CHECK /
  generated / expression-index safe); wrong-typed argument → NULL,
  `time_bucket(step, ts)` (floor to bucket: `time_bucket(5m, ts)`),
  `to_timestamp(v)` (epoch-ms number or ISO-8601 string → timestamp;
  unparseable/mistyped → NULL — range-filter string timestamps in-query),
  and `CAST(x AS INT|FLOAT|STRING|BOOL|TIMESTAMP)` (desugars to
  `to_int`/`to_float`/`to_string`/`to_bool`/`to_timestamp`, same
  NULL-on-unconvertible policy; timestamps stringify as ISO-8601).
- **Geospatial**: `geo_distance(point, lat, lon)` → haversine metres;
  `geo_bbox(point, min_lat, min_lon, max_lat, max_lon)` → bool (min_lon>max_lon
  crosses the antimeridian). `point` is a `{lat,lon}` object or `[lat,lon]`
  array; non-point/NULL → NULL. Use in `WHERE geo_distance(loc,..) <= <metres>`
  and `ORDER BY geo_distance(loc,..) LIMIT k` (nearest-first). `CREATE GEO INDEX
  <name> ON <t>(<point-col>)` makes both predicates prune via a Morton/Z-order
  index (transparent — no query change; `geo_bbox` boxes and `geo_distance <= r`
  radii route to code-range scans, exact-filtered on re-read — an
  antimeridian-crossing box/radius splits into two covered halves); without
  one they scan. Broadcast DDL, self-maintaining, on-disk (no rebuild on
  restart), cluster-wide via the secondary-index scatter. `distance('5km')`
  → metres (unit literal: m/km/mi/yd/ft/NM/…; constant, index-prunable
  radius). See [GEO.md](GEO.md).
- **`SELECT <expr>` without FROM**: constant projection, one row
  (`SELECT 1` = liveness probe; needs no privilege). `*` and other
  clauses still require a table; a FROM-less leg works inside UNION.
- **PK point reads & prefix slices**: a full composite-PK equality
  (`channel = ? AND ts = ?` on PK `(channel, ts)`) is a single bloom-gated
  point read — even for an absent key (no full-table scan). A leftmost
  equality *prefix* (plus one trailing range on the next PK column) scans
  only that key slice — `WHERE channel = ?` reads one channel, `AND ts >= ?`
  narrows it. A **range alone on the LEADING pk column** slices too:
  `WHERE id >= ? AND id < ?` on `PRIMARY KEY (id)` seeks
  instead of scanning, so a time-sortable PK (ULID/snowflake) serves time
  ranges — including range AGGREGATIONS (`GROUP BY`,
  `count`). Bounds are conservative (`>` widened to `>=`) and the full
  filter is re-applied per row, so results are exact. This holds on CLUSTERED reads too (SELECT and COUNT at any
  consistency): the coordinator and every member narrow their shard walks
  to the pinned slice, instead of each full-scanning its shard to push the
  filter down. EXPLAIN reports it as `primary-key prefix range`. No secondary index needed for shapes the primary key orders:
  `ORDER BY <leftmost pk column> LIMIT k` (optionally with a pk range —
  keyset pagination: `WHERE id > ? ORDER BY id LIMIT ?`) walks the table
  in key order with early stop, at ONE and QUORUM (k ≤ 10000) alike, with
  bounded memory. **`DESC` costs the same as `ASC`**: the walk
  seeks the range's upper bound and pages BACKWARD, so "latest N" —
  `ORDER BY id DESC LIMIT n`, a trace/log UI's default view — examines
  ~n rows. An exact single-key `ORDER BY <unindexed column>
  LIMIT k` keeps a bounded top-k instead of gathering every row — O(k)
  memory, though still a full-table scan's worth of work.
- **ORDER BY**: a multi-key `ORDER BY` whose leading key is indexed walks the
  index bounded by LIMIT plus the leading-key tie group, then re-sorts by the
  full clause — exact, without gathering every matching row. When a strictly
  more selective equality index also covers the filter, the planner probes
  its range first (capped peek): if it holds ≤256 candidates it gathers and
  sorts those instead — a filter matching (almost) nothing answers through
  the index instead of walking the whole sorted range finding nothing.
- **DISTINCT**: `SELECT DISTINCT <one column>` streams the value set (no
  row materialization; array columns dedupe as whole arrays). At
  consistency "one" on a full-copy cluster it is a single local pass.
- **Consistency ONE on a full-copy cluster is a single LOCAL pass** for every
  read shape: point reads, `COUNT(*)`, filtered counts, DISTINCT, ordered
  reads, and plain and grouped row gathers (`GROUP BY`,
  `time_bucket`). Analytics/dashboard reads over a full-copy
  cluster are the intended users; the trade is this replica's view, which may
  lag an in-flight write by a beat.
- **Memory tables**: `CREATE TABLE t (...) WITH (memory = true)` — RAM-only
  (no WAL fsync, never flushed, empty on restart, excluded from repair);
  pair with `ttl`. `SHOW STATUS` table counts are approximate version
  counts (exact after compaction).
- **Scan budget**: one statement may examine at most `storage.scan_row_budget`
  rows (default 250k; 0 disables), materialize at most `storage.scan_byte_budget`
  bytes into a result set (default 256 MB; 0 disables), and run at most
  `storage.statement_timeout_secs` (default 120s; 0 disables) — past any of them
  the statement errors. The deadline binds waits too: a statement parked on
  the engine lock past its deadline fails the moment it acquires, rather
  than running to completion however long the wait took (150 s statements
  against a 120 s timeout, once).
  it errors with `resource limit: ...`. **Setting `storage.memory_target`
  (`"auto"` / `"512MB"` / `"2GB"`) derives BOTH scan budgets from the node's own
  cgroup-aware memory limit** instead of fixed constants, so a
  320 MB container and an 8 GB server do not share one ceiling; an 8 GB node on
  `"auto"` lands on exactly the default 256 MB / ~250k. **Streamed
  `COUNT`/`DISTINCT` are exempt from the ROW budget** — they retain nothing per
  row, so they are bounded by the byte budget and the deadline
  instead. On a clustered gather the row budget
  counts each DISTINCT row key once, however many replicas deliver a copy —
  an RF=3 full-copy gather of a 183k-row table costs 183k, not 3×183k.
  `LIMIT` bounds output, not scan work: a
  filter matching nothing under `ORDER BY .. LIMIT` walks the whole range. The
  row budget bounds work; the byte budget bounds MEMORY — a scan of many
  multi-KB rows can stay under 250k rows yet gather gigabytes on the
  coordinator — enough to OOM a small node. The byte budget caps
  BOTH the finalized result AND the coordinator's in-flight gather buffer.
  The clustered gather PACES its sources against that buffer: once the
  un-finalized merge buffer crosses half the byte budget, only the source at
  the slowest cursor is pulled until finalization drains it, so a full-copy
  scan where one replica lags (or where the local shard outruns peer pages)
  stays bounded and COMPLETES — a retain-nothing shape (`COUNT`, `DISTINCT`,
  streamed `GROUP BY`) over any table size runs at any consistency in
  O(groups) coordinator memory. Only a genuinely un-drainable buffer (a
  single page larger than the budget) still errors
  (`scan gather buffer exceeded N resident bytes`) instead of OOMing.
  Streaming `COUNT`/`DISTINCT` retain nothing and are never charged bytes;
  for retaining scans add a `LIMIT`, narrow the projection/filter, or raise
  `storage.scan_byte_budget`. A gather source that answers
  `busy: engine write-locked` (live-ingest write lock held at that instant)
  is retried in place briefly rather than dropped; a source that stays busy
  or unreachable is dropped, and if too few sources remain the statement
  fails `read quorum not met: R/N members responded (<addr>: <cause>; …)` —
  the per-source causes name why. Before that error, every unanswered peer
  gets one **breaker-bypassing probe** (bounded 2 s): a peer suspended by its
  circuit breaker — the state a just-restarted or briefly-`busy` member is in
  on every coordinator until the cooldown passes — is otherwise
  indistinguishable from a dead one, which failed QUORUM reads on clusters
  with every member up. The probe runs only when the read would otherwise
  fail, and a success closes that peer's circuit for all callers. Point reads
  and time-series gathers take the same second chance.
- **Aggregates**: **grouped aggregations STREAM when every aggregate is
  foldable** (`COUNT`/`SUM`/`AVG`/`MIN`/`MAX` with plain args): rows fold into
  O(groups) per-group state as the gather produces them instead of
  materializing O(rows), so a `GROUP BY` over a window larger than
  `scan_row_budget` answers — embedded, and on a cluster **at any
  consistency** (QUORUM streams too, via the same access-path
  ladder `SELECT` uses with a fold sink on the branches that can be
  unbounded, so a shape that prunes for a row gather prunes identically
  here — not a second, independently-reasoned plan). **LWW ties are
  deterministic**: an exact cross-node HLC stamp collision resolves by one
  rule everywhere (Put beats Delete, then larger value bytes —
  `skaidb_storage::lww_wins`), applied at the memtable, the LSM merge, the
  coordinator gather and point reads. **UNIQUE indexes**: `CREATE UNIQUE INDEX i ON t (paths)` — cluster-wide
  (reservation keyed by value, claimed via the per-key consensus round →
  exactly one winner under races); NULL/absent unconstrained; distinct
  `unique violation` error; freed by update-away/delete; DDL refuses over
  existing duplicates; not combinable with global. Every write path pays
  the claim — multi-row INSERT goes row by row (a colliding row fails the
  statement; rows before it stay, as on standalone).
  **Array functions**:
  array_append / array_append_distinct / array_remove / array_set(i
  errors OOB) mutate list fields inside UPDATE/upserts (linearizable per
  row; NULL base = []); array_length / array_contains usable in WHERE.
  **INSERT ON CONFLICT
  DO NOTHING / DO UPDATE SET** upserts atomically through the same
  per-row consensus round (merge sees old columns bare, as
  `<table>.<col>`, and the incoming row as `excluded.<col>`; a real column
  named after its own table wins over the qualified form;
  plain INSERT stays replace; toast/gidx/unique-indexed tables best-effort
  read-merge-put — the unique VALUE claim stays consensus-serialized).
  **Clustered UPDATE is
  linearizable per row**: each touched row goes through a
  per-key single-decree Paxos round among its replicas (ballot =
  (hlc, node) — HLC alone is NOT unique across nodes; commit = plain LWW
  put at the value's origin ballot carrying a reserved-field commit
  history as exactly-once evidence; durable promise/accept state in a
  node-local `<data_dir>/paxos` engine). Concurrent `SET n = n + 1`
  counts exactly (100/100 + prod-shape 20x25 harness runs). PK-changing
  assignments and INSERT/DELETE stay LWW (documented caveat);
  toast/gidx/unique-indexed tables fall back to the plain path
  (for unique tables the fallback IS the enforcement path: put runs the
  claim/release legs; UPDATE into a taken value rejects, moving off a
  value frees it, multi-row self-collisions reject whole pre-write). The
  fold streams **at
  the storage layer too**: the PK-range branch scans via the
  streaming k-way merge iterator, never collecting the whole key slice
  into RAM first. Two per-row costs on
  that path are also avoided: the projected row decode allocates field-name
  strings only for KEPT fields (skipped fields are compared as borrowed
  bytes), and when the filter is provably identical to the key slice — a
  conjunction of inclusive (`>=`/`<=`) string comparisons on the leading PK
  column, the ULID-dashboard shape — the per-row residual filter
  re-evaluation is skipped entirely (`pk_prefix_scan_range_exact`; any
  shape it cannot prove exact keeps the conservative bounds + per-row
  re-check). Large streamed folds also **parallelize**: when the plan is a
  pure range walk (unindexed full scan or PK-prefix slice; no LIMIT
  early-stop, no open transaction, no index/geo/point plan) and the range
  overlaps ≥16 MB of stored data, the scan splits at SSTable block
  boundaries (weighted by block bytes, un-flushed memtable versions
  included so the newest key region doesn't pile onto the last chunk) into
  up to `[storage] parallel_scan_threads` contiguous chunks folded on
  scoped worker threads — per-partition group states merge in key order,
  so first-seen group order, representative rows, and min/max ties answer
  exactly as the serial fold (float sums may re-associate, same as the
  cluster's cross-member aggregate merge); scan budgets, the statement
  deadline, and `KILL QUERY` bind across workers through shared meter
  counters. Small ranges, LIMIT shapes, and index-served filters stay
  serial. At the storage layer, every scan — range and
  point alike — goes through a per-table **decompressed-block cache**
  (byte-budgeted; default 16 MB, sized to `memory_target/8` clamped
  [4 MB, 1 GB] when a memory target is set; ONE cache per NODE shared by
  every table and all their SSTable files, so the budget bounds the
  node's resident decompressed bytes in aggregate — per-table budgets
  were individually legal but summed to 1.7-2.6 GB on prod nodes —
  compaction inputs bypass it, retired files' entries purge on drop), so a
  re-queried window serves its decrypt+decompress work from RAM;
  `skaidb_block_cache_bytes` (+hits/misses/evictions, also in
  `SHOW STATUS`) reports residency; and sequential scans read the file
  through a 256 KB **readahead window** — one pread covers many adjacent
  blocks instead of one syscall each. The SSTable block size defaults to
  16 KB (`[storage] block_target_kb`, engine option `block_target_bytes`)
  — the measured knee of the scan-vs-point-read tradeoff (vs 4 KB: +18%
  scan throughput, 15% smaller files, −32% CPU-bound cold point reads,
  and the next doubling bought only ~3% more scan for another halving of
  point reads) — applied as data is flushed/compacted, so a change rolls
  through existing files gradually with no migration. **Value-TOAST**: `ALTER TABLE t SET
  (toast_threshold = <bytes>)` stores big top-level fields out-of-line in a
  hidden `__toast__` companion table (replicated, resynced and
  witness-mirrored like any table); the row keeps a reserved-name manifest,
  scans that skip the field skip a ~20-byte marker instead of the payload,
  and reads that want it resolve transparently at any consistency. New
  writes only; indexed/PK fields never toast; indexing a possibly-toasted
  field is refused. See docs/QUERY_SYNTAX.md. **Range partitioning**:
  `CREATE TABLE t (...) WITH (partition_by = 'range(<col>, <interval>)')`
  (intervals `15min`/`1h`/`1d`/`1w`) buckets rows into hidden per-interval
  child tables — WHERE bounds on the column give PARTITION ELIMINATION
  (the scan budget counts post-pruning rows), `ttl` drops whole expired
  partitions O(1) (retention is partition-granular: rows live up to one
  interval past a pure row-ttl; fully-expired partitions never serve
  reads), late rows land in their value's partition (no OOO horizon), and
  duplicates on the column are first-class (identity stays the PK — keep
  the partition column immutable per key, it is placement). CLUSTERS:
  parent DDL replicates; every member lazily creates the same
  deterministic children as writes apply (no child-DDL broadcast);
  retention = replicated DROP TABLE from the flusher (~5 min cadence),
  reads hide expired partitions regardless.
  CLUSTERING: `WITH (cluster_by = (c1[, c2…]))` — storage key AND
  identity = (cluster…, pk), Cassandra-style. Clustering-prefix ranges
  SEEK (budget counts only matched rows); ORDER BY a prefix walks
  sorted; same-tuple re-ingest overwrites (idempotent); same pk at a
  different clustering value = NEW row; UPDATE moving a clustering
  value re-keys (put-new-delete-old); bare-pk lookup = scan (index the
  key column for needles); clustering cols non-null, unrenameable,
  disjoint from pk; no distribute_by/toast combos; composes with
  partition_by (partition col at the clustering head). CREATE-only.
  EVENT ROLLUPS: `CREATE ROLLUP r ON t BUCKET 1h [BY (dims)] AGGREGATE
  (count(*) AS n, sum(c) AS total, …) [REFRESH dur] [RETENTION dur]` —
  a real bucket table (`PRIMARY KEY (bucket, dims…)`) pre-aggregating a
  partitioned/clustered source; query `SELECT bucket, n FROM r WHERE
  bucket >= …` instead of scanning raw events. Bucket column inferred
  (partition col, else clustering head; neither = refused). Background
  pass (~1 min; clusters: one owner per rollup) RECOMPUTES the trailing
  REFRESH window (default 2 buckets) from raw and upserts — replaces,
  never increments, so retries/handovers/replicas converge; first pass
  populates full history; rows later than the window don't update their
  bucket. RETENTION ages out rollup rows independently of the source
  ttl. NULL bucket/dim groups are skipped; source drop cascades;
  rollup-of-rollup refused; aggs: count/sum/min/max only (avg = derive
  sum/count).
  ADOPTION: `ALTER TABLE t SET (partition_by = 'range(col, 1d)')` on an
  EXISTING table — directory-rename into a hidden legacy partition (no
  copy), background drain relocates rows (original HLCs, idempotent),
  reads union children+legacy throughout, writes retire the
  pre-adoption copy, legacy drops when empty (replicated drop on
  clusters after a merged emptiness check). Clusters drain ONLINE
  per-node; embedded/standalone complete within the ALTER. Rows missing
  the column stay in legacy (served, SHOW PARTITIONS NULL bounds).
  Requires shedding indexes/streams first (recreate after).
  SECONDARY INDEXES on partitioned parents fan per-partition physical
  indexes (template on the parent; new partitions inherit; DROP fans;
  needle lookups prune through them). `SHOW PARTITIONS <t>` lists
  range/rows/disk/serving per partition; EXPLAIN adds
  partitions_total/scanned/pruned. UNIQUE+global indexes, search/
  vector/geo, streams/triggers/memory/toast/distribute_by are refused
  loudly, as is BEGIN ATOMIC PARTITION; AFTER keyset paging says to
  page with a WHERE range instead. Per-group state is
  charged to the byte budget as groups are created, so `GROUP BY <near-unique
  col>` still meets the memory guard. Non-foldable aggregates (`PERCENTILE`,
  `COUNT(DISTINCT …)`, ts-only funcs) keep the materializing path at every
  consistency — the guard is scoped, not disabled. `COUNT(*)`, `COUNT(expr)`, `COUNT(DISTINCT expr)` (exact),
  filtered `COUNT(*)` is answered index-only when a secondary index fully
  covers a conjunctive equality/range filter (no row reads — safe on tables
  of any size). Row-producing shapes (`GROUP BY`, `time_bucket`, plain
  `SELECT`) resolve a candidate key set first, and the merge is
  bounded by that set's key span. One NULL-safe negated equality
  (`col != v OR col IS NULL`)
  beside a covering conjunction counts by complement
  (two index-range cardinalities); a filter an index BOUNDS but does not
  cover counts its candidate slice rather than the
  table; other filtered counts stream with
  bounded memory (at consistency "one" on a full-copy cluster: a single
  local pass, like DISTINCT),
  `APPROX_COUNT_DISTINCT(expr)` (opt-in HLL on the search pushdown, exact
  everywhere else), `SUM`, `AVG`, `MIN`, `MAX`, `PERCENTILE(expr, p)`
  (exact percentile_cont, p a literal fraction in (0,1], row-gather path
  only — no pushdown/partials); time-series only: `RATE`,
  `INCREASE`, `DELTA`, `FIRST`, `LAST`.
- **Window functions**: `ROW_NUMBER()`, `RANK()`, `DENSE_RANK()`,
  `LAG(expr[, offset[, default]])`, `LEAD(…)` with
  `OVER ([PARTITION BY …] [ORDER BY …])` — SELECT-list only (alias +
  `ORDER BY alias` to sort by one), no mixing with `GROUP BY`/aggregates.
  Computed at the coordinator over the FULL filtered set before outer
  `ORDER BY`/`LIMIT` (a windowed query therefore gathers every matching
  row — no top-k truncation); ties and missing window `ORDER BY` break by
  input position.
- **`GROUP BY` memory**: a plain `GROUP BY`/aggregate query (no `TOP k
  BY`, `*`, join, or set op) decodes only the columns the filter,
  grouping, aggregates, `HAVING`, and `ORDER BY` actually reference — not
  every column of every matching row — so grouping on one or two fields
  of a wide/large-document table costs roughly what those fields alone
  would, regardless of how large the other columns are. `GROUP BY ...
  TOP k BY` returns whole rows per group and does not get this — it
  still materializes every selected column.
- **Bind parameters**: `?` in prepared `SELECT/INSERT/UPDATE/DELETE`
  (binary protocol / drivers), including `LIMIT ? OFFSET ?` (non-negative
  integer), `NEAREST`'s query/k, and `EXPLAIN <preparable>` (explain the
  exact bound query). Values bind as **typed** values, so `?`
  can carry an array or nested document (e.g. Python `list`/`dict`) that has
  no SQL literal form — including `WHERE id IN (?)` bound to an array. Not on
  the one-shot REST path.

**Not in the language**: subqueries, CTEs, window functions, `FULL OUTER
JOIN`, `INTERSECT`/`EXCEPT`, `ADD/DROP COLUMN` / typed columns
(schema-less; NOT NULL / DEFAULT / CHECK / SERIAL / IDENTITY / GENERATED
… STORED column items exist), `VIRTUAL` generated columns,
`currval()`/`lastval()` (use `INSERT … RETURNING`),
`UPDATE`/`DELETE … RETURNING`, `ORDER BY embedding <-> [..]` (use
`NEAREST`).

---

## 3. Statement reference

```sql
-- DDL
CREATE TABLE [IF NOT EXISTS] t (PRIMARY KEY (col [, col ...]))
  [WITH (ttl = dur, witness = bool, replication = n, nodes = ['id', ...],
         distribute_by = (col [, ...]))]
--   replication = n: per-table RF override (n >= members = full copy).
--   nodes = [...]: pin the whole table to those members; entries accept
--   aliases, resolved to stable ids at DDL time; non-members refused (mutually exclusive
--   with replication; every pin holds every row; non-pin coordinators route
--   to the pins; REMOVE NODE refuses while a table pins the node).
--   distribute_by = (col, ...): ring placement by the encoded prefix of these
--   LEADING primary-key columns — all rows sharing the prefix values land on
--   one replica set (partition co-location at RF < members). Must be a
--   leading PK prefix; fixed at CREATE (ALTER refuses). Write partition
--   columns with one consistent type (1 vs 1.0 are different prefixes).
BEGIN ATOMIC PARTITION (col = lit [, ...]) ... COMMIT | ROLLBACK
--   Cluster single-shard transaction (driver connections): pins one partition
--   of distribute_by tables; statements may touch only rows whose
--   distribution columns equal the pinned literals (any number of tables),
--   reads see the session's buffered overlay, COMMIT applies all-or-nothing
--   via a partition-scoped consensus round (crash-safe: replicas complete an
--   interrupted commit). Isolation = atomicity only (LWW between
--   transactions; no read-set validation). Refused inside: DDL, USE, TS
--   writes, tables with UNIQUE/GLOBAL indexes or value-TOAST; buffer cap
--   8 MB. Plain BEGIN stays embedded/standalone-only.
ALTER TABLE t SET (replication = n | nodes = ['ref', ...])
--   Placement + witness flag also shown in the UI data tab (pins as aliases).
--   ONLINE placement transition: reads/writes address the UNION of old+new
--   placement until a background driver repairs to convergence and
--   auto-finalizes (SHOW TABLES: transition = true while open). One per
--   table at a time. Escape hatch if the driver died: REPAIR CLUSTER then
--   ALTER TABLE t SET (placement_finalized = true). RECLAIM trims old
--   copies after finalize. Pin-set SHRINK applies immediately (no window).
--   witness = false: exclude the table from witness-node mirroring (and from
--   the witness tombstone-GC floor). Toggle later: ALTER TABLE t SET (witness = true)
--   System tables refuse the option. Default true.
--   ttl: rows expire <dur> after their last write — immediately invisible to
--   every read; space reclaimed by compaction. Converges at any RF.
--   Live-tunable: ALTER TABLE t SET (ttl = 30d); 0 clears. Shortening can
--   expire existing rows at once; widening/clearing un-expires rows
--   compaction hasn't reclaimed yet (expiry is lazy).
--   VISIBILITY is immediate; SPACE comes back on a background sweep. Flush is
--   size-triggered, so a low-write TTL table would otherwise hold every
--   expired row in RAM for years; the sweep freezes+flushes it and runs a
--   reclaim compaction every storage.ttl_reclaim_interval_secs (900; 0 = off).
--   Only tables the ordinary LSM triggers never reach are swept (whole
--   on-disk size under the flush threshold), and an attempt that reclaims
--   nothing backs off, so a quiet table never becomes a compaction treadmill.
--   Plan for retention + ~one sweep interval of rows, not retention exactly.
--   A TTL table is also digest-INELIGIBLE: anti-entropy stamp-scans it.
DROP TABLE [IF EXISTS] t                    -- cascades to the table's
--   secondary/search/vector indexes
ALTER TABLE t RENAME TO t2
ALTER TABLE t RENAME COLUMN a TO b          -- rewrites rows, rebuilds indexes
CREATE TABLE child (PRIMARY KEY (id),
  [CONSTRAINT n] FOREIGN KEY (c [, ...]) REFERENCES parent (p [, ...])
  [ON DELETE RESTRICT|NO ACTION|CASCADE|SET NULL] [ON UPDATE <same>] [, ...])
ALTER TABLE child ADD [CONSTRAINT n] FOREIGN KEY (...) REFERENCES p (...) [ON ...] [NOT VALID]
ALTER TABLE child VALIDATE CONSTRAINT n     -- check existing rows, mark valid
ALTER TABLE child DROP CONSTRAINT [IF EXISTS] n  -- takes its auto index along; CHECK or FK
CREATE TABLE t (PRIMARY KEY (id),
  c [NOT NULL] [DEFAULT expr] [CHECK (expr)] [, ...],   -- column item: NO type
  [CONSTRAINT n] CHECK (expr) [, ...])                  -- table-level
ALTER TABLE t ADD [CONSTRAINT n] CHECK (expr) [NOT VALID]
ALTER TABLE t ALTER [COLUMN] c SET NOT NULL           -- scans existing rows (cluster-wide) first
ALTER TABLE t ALTER [COLUMN] c DROP NOT NULL | SET DEFAULT expr | DROP DEFAULT
--   Column constraints (schema-less stays: `c TEXT NOT NULL` is a parse
--   error; no ADD/DROP COLUMN). c = top-level field or dotted path.
--   NOT NULL: column present and non-NULL in the row that lands (absent
--   field = NULL). DEFAULT: INSERT only, for columns the column list does
--   not name (explicit NULL is named); applied before the key is computed;
--   evaluated per row against an empty row, so no column refs / aggregates
--   / windows / params / unknown functions (DDL-time errors). CHECK: on
--   the final row; false fails, true or NULL passes, non-boolean = type
--   error; NOT VALID = new writes only until VALIDATE. UPDATE gated per
--   row; ON CONFLICT DO UPDATE checks the PROPOSED row before conflict
--   resolution, then the merged row (Postgres). Violating statement writes
--   nothing. Errors `not null violation: column "c" of table "t"` /
--   `check violation: constraint "n" of table "t": c = v, …` (Constraint
--   class; procedures NOT_NULL_VIOLATION / CHECK_VIOLATION). Names:
--   chk_<t>_<c> (column CHECK), chk_<t>[_<n>] (table-level); one namespace
--   with FKs per table. RENAME COLUMN rewrites items, refuses when a CHECK
--   expr references the column. CLUSTER: ADD CHECK / VALIDATE / SET NOT
--   NULL scan every member at QUORUM before broadcast; travel with schema
--   sync. Partition children use the parent's constraints.
--   FOREIGN KEY: referenced cols = parent's full key (cluster_by first) or
--   exactly one UNIQUE index's paths. Child tuple with any NULL/absent col is
--   unconstrained (MATCH SIMPLE). Violation = `foreign key violation`
--   (Constraint class; procedures: FOREIGN_KEY_VIOLATION); whole statement
--   writes nothing. Default RESTRICT on both sides; CASCADE/SET NULL chain
--   through levels (max depth 16), any RESTRICT down the chain fails the
--   statement. Self-refs OK; one multi-row INSERT satisfies itself in any
--   order. Same database only; parent not TS/memory/ttl/partitioned, child
--   not memory/partitioned; top-level columns only. Default name
--   fk_<child>_<cols joined by _>. Supporting index: reuses a local index
--   whose leading paths are the FK columns, else creates <n>_idx (dropped
--   with the constraint). DROP INDEX refuses on an index a constraint uses
--   (either side); DROP TABLE refuses on a referenced parent. NOT VALID =
--   enforce new writes only until VALIDATE. Txn overlay is visible to the
--   checks. CLUSTER: enforced cluster-wide by the coordinator (QUORUM read
--   of the other side, then write); concurrent opposite-side statements are
--   serialized per referenced tuple through the hidden guard table
--   __fkref__<child>__<constraint> (CAS rows {n: claims in flight, at,
--   fence: token, fence_at}): child write = claim → probe → put → release;
--   parent delete/key change = fence → drain claims → index-maintenance
--   barrier on every member → RESTRICT probe / cascade (fence refreshed per
--   page) → unfence. State older than 10 s (FKREF_GRACE_MS) without a
--   heartbeat is a dead statement's and is reset by the other side
--   (counters skaidb_cluster_fk_guard_waits_total /
--   _stale_resets_total). Not covered: a child put stalled > 10 s between
--   claim and release; ADD/VALIDATE scans (unfenced); repair/resync/PITR
--   replay. Standalone never writes the guard (DB lock serializes).
--   ADD/VALIDATE scan every member before broadcasting; the constraints
--   travel with schema sync; a violating statement writes nothing, a
--   passing one lands row by row. FK tables are refused inside BEGIN
--   ATOMIC PARTITION.
--   Tables with no constraint keep the batched put / CAS paths untouched.
CREATE SEQUENCE [IF NOT EXISTS] s [INCREMENT [BY] n] [MINVALUE n | NO MINVALUE]
  [MAXVALUE n | NO MAXVALUE] [START [WITH] n] [CACHE n] [CYCLE | NO CYCLE]
  [OWNED BY t.c | OWNED BY NONE]
ALTER SEQUENCE [IF EXISTS] s [same options] [RESTART [WITH n]]  -- RESTART = setval(n, false)
DROP SEQUENCE [IF EXISTS] s [, ...]         -- refused while a column DEFAULT calls nextval(s)
SELECT nextval('s')  /  SELECT setval('s', n [, is_called])
CREATE TABLE t (PRIMARY KEY (id),
  id SERIAL | BIGSERIAL                          -- = GENERATED BY DEFAULT AS IDENTITY
  id GENERATED ALWAYS | BY DEFAULT AS IDENTITY [(seq options)], ...)
ALTER TABLE t ALTER [COLUMN] c ADD GENERATED ALWAYS | BY DEFAULT AS IDENTITY [(...)]
ALTER TABLE t ALTER [COLUMN] c DROP IDENTITY [IF EXISTS]
CREATE TABLE t (PRIMARY KEY (id), c GENERATED ALWAYS AS (expr) STORED [NOT NULL] [CHECK (e)], ...)
ALTER TABLE t ALTER [COLUMN] c ADD GENERATED ALWAYS AS (expr) STORED  -- rewrites every row
ALTER TABLE t ALTER [COLUMN] c SET EXPRESSION AS (expr)               -- rewrites every row
ALTER TABLE t ALTER [COLUMN] c DROP EXPRESSION [IF EXISTS]            -- values stay as data
INSERT INTO t (c, ...) [OVERRIDING SYSTEM VALUE] VALUES (expr | DEFAULT, ...)
  [ON CONFLICT ...] [RETURNING * | expr [AS a], ...]
--   Sequences: database-scoped counters, defaults as Postgres (increment
--   1, min 1 / max i64::MAX ascending — mirrored descending, start = min,
--   CACHE 1, NO CYCLE); values i64. nextval('s') / nextval('db.s') allowed
--   ONLY in a column DEFAULT, an INSERT's VALUES and a FROM-less SELECT;
--   setval only in a FROM-less SELECT; elsewhere `nextval() is allowed in a
--   DEFAULT, an INSERT's VALUES and a FROM-less SELECT`. Bound reached on
--   NO CYCLE: `nextval: reached maximum value of sequence "s" (n)`; CYCLE
--   wraps. A failed statement still consumes its values (never rolled
--   back). CACHE n leases n values per counter write, per node: unique
--   cluster-wide, dense per coordinator, interleaved across coordinators;
--   setval/RESTART only invalidate the running node's block. CACHE 1
--   (default): one counter round per value (local write / cluster CAS),
--   dense across the cluster. Counter = one row of the hidden per-db
--   table __seq__ (RF of the database); definitions travel with schema
--   sync (seq:<name> keys, tombstoned on DROP).
--   SERIAL / IDENTITY: creates <table>_<col>_seq OWNED BY t.c (dropped
--   with the table / DROP IDENTITY, keeps its name across RENAME) and
--   makes c NOT NULL with a nextval DEFAULT; a DEFAULT on a PK column is
--   applied before the key is computed, so INSERT INTO t (name) VALUES
--   ('x') keys the row. BY DEFAULT (and SERIAL): a supplied value is
--   taken, counter unchanged. ALWAYS: a supplied value is refused
--   (`column "c" of "t" can only be updated to DEFAULT: it is an identity
--   column defined as GENERATED ALWAYS`) unless OVERRIDING SYSTEM VALUE;
--   UPDATE SET c / ON CONFLICT DO UPDATE SET c always refused. No DEFAULT
--   together with an identity item. DEFAULT as a VALUES element = column
--   not named for that row. ADD GENERATED: every row must already have
--   the column, refused on a column with a DEFAULT or already identity.
--   RETURNING: the rows as they landed (defaults/ids applied; merged row
--   for DO UPDATE; DO NOTHING-skipped rows omitted) as a result set
--   instead of an affected count; `*` = every field in name order.
CREATE TYPE [IF NOT EXISTS] t AS ENUM ('a', ...)  /  ALTER TYPE t ADD VALUE [IF NOT EXISTS] 'v'
CREATE DOMAIN [IF NOT EXISTS] d [AS] [NOT NULL] [DEFAULT e] [CHECK (e over VALUE)]
DROP TYPE | DROP DOMAIN [IF EXISTS] n [, ...]      /  SHOW TYPES
CREATE TABLE p (PRIMARY KEY (id), m t, q d NOT NULL, ...)   -- <col> <type>: the bundle expands
--   No column types: an enum/domain is a NAMED CONSTRAINT BUNDLE. Enum →
--   column CHECK `<col>_<type>` = `col IN (...)`; domain → its NOT NULL /
--   DEFAULT / CHECK (VALUE → column; a column DEFAULT wins). ALTER TYPE ADD
--   VALUE rewrites every derived CHECK (by name + exact expr); DROP TYPE
--   refused while one exists; DROP DOMAIN leaves its constraints. Enum
--   values compare as strings. Db-scoped, schema-synced (typ: keys).
CREATE [OR REPLACE] VIEW [IF NOT EXISTS] v [(c1, ...)] AS <select>
DROP VIEW [IF EXISTS] v [, ...]
CREATE MATERIALIZED VIEW [IF NOT EXISTS] m [(c1, ...)] [WITH (refresh = '5m')]
  AS <select> [WITH [NO] DATA]
REFRESH MATERIALIZED VIEW m                 -- affected = new row count
DROP MATERIALIZED VIEW [IF EXISTS] m [, ...]
--   Views: stored SELECT text (any SELECT; no ? params, no nextval), in
--   the table namespace of its db, sees tables as they are NOW (schema-
--   less: new columns show through `*`), exposes exactly its projection
--   (`column "x" does not exist in view "v"`; a computed item has no
--   sub-fields). Simple body (no DISTINCT/GROUP BY/HAVING/set ops/LIMIT/
--   OFFSET/aggregates/windows/NEAREST) INLINES onto the base table (point
--   lookups, index scans, ORDER BY/LIMIT pushdown, FTS, NEAREST work
--   through it); otherwise (or as a JOIN right side) the body runs first
--   and the outer reads its rows, grouping-column WHERE conjuncts pushed
--   in. Views over views ok (cycle refused at CREATE, depth 16). Writes
--   through a view refused (`"v" is a view`); DROP TABLE / RENAME of a
--   referenced table refused (`view "v" depends on "t"`); DROP DATABASE
--   drops its views. RBAC: SELECT on the VIEW name; body = definer rights.
--   Materialized: result stored in hidden `__mv__m` (PK `__row` = content
--   hash + ordinal — unchanged rows keep keys, duplicates survive), placed
--   like the body's FROM table; reads are plain table reads of the
--   snapshot (empty before the first refresh / WITH NO DATA). REFRESH =
--   run body at session consistency, upsert rows, delete stale keys —
--   readers see old ∪ new meanwhile, never empty (no CONCURRENTLY
--   needed). refresh = '<interval>' (ms/s/m/h/d/w): maintenance tick
--   (60 s), cluster owner = ring owner of the name. Sync: CREATE OR
--   REPLACE VIEW / CREATE MATERIALIZED VIEW … WITH NO DATA in dependency
--   order (vw:/mv: keys, tombstoned). No updatable views, CHECK OPTION,
--   ALTER VIEW, indexes on a matview, CREATE TABLE AS.
WITH a [(cols)] AS (SELECT …) [, b AS (SELECT …)] SELECT …   -- CTEs (SELECT only)
--   Each CTE = a statement-scoped view (same inline/overlay expansion,
--   same projection rule), visible to the statement's subqueries and a
--   view body may use WITH; later CTEs may name earlier ones; a CTE
--   shadows a same-named table/view. No WITH RECURSIVE; a CTE named
--   twice runs twice (not a materialization fence).
<expr> [NOT] IN (SELECT …)  /  [NOT] EXISTS (SELECT …)  /  (SELECT …)  -- subqueries
--   Anywhere an expression goes (SELECT items/WHERE/HAVING/ORDER BY/GROUP
--   BY/ON, UPDATE SET/WHERE, DELETE WHERE, INSERT VALUES); nest freely;
--   may name a view. UNCORRELATED ONLY: an inner column qualified by an
--   OUTER alias is refused (`correlated subqueries are not supported:
--   "c.id" refers to the outer query`); no ANY/ALL. Each runs ONCE per
--   statement (session consistency, scan byte budget) and becomes a
--   literal: IN → IN (list) with NULLs dropped (NOT IN = plain list, no
--   three-valued trap), EXISTS → bool (inner LIMIT 1), scalar → value
--   (NULL when no row; >1 row / >1 column = error). The statement then
--   runs as an ordinary one (pushdown, index scans, keyset paging intact).
--   Not allowed in CHECK/DEFAULT/generated. RBAC: SELECT on every table
--   read (FROM, joins, set-op legs, subqueries) on the user's own grants.
--   GENERATED ALWAYS AS (expr) STORED: computed from the landing row
--   (after DEFAULTs / UPDATE assignments / the DO UPDATE merge) on EVERY
--   write path, overwriting the column; NULL result stored as NULL (NOT
--   NULL then refuses), evaluation error fails the write; RETURNING,
--   indexes and filters see a plain stored field. Explicit writes
--   refused: INSERT naming it (`cannot insert a non-DEFAULT value into
--   column "c" of "t": it is a generated column`; DEFAULT element ok,
--   OVERRIDING SYSTEM VALUE does not apply), UPDATE SET c / ON CONFLICT
--   DO UPDATE SET c (`column "c" of "t" can only be updated to DEFAULT:
--   it is a generated column`). expr reads other columns only: no
--   aggregates / windows / params / nextval / now() / excluded. / itself
--   / other generated columns (DDL-time). Not a PK / distribute_by /
--   cluster_by / partition column; no DEFAULT or identity with it; NOT
--   NULL + CHECK combine. ADD GENERATED / SET EXPRESSION rewrite every
--   row (ordinary UPDATE path, cluster-wide from the DDL coordinator; a
--   constraint failure fails the ALTER after the schema change); DROP
--   EXPRESSION keeps the values. RENAME COLUMN refused while a generated
--   expr references the column. Sync key gen:<table>/<col>. No VIRTUAL.
CREATE INDEX [IF NOT EXISTS] i ON t (path[[]] | (expr) [, ...]) [WHERE pred]   -- composite = leftmost-prefix
--   (expr): expression index (CHECK rules; planner matches the same expr text
--   in WHERE/ORDER BY); WHERE pred: partial — entries only for matching rows,
--   used only when the query's WHERE contains every conjunct of pred; a
--   partial UNIQUE index constrains only the rows it covers.
--   a `path[]` component makes the index MULTIKEY: one entry per array
--   element, so `col = 'x'` containment is an index probe (exact counts);
--   planner requires equality through the [] column; max one [] per index
--   append WITH (global = true) for a value-sharded GLOBAL index: a
--   full-tuple equality probe routes to the value's replica set (one
--   round-trip, no cluster scatter — the RF<members win). Ranges and
--   partial prefixes fall back to scatter. Backfill runs in the
--   background after DDL (probes route once it completes); local
--   indexes remain the default. See docs/GLOBAL_INDEXES.md.
DROP INDEX [IF EXISTS] i
CREATE VECTOR INDEX [IF NOT EXISTS] v ON t (path) DIM n [USING cosine|l2|dot] [QUANTIZED] [EMBED]
-- QUANTIZED: int8 scalar-quantized in-RAM graph (4x less vector RAM);
-- queries over-fetch 4x + RESCORE top-k against exact row vectors, so
-- _distance stays exact. Build-time choice (rebuild to change); not with
-- EMBED (no exact vector in the row). Snapshot magic SKHNSW02.
DROP VECTOR INDEX [IF EXISTS] v
ALTER VECTOR INDEX v SET (ef = n)           -- live recall/latency tuning (persisted);
--   build-time knobs (m, ef_construction, dim, metric) need a rebuild
CREATE STREAM [IF NOT EXISTS] s ON t WHEN (<predicate>)
       [WITH (start = 'now', retention = '24h')]
--   Standing filter over t's writes. Each matching INSERT/UPDATE appends to
--   `_stream_<name>`, an ordinary table in the same db: (id, op, k, ts, doc)
--   where id is the position, k the row's PRIMARY KEY value and doc the row.
--   Replay = ordered read: SELECT * FROM _stream_s WHERE id > '<last>'
--   ORDER BY id LIMIT n. retention is that table's TTL; DROP STREAM drops it.
--   The log is READ-ONLY: INSERT/UPDATE/DELETE and DROP TABLE on it are
--   refused (a forged row would be indistinguishable from a captured event);
--   use DROP STREAM. CREATE STREAM refuses if a table of the log's name
--   already exists rather than adopting it. A log that cannot be written
--   never fails the source write — the event is counted lost by
--   skaidb_stream_events_lost_total.
--   With [mqtt] enabled each event is ALSO published live as JSON to
--   $stream/<db>/<name> (QoS 0, best-effort: the log is authoritative —
--   resume by replaying from the last id). Emission covers the cluster
--   paths; every replica emits locally and all mint the same event id, so
--   the log converges instead of duplicating.
--   WITH (pre_image = true) adds an `old` column carrying the row as it was
--   BEFORE the change (NULL for an insert). OPT-IN and default false: a
--   stream already doubles the write volume of what it matches, and carrying
--   the pre-image makes each event two documents instead of one — charging
--   that to existing streams would be a silent regression. A trigger's own
--   stream sets it automatically.
--   op is "put" (matches now), "exit" (matched before, no longer does) or
--   "delete" (emitted when the PRE-IMAGE matched). start = 'now' | 'earliest' (earliest backfills existing rows
--   inline). SHOW STREAMS reports live consumers/lag. Guarantees:
--   at-least-once, per-key order only; a write that lost LWW never appears;
--   repair cannot replay pre-creation history. The tail position survives a
--   restart (skips ahead if >10k behind). Costs a second replicated row per
--   match — filter narrowly. Full detail: docs/STREAMS.md
--   WHEN is a normal boolean expression (evaluated like WHERE) and REQUIRED.
--   retention takes a duration ('24h', '90m', 30d); start takes only 'now'
--   (backfill unimplemented — any other value is refused, not ignored).
DROP STREAM [IF EXISTS] s
CREATE PROCEDURE [IF NOT EXISTS] p(a TEXT, n INT) BEGIN <step>; <step>; END
--   Server-side statement list stored in the catalog and run by CALL, so N
--   round trips become one. Types: TEXT|INT|FLOAT|BOOL|TIMESTAMP|JSON|ARRAY
--   |ANY (a CALL-time contract only — the store stays schema-less; NULL fits
--   any type, INT widens to FLOAT, ANY opts out). Body takes
--   SELECT/INSERT/UPDATE/DELETE and nested CALL: no DDL, no BEGIN/COMMIT, no
--   dynamic SQL (a string has no knowable footprint). Every statement ends
--   with ';', including the last. Nesting is capped at 16 deep — a cycle
--   cannot be refused at creation (either half can be recreated), so the cap
--   aborts it with an UNCATCHABLE resource-limit error.
--   Unqualified names in the body bind to the CREATING session's database,
--   not the caller's, and are stored qualified.
--   A parameter SHADOWS a same-named unqualified column, so `WHERE id = id`
--   would substitute both sides; that collision is REFUSED at CREATE when
--   the column is declared (primary key, series key, indexed path). Rename
--   the parameter or qualify the column (`orders.id` never substitutes).
--   Control flow (all inside a body only):
--     DECLARE v <TYPE> [DEFAULT <expr>]      -- block-scoped; NULL by default
--     DECLARE c CURSOR FOR <select>
--     SET v = <expr>                         -- v must be declared (else error)
--     SELECT <items> INTO v1, v2 FROM ...    -- first row, positional; sets FOUND
--     IF <c> THEN ... [ELSEIF <c> THEN ...] [ELSE ...] END IF
--     WHILE <c> DO ... END WHILE             -- LEAVE exits the innermost loop
--     FOR v IN (<select>) DO ... END FOR     -- v is a row document: v.col
--     OPEN c; FETCH c INTO v1[, v2]; CLOSE c -- FETCH sets FOUND
--     RETURN [<expr> | {k: <expr>, ...}]
--     BEGIN ... [EXCEPTION WHEN <cond>[, <cond>] THEN ...] END
--   Only TRUE takes a branch (NULL behaves as false, like WHERE). Variables
--   substitute exactly as parameters do, so the same shadowing rule applies.
--   RETURN {..} builds fields from EXPRESSIONS (a plain {..} literal is
--   constant-only); it yields ONE row, one column per field. RETURN <expr>
--   yields one column named `result`. No RETURN = the last statement's rows.
--   No iteration cap: WHILE is bounded by the CALLER's scan budget/deadline,
--   which is the only termination guarantee.
--   EXCEPTION conditions: NOT_FOUND | CONSTRAINT | UNIQUE_VIOLATION |
--   FOREIGN_KEY_VIOLATION | NOT_NULL_VIOLATION | CHECK_VIOLATION |
--   TYPE_ERROR | UNSUPPORTED | OTHERS; `error_kind`/`error_message` are bound
--   inside a handler. NEVER catchable: resource limits (scan budget,
--   statement deadline, KILL QUERY, call depth) — a body that could catch its
--   own timeout could loop forever — and storage/IO/CLUSTER errors, because
--   swallowing "peer unreachable" turns it into "there was nothing there".
--   CURSOR = resumable keyset scan over the PK, NOT a snapshot: it sees
--   concurrent writes. Query must be a plain SELECT of columns from one table
--   with an optional WHERE and a single-column PK (anything else refused).
--   Resumes at ANY consistency/placement: a full local copy at ONE walks
--   its own shard; every other cluster route pages the LWW-merged
--   distributed read seeded strictly after the cursor. FOR..IN
--   materializes instead (byte-budget bounded); use a cursor for sweeps
--   larger than that.
DROP PROCEDURE [IF EXISTS] p
CALL p('x', 3)                              -- returns the LAST statement's
--   result set (intermediate ones are discarded); `CALL p(?, ?)` prepares,
--   so drivers bind arguments. Arguments must be constant (a call site has
--   no row): a column reference is refused, not read as NULL. Errors name
--   the failing site — "procedure p, statement 2: ...".
--   Runs at ONE coordinator and replicates its EFFECTS through the ordinary
--   write path; it is NOT a transaction (may run inside BEGIN ATOMIC
--   PARTITION, where its writes buffer and commit with the rest — checked
--   against every table the body COULD write, transitively, before any of it
--   runs, so a refused CALL buffers nothing).
--   The body inherits the CALLER's scan budget and deadline — one ceiling
--   for the whole call, not one per statement.
SHOW PROCEDURES                             -- name, params, definer, created
--   (never the body: it names tables the lister may not read)
CREATE JOB [IF NOT EXISTS] j ON SCHEDULE {EVERY '<dur>' | CRON '<expr>'}
       CALL p(<args>) [WITH (opts)]
--   EVERY = duration since the last tick (drifts vs the calendar); CRON =
--   calendar instants (the only way to say "3am daily"). Both reduce to one
--   stored next-run instant. Cron = 5 fields (min hour dom mon dow), with
--   * a a-b a,b */n a-b/n and jan../mon.. names; Sunday = 0 or 7; NO seconds
--   field (use EVERY). When BOTH dom and dow are restricted a day matching
--   EITHER fires (classic cron rule). A schedule that can never fire
--   (0 0 30 2 *) is REFUSED at CREATE, not silently never run.
--   opts: timezone (UTC default; ONLY 'UTC' or fixed '+HH:MM'/'-HH:MM' —
--   named zones are REFUSED, no tz database is embedded, so DST ambiguity
--   cannot arise; CRON only), catchup (false), timeout ('5m'), budget
--   (1000000 rows), ttl ('7d' history), result_cap (4096 bytes).
--   timeout+budget are NOT tuning: a CALL inherits the caller's budget and
--   deadline, a scheduled run has no caller, and an UNSET budget means
--   UNBOUNDED — so a job always runs with both installed.
--   A job runs as its recorded DEFINER (a schedule has no invoker). The
--   SERVER stamps it from the authenticated role, so a role can only create
--   a job that runs as ITSELF — the escalation surface of the feature.
--   CREATE JOB needs everything a CALL of the procedure would, and the
--   definer's rights are RE-CHECKED before every run (a later REVOKE stops
--   the job; the refused run is recorded as a failure with the reason).
--   OWNERSHIP: preferred owner = ring primary for the job name, made correct
--   by a linearizable CAS lease on _job_state (expires, so a dead owner is
--   replaced with no operator action). At-least-once: an owner can run and
--   die before recording. Scheduler ticks every 5s, so a finer schedule
--   fires at the TICK rate and records the skipped ticks. Under memory/disk
--   pressure a tick is DEFERRED: it did not run and is retried, not recorded
--   complete (skaidb_jobs_deferred_total — invisible in the job's own state).
--   Missed windows SKIP to the present by default (recorded), catchup = true
--   runs the oldest instead. Failures are recorded + counted + backed off
--   exponentially (capped 1h). History is best-effort; the LEASE recovers a
--   job, not the history.
--   In a job body (and in the job's CALL args), four zero-arg calls:
--     SCHEDULED_AT()    -- the LOGICAL tick, identical across attempts; key
--                       -- idempotent writes on it (INSERT .. ON CONFLICT)
--     CURRENT_RUN()     -- this attempt's id (NOT an idempotency key)
--     LAST_SUCCESS()    -- finished_at of the last ok run, else NULL
--     PREVIOUS_RESULT() -- that run's RETURN document
--   RETURN doc -> _job_runs.result, capped by result_cap and FLAGGED when it
--   does not fit ({truncated: true}) rather than silently cut; it is also the
--   next run's PREVIOUS_RESULT(), which is how an incremental job carries a
--   watermark without its own table.
--   Metrics: skaidb_jobs_runs_total / _failures_total / _deferred_total.
--   _job_runs also records what the run DID: effects[] = one entry per
--   statement SITE (position in the code), NOT per execution — a WHILE loop
--   running 10k statements is ONE entry {stmt, executions, rows, ms}, so a
--   record is bounded by the LENGTH OF THE CODE, not the data volume. Capped
--   at the heaviest 20 sites + one "+rest" rollup carrying the totals (it
--   says so; a silent truncation would read as "the body only did this").
--   tables_written = what it ACTUALLY changed (vs the static footprint =
--   what it could). A trigger's whole batch shares one record and the
--   effects accumulate across it.
--   Jobs and triggers run on standalone servers too (one node always owns
--   the lease; no ring to pick from).
CREATE TRIGGER [IF NOT EXISTS] t ON <table> WHEN (<predicate>) CALL p(<args>)
       [WITH (opts)]
--   A JOB whose source is a change stream instead of a schedule — ONE
--   concept, so ownership/lease/history/failure handling are identical.
--   Creates and OWNS a hidden stream `__trg_<name>` (pre_image = true) plus
--   the job; DROP TRIGGER removes both (leaving the stream would keep
--   doubling the source table's writes for nobody).
--   Body sees NEW (row now), OLD (row before; NULL on insert — test
--   OLD IS NULL), EVENT() (the id: the identity to key idempotent writes
--   on) and OP (put|exit|delete). Both NEW and OLD come from the LOG, not a
--   re-read. SCHEDULED_AT() is NULL in a trigger (no tick).
--   Fires from the LOG, never inline on the write path: user code in the
--   applier is a deadlock class, capture must never fail the write, and only
--   background work can be shed. Inherits the stream's guarantees (predicate
--   re-checked per committed write, LWW losers never fire, repair cannot
--   replay ancient history).
--   ONE run record per BATCH, never per event (a record per fired trigger
--   re-doubles write volume on a hot table). A poison event is retried
--   (3x) then SKIPPED, counted and recorded — a cursor that never moves is a
--   trigger that stopped silently. Cost: stream doubles matched write volume,
--   pre-images double it again; SHOW TRIGGERS names the stream so you can
--   measure it.
--   CASCADE CYCLES ARE REFUSED at CREATE (static walk of each trigger's
--   procedure write-footprint across the trigger graph). A cascade without a
--   cycle is a DAG and terminates, so this bounds what a runtime depth cap
--   would — deterministically, since a per-event depth counter would be
--   whichever replica's copy won LWW.
--   opts: retention (stream's), timeout, budget, ttl, result_cap.
--   timezone/catchup are REFUSED (a trigger has no calendar, and its cursor
--   is already where it left off).
DROP TRIGGER [IF EXISTS] t                  -- removes the job AND its stream
SHOW TRIGGERS  -- name, table, predicate, procedure, definer, stream, cursor,
--   last_status, last_error, failures
DROP JOB [IF EXISTS] j
SHOW JOBS  -- name, source, procedure, definer, owner, next_run, last_run,
--   last_status, last_error, failures
--   Tables (ordinary, replicated, created with the first job in a db):
--   _job_state (PK job) = owner/lease/next+last run/status/error/failures;
--   _job_runs (PK id, TTL'd per job) = one row per RUN. Both FEATURE-OWNED:
--   readable by clients, and INSERT/UPDATE/DELETE/DROP TABLE refused (a
--   forged row is indistinguishable from a real one; dropping _job_state
--   takes the scheduler down). CREATE STREAM on either is refused — it
--   would capture its own writes. DROP JOB keeps the history.
GRANT EXECUTE ON PROCEDURE p TO r           -- also ON DATABASE d / ON *
--   CALL runs with INVOKER rights: EXECUTE is only the gate. The caller
--   must ALSO hold every privilege the body needs — the UNION over the whole
--   call graph, walked at call time (not snapshotted), so redefining a
--   callee changes its callers' requirements immediately. Jobs/triggers (phases 3-4) instead
--   run as the recorded definer, which the SERVER stamps from the
--   authenticated role — a client's DEFINER clause is discarded.
--   Full detail: docs/PROCEDURES.md
CREATE SEARCH INDEX [IF NOT EXISTS] s ON t (path [, ...]) [WITH (opts)]
DROP SEARCH INDEX [IF EXISTS] s
REBUILD SEARCH INDEX s
ALTER SEARCH INDEX s SET (opts)             -- query-time opts only, live
CREATE TIMESERIES TABLE [IF NOT EXISTS] t
       (SERIES KEY (label [, ...]) [, RETENTION dur] [, OOO dur])
ALTER TABLE ts_table SET (retention = dur | ooo = dur)  -- live-tunable; 0 clears retention
CREATE ROLLUP [IF NOT EXISTS] r ON ts_table BUCKET dur [RETENTION dur]
CREATE ROLLUP [IF NOT EXISTS] r ON event_table BUCKET dur [BY (dim, ...)]
       AGGREGATE (count(*)|count(c)|sum(c)|min(c)|max(c) AS alias [, ...])
       [REFRESH dur] [RETENTION dur]   -- event-table form, see below

-- DML (UPDATE/DELETE rejected on time-series tables — append-only)
INSERT INTO t (col, ...) VALUES (expr, ...) [, (...), ...]
UPDATE t SET path = expr [, ...] [WHERE expr]
DELETE FROM t [WHERE expr]

-- Query
SELECT [DISTINCT] item [, ...] FROM t [[AS] a]
  [JOIN ...] [NEAREST (path, [vector], k)] [WHERE expr]
--   NEAREST: kNN over a vector index. MANAGED index (CREATE VECTOR INDEX …
--   ON t(text_col) EMBED DIM n) embeds the text column via [inference] at
--   ingest (out of band, never blocks a write) and auto-embeds a STRING query:
--   NEAREST(text_col, 'natural language', k). [inference] keys can also be
--   set via SKAIDB_INFERENCE_<KEY> env vars (override the config file at
--   startup, type-checked). See docs/VECTOR.md.
  [RANK BY RRF [(c)]]
--   RANK BY RRF: HYBRID search — fuse the NEAREST (vector) leg and the WHERE
--   search-predicate (text) leg by Reciprocal Rank Fusion (ES `rrf` retriever).
--   rrf_score() = sum 1/(c+rank) over both legs (c default 60); residual WHERE
--   filters both legs; ordered rrf_score() desc. Needs a NEAREST + a search
--   predicate; cluster-wide.
  [RERANK [ON col] [WITH 'model'] [QUERY 'text'] [TOP n]]
--   RERANK: second-stage CROSS-ENCODER reranking (ES text_similarity_reranker).
--   Top n candidates (default 100, cap 1000) of the search/hybrid retrieval
--   are re-scored by the external [inference] rerank_url endpoint
--   (Cohere/Jina/TEI wire) and served in the reranker's order; score() reads
--   the rerank score (rrf_score() keeps the fusion score on hybrid). Defaults:
--   ON = the searched columns, WITH = inference.rerank_model, QUERY = the
--   search text. Needs a WHERE search predicate; no ORDER BY/GROUP BY.
--   Coordinator-side, opt-in per query — endpoint down fails only RERANK
--   queries. See docs/SEARCH.md, docs/VECTOR.md.
  [GROUP BY expr [, ...] [TOP k BY expr [ASC|DESC]]] [HAVING expr]
--   GROUP BY ... TOP k BY e: per-group top-k ROWS (not aggregates); with
--   MATCH + TOP k BY score() it is ES top_hits in SQL
  [{UNION | INTERSECT | EXCEPT} [ALL] SELECT ...]   -- left to right; ALL = multiset
--   SELECT DISTINCT ON (e, ...) items ... ORDER BY e, ..., more: first row per
--   group in that order (ORDER BY must start with the ON exprs; LIMIT/OFFSET
--   after the pick; no GROUP BY/aggregates — use GROUP BY … TOP 1 BY …).
  [ORDER BY expr [ASC|DESC] [, ...]] [LIMIT n] [OFFSET n]
--   Output aliases resolve in ORDER BY/GROUP BY (bare names) and HAVING
--   (anywhere in the predicate): count(*) AS c ... HAVING c > 1 ORDER BY c.
--   Alias shadowing a source column: ORDER BY prefers the output column,
--   GROUP BY/HAVING the source column.
  [AFTER (last_sort_value, last_pk_value)]
--   AFTER: DEEP PAGINATION keyset cursor (ES search_after). Search queries,
--   ordered by score() DESC or one column + LIMIT; pk = implicit ASC
--   tie-break (single-column pk required; every sorted search page is
--   (sort value, pk)-deterministic). STABLE under concurrent writes (no
--   shifted/duplicated pages); per-page cost ≈ the OFFSET equivalent
--   (doubling ranked fetch, cap 65,536). No OFFSET/GROUP BY/RRF/RERANK.
--   PLAIN (non-search) selects: `AFTER (<pk-literal>)` with bare-column
--   items + ORDER BY the single-column pk ASC + LIMIT resumes the pk walk
--   BY STORED KEY POSITION (not an SQL compare — mixed-type keys page
--   exactly once where `WHERE pk > last` silently drops cross-type rows);
--   cluster: ANY consistency/placement — a full local copy at ONE walks
--   its own shard, every other route (QUORUM default, sharded, pinned)
--   serves each page as a bounded LWW-merged distributed read seeded
--   strictly after the cursor, page-level read quorum enforced. Each
--   page is its own read (between-pages writes land or miss, ordinary
--   keyset semantics).
--   This page is the unit the binary protocol's query_stream lane AND
--   REST /query use: an eligible unbounded SELECT streams server-side
--   page by page (1024 rows/page; /query emits them as chunked JSON), so
--   the node holds ONE PAGE, not the result — dump-sized reads no longer
--   accumulate a result-sized Vec (and no engine lock is held across
--   socket writes). A lone SELECT * is eligible: its columns resolve UP
--   FRONT via one retain-free discovery scan over the matching rows (the
--   sorted field union — the same columns the one-shot reports), absent
--   fields pad NULL; a field first appearing mid-stream is not among that
--   stream's columns; the WILDCARD form is the one shape still needing
--   consistency ONE on a full-copy node (its column discovery reads the
--   local shard). Ineligible shapes (ORDER BY on non-pk, aggregates,
--   expressions, functions/params in the filter, composite pk, TS tables)
--   keep the classic materialize-then-chunk path under scan_byte_budget.
--   Filter-only queries on other columns: use WHERE col > last
--   instead. No PIT.
-- joins: [INNER|LEFT [OUTER]|RIGHT [OUTER]|FULL [OUTER]|CROSS] JOIN t [AS a] [ON expr]
--   FULL = every left row (null right when unmatched) + unmatched right
--   rows (left sides nulled).
--   equi-joins hash-join; qualify columns by alias (u.id). No cluster
--   join pushdown: joins pull both tables to the coordinator — fine for
--   lookups, wrong for large fact-to-fact joins.

-- Search-specific statements
SUGGEST 'text' ON index [COLUMN col] [LIMIT n]   -- "did you mean" terms
EXPLAIN SCORE <select> FOR <pk-literal>          -- per-row BM25 breakdown
EXPLAIN <statement>                              -- plan inspection: access path,
--   pushdown/fallback decisions, cluster fan-out — advisory, never executes.
--   `access` is the LOCAL plan; `cluster.access` is what the coordinator
--   actually runs. On a cluster, believe `cluster.access`.

-- Databases (namespaces; `default` always exists and cannot be dropped)
CREATE DATABASE [IF NOT EXISTS] d
DROP DATABASE [IF EXISTS] d
USE d                                            -- session-state (binary protocol)
SHOW DATABASES

-- Transactions — embedded engine + DRIVER connections to a STANDALONE
-- server (per-connection state; stateless REST/ES/UI autocommit; cluster
-- refuses — no distributed coordinator). Session-scoped: one session's
-- uncommitted writes are invisible to others; concurrent txns coexist;
-- commits resolve LWW. Crash-atomic COMMIT: a redo journal is durable
-- before any row applies, and recovery replays interrupted commits to
-- completion (acid-crash harness: 0 violations, partial txns impossible).
-- Privilege: INSERT on the SESSION'S CURRENT DATABASE (a grant ON * also
-- satisfies it) — same gate for BEGIN ATOMIC PARTITION. It only decides
-- whether a session may open a txn; statements inside are each authorized
-- against their own table, so a db-scoped app needs no cluster-wide grant.
BEGIN | COMMIT | ROLLBACK

-- Users, roles, grants
CREATE USER [IF NOT EXISTS] u PASSWORD 'pw'
CREATE USER [IF NOT EXISTS] "user@REALM" GSSAPI   -- external Kerberos user,
                                                  -- no local password; the KDC
                                                  -- vouches, skaidb maps the
                                                  -- principal to its own role
ALTER USER u PASSWORD 'pw'
DROP USER [IF EXISTS] u
CREATE ROLE [IF NOT EXISTS] r  |  DROP ROLE [IF EXISTS] r
GRANT  privilege ON { table | DATABASE db | * } TO r
REVOKE privilege ON { table | DATABASE db | * } FROM r
GRANT ROLE r TO u  |  REVOKE ROLE r FROM u
SHOW GRANTS [FOR r]

-- Introspection (no privilege needed; names only, no data)
SHOW TABLES        -- (table, primary_key, replication, nodes, witness, transition)
SHOW CREATE TABLE <t> -- replayable DDL: CREATE with all options (column
                   -- items + valid CHECKs inline) + one row per index
                   -- + one ALTER TABLE … ADD CONSTRAINT per foreign key / NOT VALID CHECK
                   -- (auto-created FK indexes are omitted: the ALTER remakes them)
SHOW INDEXES       -- (index, table, kind, columns, local) — local is THIS
                   -- node's live state: ok / building / missing
SHOW CONSTRAINTS [ON t] -- (constraint, table, type, columns, valid,
                   -- definition); type NOT NULL | DEFAULT | IDENTITY |
                   -- GENERATED | CHECK | FOREIGN KEY; constraint/columns NULL
                   -- where n/a; definition = `c NOT NULL` | `c DEFAULT e` |
                   -- `c GENERATED … AS IDENTITY [(opts)]` (one row per identity
                   -- column, no separate NOT NULL/DEFAULT rows) | `c GENERATED
                   -- ALWAYS AS (e) STORED` (its NOT NULL is a separate row) |
                   -- `CHECK (e)` | FK clause
SHOW SEQUENCES     -- (sequence, increment, min_value, max_value, start, cache,
                   -- cycle, owned_by "t.c" | NULL, last_value); last_value =
                   -- highest value leased (NULL before the first nextval),
                   -- QUORUM read on a cluster. Owned sequences included.
SHOW VIEWS         -- (view, kind view|matview, refresh, last_refresh, rows,
                   -- definition "[(cols)] AS <select>") in dependency order;
                   -- rows = backing-table count (cluster-wide on a cluster).
                   -- SHOW TABLES lists views with kind view|matview, no key;
                   -- SHOW CREATE TABLE <view> prints the CREATE statement.
SHOW FOREIGN KEYS [ON t] -- (constraint, table, columns, references,
                   -- referenced_columns, on_delete, on_update, index, valid,
                   -- definition); ON t = declared on OR referencing t
SHOW STREAMS       -- (name, table, predicate, consumers, lag). consumers/lag
                   -- are 0: streams are DECLARATION-ONLY today, nothing emits
DESCRIBE t         -- (column, key, indexes): one row per PK/indexed column of
DESC t             -- table t (DESC is an alias). Catalog-only, no privilege.
DESCRIBE t FULL [SAMPLE n | EXACT]
                   -- also reads rows to surface EVERY field with its type:
                   -- (column, type, key, indexes). SAMPLE n = first n rows in
                   -- PK order (default 1000). EXACT = scan all rows, cached in
                   -- a RAM field registry keyed by the table's write stamp:
                   -- repeats are O(fields) until the table changes; always
                   -- exact (TTL tables: never cached). Reads data -> needs
                   -- SELECT; local shard on a cluster (complete when RF >=
                   -- members).
SHOW STATUS        -- (metric, value): disk/memtable(+max)/wal/cache/flushes/compactions,
                   -- per-table table.<db>.<table>.* (row: live_keys/tombstones/
                   -- disk_bytes; TS: kind/series/samples_appended/disk_bytes),
                   -- per-index search.<name>.*
SHOW DATABASES

-- Admin statements (SQL spellings of the HTTP admin surface; reads need
-- MONITOR on *, mutations need ADMIN on * (ADMIN implies MONITOR); share
-- its RBAC + audit path — a SQL-only client is fully self-sufficient)
SHOW CLUSTER                        -- ring/peers/liveness as (key, value) rows
SHOW CONFIG [LIKE 'pat%']           -- full config flattened to dotted keys, secrets masked
SET CONFIG section.key = literal    -- live-mutable keys apply instantly
SHOW SLOW QUERIES [LIMIT n]         -- slow-query log (masked SQL)
SHOW QUERIES                        -- statements running NOW on this node: id, user,
                                    -- db, via, elapsed_ms, rows_examined (live, from
                                    -- the scan meter), sql (truncated). MONITOR.
SHOW MAINTENANCE                    -- background jobs running NOW on this node: id,
                                    -- kind, target, elapsed_ms, progress. Covers
                                    -- anti-entropy passes, content-digest builds,
                                    -- index/vector/geo backfills, search catch-ups,
                                    -- hint drains, flush builds, compactions,
                                    -- embed drains. MONITOR.
KILL QUERY <id>                     -- cooperative terminate by SHOW QUERIES id (ADMIN):
                                    -- the statement errors "terminated by KILL QUERY"
                                    -- at its next scan tick; a statement that never
                                    -- ticks (pure point write) finishes normally.
                                    -- Registration is per-node — run on the node the
                                    -- client is connected to.
                                    -- DISCONNECT CANCELLATION (OPT-IN, default OFF —
                                    -- SET CONFIG iwm.disconnect_cancellation = 'true',
                                    -- live-mutable): the same cooperative kill fires
                                    -- automatically ("terminated by client
                                    -- disconnect") when a DRIVER client closes its
                                    -- connection while its statement runs — within
                                    -- ~2-3 s, instead of the statement running on to
                                    -- scan_row_budget/statement_timeout_secs with
                                    -- nobody listening. The `internal` lane is
                                    -- exempt (a connection's own teardown work runs
                                    -- there, on that connection's thread, after its
                                    -- client has legitimately gone). Counted by
                                    -- skaidb_queries_disconnect_kills_total. REST is
                                    -- deliberately excluded: an HTTP client may
                                    -- legitimately half-close after sending its
                                    -- request, so there end-of-stream does not mean
                                    -- "gone" and the timeout ceiling still governs.
RESET OOM COUNTER [ON '<node>']     -- zero the oom_kills counter (node_stats/host
                                    -- stats) on every member AND registered witness,
                                    -- or one node by member id / witness id (ADMIN).
                                    -- The kernel cgroup count is read-only, so this
                                    -- persists a per-node baseline that is subtracted
                                    -- from it; the count survives service restarts
                                    -- and self-heals across container reboots.
                                    -- Returns one row per targeted node:
                                    -- (node, absorbed, status). Witnesses are outside
                                    -- the ring (pull-based), so their reset is
                                    -- STAMPED on the witnesses registry row and
                                    -- applied on the next pull cycle, exactly once —
                                    -- their row reports absorbed = NULL, status
                                    -- "stamped".
REPAIR CLUSTER                      -- anti-entropy pass, full sweep: also
                                    -- re-verifies hot tables the timer's
                                    -- passes defer (see hot-table backoff)
RECLAIM                             -- drop keys/series this node no longer owns
ALTER CLUSTER ADD NODE 'host:7100'  -- online resharding
ALTER CLUSTER REMOVE NODE 'id'

-- Backup / restore (paths are server-side, on the answering node)
BACKUP TO 'path'      -- crash-consistent copy of this node's data dir
BACKUP CLUSTER TO 'path'  -- all members + one cut instant (CLUSTER_CUT)
                      -- (per-shard on a cluster); refuses to overwrite
RESTORE FROM 'path'   -- embedded/standalone only — on a cluster stop the
                      -- node, restore its dir offline, let repair converge
RESTORE FROM 'path' TO TIMESTAMP '2026-08-19T14:06:00Z'
                      -- point-in-time recovery: restore the backup, then
                      -- replay archived WAL up to that instant (undo a bad
                      -- DELETE without losing work done since the backup).
                      -- ISO-8601 or epoch ms; needs storage.wal_archive_dir

-- Session consistency (binary protocol only; overrides the per-request
-- value until changed. REST is stateless and rejects it with guidance.)
SET CONSISTENCY ONE | QUORUM | ALL
SET SCAN BUDGET ROWS n [BYTES n] | DEFAULT
                      -- session-scoped, TIGHTENING-only (min with node config);
                      -- any role may issue; reverts on silent driver reconnect
```

**RBAC**: privileges are `SELECT INSERT UPDATE DELETE CREATE DROP GRANT
MONITOR ADMIN`. `ADMIN ON *` = superuser; a database grant covers its
tables; a user acts as its own-named role and inherits granted roles.
A user created `… GSSAPI` is external (Kerberos): no local password, the KDC
vouches for the principal and skaidb maps it to the same own-named role;
external users can't authenticate by SCRAM and password users can't be
reached through the external path.
Table grants are matched by the table's **canonical `db.table` identity**,
not the raw name: `GRANT ON t` in session db `d` means `d.t` (never a
cross-database wildcard), and `GRANT ON d.t` authorizes the natural
`USE d; ... t` query — both spellings resolve to the same table.
Management statements need `GRANT`; `SHOW GRANTS FOR <own role>` is always
allowed. `MONITOR ON *` = read-only control plane (SHOW CLUSTER/CONFIG/
SLOW QUERIES + read-only admin HTTP), never mutations. **Index DDL is
table-scoped**: CREATE/DROP/REBUILD/ALTER of an index need `CREATE` on the
owning table — a role that creates its indexes can drop them.
`remote_write` needs `INSERT` and `/api/v1/query*` need `SELECT` on the
SCOPED table (default: `metrics` in the default db; a `/db/<db>[/table/<t>]`
path prefix moves both the target and the check — a db-scoped account can
serve Grafana from its own data). Mutating admin HTTP endpoints need
`ADMIN` on `*`.

**Indexing**: predicates on indexed columns (`=`, ranges, AND-combined) and
matching `ORDER BY` accelerate; everything else scans with identical
results. The primary key routes in a cluster: `WHERE pk = v` is a point
read to the key's replica set. QUORUM `ORDER BY <indexed> LIMIT k`
(k ≤ 10000) is a **distributed sorted top-k**: each member contributes its
local index-ordered candidates, the bounded union is quorum re-read and
re-sorted — reads ~members × 4k rows, not the match set. Multi-key orders
ride the leading-column windows under a completeness proof (≥ k rows must
sort strictly before every truncated window's weakest leading value);
proof failure — e.g. a low-cardinality leading column — falls back to the
exact gather.

---

## 4. Full-text search

```sql
CREATE SEARCH INDEX articles_fts ON articles (title, body, year, published)
  WITH (analyzer = 'english', refresh_ms = 1000,
        title.boost = 2.0, title.keyword = true,
        title.copy_to = 'everything', body.copy_to = 'everything',
        year.type = 'long', published.type = 'bool');

SELECT id, title, score(), HIGHLIGHT(body, 120) AS snippet FROM articles
WHERE MATCH(body, 'quick brown fox') AND published = true
ORDER BY score() DESC LIMIT 10;
```

**Index options** — global: `analyzer` (default `'standard'` = UAX§29 +
lowercase; `'folding'`, `'whitespace'`, `'keyword'`, `'ngram(min,max)'`,
`'edge_ngram(min,max)'`, languages `'english'`, `'german'`, … with
stopwords+stemming; or a CUSTOM PIPELINE `'<tokenizer> | <filter> | …'` —
tokenizers unicode/whitespace/keyword/ngram/edge_ngram/regex(pat), filters
lowercase/ascii_folding/alphanum_only/remove_long(n)/stop(lang)/
stopwords(w1,w2)/stem(lang); NO char filters — they'd skew highlight
offsets), `refresh_ms` (NRT visibility, default 1000; `0` =
commit every write), `synonyms` (`'quick,fast; new york,nyc'` — multi-word
entries match as phrases, both directions, hot-reloadable via `ALTER`).
Per-column: `<col>.type` (`text` default, `keyword`, `long`, `double`,
`bool`, `date` — typed columns become fast fields queryable in `SEARCH()`
ranges and usable for sorting/facets), `<col>.analyzer`,
`<col>.search_analyzer`, `<col>.boost`, `<col>.keyword = true` (adds an
exact-match `.keyword` twin), `<col>.copy_to` ("search everything" field).

**Predicates** (compose with AND/OR/NOT among themselves; ordinary
conditions join at top level with AND and filter afterward; mixing under
OR/NOT is rejected):
- Analyzed: `MATCH(col,'text')`, `MATCH_PHRASE(col,'text'[,slop])`,
  `FUZZY(col,'text'[,dist])` (≤2), `SEARCH('query-string')` (mini-language:
  `term "phrase" col:term +must -must_not AND OR year:[2020 TO 2024]`),
  `MATCH_CROSS(col, col, ..., 'text')` (term-centric multi-field,
  ES cross_fields), `MATCH_BEST(col, col, ..., 'text')` (field-centric
  dis-max over an explicit column subset, ES best_fields — a row scores
  as its best single field).
- Pattern (NOT analyzed — lowercase them under lowercasing analyzers):
  `MATCH_PREFIX(col,'pre')`, `WILDCARD(col,'qu*ck')`, `REGEXP(col,'...')`.
- `MORE_LIKE_THIS(col, 'like text')` — similar rows.
- `BOOSTED(required, optional, ...)` — `required` decides matches, each
  `optional` only raises scores (ES must+should).
- `score()` projects BM25 (also injected as `_score`); `ORDER BY score()
  DESC LIMIT k` is the pushed top-k (LIMIT required, DESC only).
  `ORDER BY <fast column> LIMIT k` also pushes down.
- `HIGHLIGHT(col [, max_chars [, pre_tag, post_tag [, no_match_size
  [, fragments]]]])` — snippet with matches marked (`<b>…</b>` default; a
  pre/post string pair = ES pre_tags/post_tags; no_match_size = leading
  chars when unmatched; fragments 2-10 = ES number_of_fragments → the value
  becomes an ARRAY of fragments in text order; default 1 = single string).
- Per-group top documents: `SELECT region, title, score() FROM t WHERE
  MATCH(title, 'q') GROUP BY region TOP 3 BY score()` — each group's 3
  best-scoring rows (ES `top_hits` equivalent).
- Aggregations work over search (`GROUP BY region`, `COUNT/SUM/AVG/MIN/
  MAX`, `time_bucket` date histograms, `COUNT(DISTINCT)` exact,
  `APPROX_COUNT_DISTINCT` sketch) — exact fast-field pushdown or exact
  row fallback, never approximated silently. On SHARDED corpora
  (RF < members) ungrouped distinct counts scatter as mergeable term-set
  partials (exact across shards; the coordinator merges — per-shard
  finals would double-count shared values); grouped distincts row-fall
  back.
- Diagnostics: `EXPLAIN <statement>` (plan rows: access path, pushdown
  vs. fallback, cluster fan-out — advisory, never executes). **On a cluster
  the `access` row is the LOCAL plan only**; `cluster.access`
  states what the coordinator actually executes and whether the local
  plan's index bound is enforced distributed, and `cluster.caveat` flags
  any shape that drops it. The two rows disagreeing is the signal that a
  bound was planned and discarded;
  `EXPLAIN SCORE SELECT ... WHERE MATCH(...) FOR <pk>`
  (BM25 breakdown JSON; works at any RF — routed to a replica of the key);
  `SUGGEST 'levensthein' ON idx` (typo suggestions).

**Semantics to remember**: the table is the source of truth — indexes are
derived and rebuild automatically if lost/torn/mismatched. Writes become
searchable within `refresh_ms` (+ a 200 ms server tick); the writing
session sees its own writes immediately. Multi-field scoring is dis-max.
Distributed: relevance top-k scatters and merges at any RF; aggregations
and fast-field sorted top-k use sharded partials on RF < members clusters
(each node aggregates only key-space it primarily owns), falling back to
an exact row gather when anything wobbles.

---

## 5. Time-series

```sql
CREATE TIMESERIES TABLE cpu (SERIES KEY (host, core), RETENTION 30d, OOO 10m);
INSERT INTO cpu (host, core, ts, value) VALUES ('web1', '0', 1712000000000, 0.63);

SELECT time_bucket(1m, ts) AS t, host, avg(value), max(value)
FROM cpu WHERE ts >= now() - 1h AND host = 'web1' GROUP BY t, host ORDER BY t;

SELECT time_bucket(5m, ts) AS t, rate(value) FROM cpu
WHERE ts >= now() - 6h GROUP BY t;
```

- `SERIES KEY` columns are string labels (all required per insert); `ts` is
  required (timestamp or int ms, increasing per series unless within the
  `OOO` window; equal ts = last-write-wins); other columns are numeric
  fields. Append-only: UPDATE/DELETE rejected; `RETENTION` expires blocks.
- **`ALTER TABLE <ts> SET (retention = 60d | ooo = 1h)`** — both
  live-tunable (retention applies at next flush, `0` clears it; ooo applies
  to subsequent inserts — widen it temporarily to backfill a live table).
- **`SELECT DISTINCT <label cols>`** on a TS table serves from series
  METADATA (label sets — no sample scan, immune to point count); a `ts`
  constraint forces the sample path. Same for unbounded, filter-free
  **`min(ts)`/`max(ts)`**: answered from block/head time bounds (cluster
  = union across members, the freshest committed frontier; unreachable
  member → exact sample gather). remote_write: an incoming literal
  `name` label renames to `exported_name` (collides with the metric-name
  mapping otherwise).
- **INSERT reports drops**: points outside the OOO window are discarded and
  `affected` counts only what landed (`0` = all dropped; per-field counts
  when a row has several numeric fields). Check `affected` in ingest code;
  `timeseries.<t>.samples_rejected` in SHOW STATUS is the cumulative view.
- **OOO ingest is buffer-drained, not buffer-capped**: behind-head
  samples inside the OOO window buffer per series (512); a FULL buffer
  triggers a head flush (draining every buffer) and a retry instead of
  rejecting — a sustained backfill costs ~1 flush per 512 samples per
  series and is never refused while in-window.
- **TS maintenance is best-effort and self-bounding**: retention/compaction
  failures never fail the append that triggered them (they count into the
  store's `maintenance_errors` stat and retry next flush); repair/hint
  merge ingest folds its own block backlog inline (bounded rounds), and
  hinted TS writes coalesce per table per drain pass — a hint storm can't
  grow the block-directory count without bound, and leftover directories
  from an interrupted block write can't wedge later flushes.
- `rate/increase/delta` are counter-reset-aware, computed per series then
  summed across the group (PromQL `sum(rate(...))` semantics); `first/last`
  take the earliest/latest value.
- **Raw dumps are scan-metered at the SOURCE**: a raw
  `SELECT` (no aggregation) charges each gathered sample against the
  statement scan budget like any row gather — a huge unbounded range dump
  errors cleanly instead of materializing until OOM. The charge lands
  inside the store walk (and per peer response on a cluster), so the
  abort happens before the result accumulates at the coordinator.
  Narrow the range or aggregate (per-bucket partials are bounded, exempt,
  and unaffected). `COUNT(*)` over an empty selection returns 0, not NULL
  (the partials COUNT→SUM fold coalesces). Gotcha worth knowing:
  `ts` is epoch MILLISECONDS — a bound sent in epoch seconds reads as
  ~1970 and unbounds the walk (symptom: a narrow-window query dies on the
  scan budget having examined a whole series' history).
  **Paging works at any size**:
  `WHERE ts > <cursor> ORDER BY ts LIMIT n` walks time slices with early
  stop (each page costs ~its own rows) — the export pattern. Bare
  `LIMIT n` and unbounded `ORDER BY ts [DESC] LIMIT n` ("latest/oldest
  n") self-anchor the walk at the table's DATA FRONTIER (local
  min/max across head+blocks; wall-clock fallback), so they work
  without an explicit time bound on live AND dormant tables alike. `COUNT(*)`
  on single-field TS tables serves from partials (no sample gather).
- **Rollups**: `CREATE ROLLUP r30m ON cpu BUCKET 30m RETENTION 90d` stores
  `f_count/f_sum/f_min/f_max/f_first/f_last` per bucket, auto-maintained on
  flush AND on repair backfill. Aggregate queries on the source
  automatically answer from rollups past the retention horizon (and, on a
  single node, for any fully-flushed window) — `rate`-family always needs
  raw samples. Query rollups directly like any TS table.
- **Prometheus**: `remote_write` at `POST /api/v1/write` ingests into the
  auto-created `metrics` table (metric name = `name` label).
  `/api/v1/query`, `/query_range`, `/labels`, `/label/<n>/values`,
  `/series`, buildinfo/metadata serve Grafana's built-in Prometheus
  datasource. `query_range` serves bare `*_over_time(m[w])` (the shape
  every Grafana panel emits) from per-step WINDOWED PARTIALS — one bounded
  row per (series, step) from each member instead of every raw sample;
  float-identical to the raw route (equivalence-tested), any window/step
  relationship; rate-family/offset/@/subqueries keep the raw route. The
  label/series endpoints answer from series METADATA, never samples. PATH-PREFIX SCOPING: `/db/<db>/api/v1/*` → that db's
  `metrics` table (write too); `/db/<db>/table/<t>/api/v1/*` → ANY TS
  table, whose FIELDS are the metric names (`pm25{sensor="pi1"}`) —
  point a Grafana datasource base URL at the prefix; permission =
  Select on the scoped table. PromQL subset: selectors with `= != =~ !~` (regex anchored),
  bare `{name=~"..."}` selectors, `offset`, `rate/increase/delta/irate/idelta[5m]`,
  `avg/min/max/sum/count/last_over_time[5m]` (Grafana drilldown tiles),
  `stddev` + `quantile(φ, v)` + `topk/bottomk(k, v)` aggs, trailing commas in matcher blocks,
  `timestamp(<selector>)` + `time()` (last-reading/staleness stats),
  number-only exprs (`1+1` health check), comparisons `== != > < >= <=`
  (filter; 0/1 with `bool`), `and/or/unless` (drilldown emits
  `<e> and <e> > -Inf`), `Inf`/`NaN` literals,
  `sum/avg/min/max/count/stdvar/group [by|without]` + `count_values`,
  vector arithmetic `+ - * / % ^` (PromQL precedence: `^` right-assoc
  tightest, unary minus between `^` and `*`; `on/ignoring` matching +
  `group_left/right(extra)` many-to-one joins, set ops take `on/ignoring`
  too), the `@` modifier (unix time, `start()`, `end()`), subqueries
  `[range:step]` into any range function (omitted step = 60s, inner
  steps epoch-aligned),
  `histogram_quantile`, `label_replace/label_join`, `sort/sort_desc`,
  `absent(v)`, the Tier-2 window analytics (`present/absent_over_time`,
  `changes/resets`, `deriv`/`predict_linear(m[w], t)`,
  `stddev/stdvar/mad_over_time`, `quantile_over_time(φ, m[w])`), and the
  Tier-3 per-sample family: `abs/ceil/floor/round(±to_nearest)/clamp*/
  sqrt/exp/ln/log2/log10/sgn`, UTC calendar fns (`minute/hour/day_of_*/
  days_in_month/month/year`, no-arg ok), `vector()/scalar()` (names bind
  only with '(' — a metric named `year` stays a selector). Scalar fn
  args (quantile φ, topk k, clamp bounds, predict_linear seconds) take
  any CONSTANT arithmetic expr — `predict_linear(m[6h], 24 * 3600)`
  works verbatim; non-constant scalar args are a parse error. Grouping
  accepts both positions (`sum(x) by (a)` too); rate/increase/delta use
  Prometheus's exact extrapolatedRate; NaN/±Inf render as API sample
  values (not filtered); a scrape-time literal `name` label (stored as
  `exported_name`) renders back as `name`. Label-set semantics match
  Prometheus exactly: `without(...)` aggregations and EVERY
  vector∘vector binary operator drop `__name__` (including filter
  comparisons and `on(__name__, ...)` keys), group_left/right filter
  comparisons graft the requested extra labels, and
  `histogram_quantile` drops `__name__` even from bare-selector input
  and emits a SAMPLE for every group — φ bounds checked before
  histogram validity (φ<0 → -Inf, φ>1 → +Inf, invalid/empty histogram
  → NaN) instead of dropping the series. PromQL is structurally
  COMPLETE (Tiers 1–4) and VERIFIED two ways: panel-by-panel — Node
  Exporter Full (#1860, 284 queries) vs real Prometheus on
  identical data, 253/253 non-empty panels exact — and via
  the official prometheus/compliance promql-compliance-tester on
  identical remote_written data: **526/538 (97.8%)**,
  identical single-node and 3-node RF=3. The remaining
  ledger is small and known: scientific/hex number literals, negative
  `offset`, `timestamp(<expr>)` beyond a bare selector,
  `{__name__=~".*"}` acceptance, `clamp(min>max)` → NaN vs empty,
  staleness markers. Out of scope: native histograms, trig functions,
  `atan2`.
- **Self-scrape**: `config set observability.self_scrape true` (live) makes
  the node ingest its own `/metrics` every
  `observability.self_scrape_interval_secs` — self-dashboarding without an
  external Prometheus.
- **Node stats table**: every node INSERTs its host stats (cpu, mem, disk,
  uptime, restarts, oom_kills) into the replicated `node_stats` table every
  `observability.node_stats_interval_secs` (default 1s; on by default, live
  keys `observability.node_stats*`). One timestamped row per node, PK=node.
  The UI overview's nodes table reads it and shows per-row age (no probe
  flapping);
  query it: `SELECT node, restarts, oom_kills FROM node_stats`.
- **Drivers table**: every live binary-protocol connection registers a row
  in the replicated in-memory `drivers` table (PK=conn_id: node, endpoint,
  remote_addr, auth_user, connected_at, and — self-reported by all 8
  bundled drivers via the Hello opcode — client_name, client_version) and
  removes it on disconnect. REST
  connections are not tracked (request-scoped churn, not signal — though
  the gateway DOES speak HTTP keep-alive: one socket serves many
  requests, honoring `Connection: close`/HTTP/1.0, chunked row streaming
  closes). Shown on the UI overview tab; query it:
  `SELECT node, remote_addr, auth_user, client_name, client_version FROM drivers`.
- **Cluster & node names**: every deployment self-names at first boot —
  a random `adjective-animal` cluster name (replicated `cluster_meta`
  table) and a random per-node alias (replicated `node_aliases`, keyed
  by the stable internode id). Dotted form `<cluster>.<function>.<alias>`
  with function `node` or `witness` (a witness's alias lives in the
  `witnesses` registry; its stable witness_id never changes). Rename
  with `ALTER CLUSTER SET NAME '<name>'` / `ALTER NODE '<alias|dotted|id>'
  SET NAME '<name>'` (Admin privilege) from ANY member — but never from
  a witness node, which mirrors identity one-way from its primary and
  refuses both statements. Names surface in `/status`
  (`cluster_name`, `node_aliases`), the UI header badge, and the
  witnesses table. Aliases are sugar; ids are truth — durable references
  (table pins, membership) store ids, so renames never move data.
- **Internode cert ROTATION** (no flag day): `internode_tls_ca` accepts a
  CONCATENATED bundle — every cert in the file enters the trust store, both
  directions. Order is forced by mTLS being mutual: (1) deploy old+new CA
  bundle everywhere, certs unchanged; (2) swap certs node by node (mixed
  old/new interoperate while both hold the bundle); (3) drop the old CA. A
  node given the new CA WITHOUT the bundle rejects peers still presenting old
  certs and partitions itself, even though its own cert is valid. Pinned by
  `cert_rotation_via_ca_bundle`. Turning cert mode ON is different — no mixed
  window, flag-day restart.
- **Witness mode** (`[witness]` config on a STANDALONE node): the node
  periodically pulls a full copy of the configured databases from a
  primary cluster it is not a member of — a cross-region backup that
  never joins the primary's ring or quorums and sets its own pace
  (`interval_secs`, default 1h). Data moves over the internode protocol
  (`ScanPage` pages: byte-exact rows with HLC stamps and tombstones —
  re-pulls converge by last-writer-wins, deletes propagate). Placement
  aware: full-copy tables pull from one member with failover, pinned
  tables from their pins, and sharded tables (per-table replication
  below the primary's member count) SCATTER over every configured
  member and merge — the witness stays complete for any placement. A
  down member only stales that member's shards, loudly logged; schema
  listing and the registration/heartbeat/watermark row in the primary's
  `witnesses` table (PK=witness_id: region, registered_at, last_seen_at,
  watermarks) go over SQL with witness-scoped credentials on the primary
  (`CREATE ROLE witness_role; GRANT SELECT, INSERT, UPDATE ON witnesses
  TO witness_role` — SELECT included because registration reads the row
  back before choosing INSERT vs UPDATE, so write-only grants fail on the
  first beat). Both sides publish the association on the
  unauthenticated `GET /status`: each MEMBER carries `witnesses[]`
  (witness_id, alias, region, registered_at, last_seen_at,
  `seen_age_secs`, `sync_age_secs`), and the WITNESS itself reports
  `role: "witness"` + witness_id/region/databases/primary_sql_addrs/
  interval_secs/duty_pct/`last_pull_at` — pair them on witness_id to file
  a witness under the cluster it backs up. A witness cannot report the
  cluster's NAME (it mirrors only its configured databases, not `default`
  where `cluster_meta` lives, and its role is granted `witnesses` alone),
  hence the endpoint list. ALERT ON `sync_age_secs` (oldest per-table
  watermark), not the heartbeat: a witness heartbeats on schedule while a
  single table stays stuck — that is exactly how one failed silently for
  three days. `last_pull_at` is the witness's own last SUCCESSFUL cycle,
  the tell for "up but every cycle failing" (it never gets to heartbeat).
  Judge gaps against `duty_pct`, not `interval_secs`: at 25% a long cycle
  rests ~3x as long as it ran. If the primary runs
  `encryption.client_tls = "required"`,
  set `witness.primary_tls = true` so this SQL control-plane connection
  presents client TLS (CA defaults to `[auth].internode_tls_ca`, SNI to
  `skaidb`) — the bulk data pull already rides the `[auth]`-secured
  internode port. Pair with `server.read_only = true`: drivers can read
  the copy, nothing can diverge it (the pull applies beneath the session
  layer, so read-only never blocks it). Cycles are NEAR-LIVE cheap
  (default `interval_secs` 60): unchanged tables are skipped via a
  per-table `write_seq` hint (one tiny RPC each), and changed tables
  pull only their delta — the primary walks its value-free stamps
  sidecar (present for encrypted tables too, sealed in its own nonce
  domain) and returns rows stamped since the witness's watermark, so
  steady-state traffic is proportional to change, not data size. A
  periodic FULL sweep per table (`full_sweep_interval_secs`, default
  24h) backstops the one delta blind spot (a delayed hint-replay
  landing an old-stamped row behind the watermark); primaries without
  the delta verbs (rolling upgrade) degrade gracefully to full sweeps.
  **What a witness mirrors is decided twice**: the primary opts a table
  out for every witness with `WITH (witness = false)`, and each witness
  narrows what remains with `witness.tables` / `witness.exclude_tables`
  (`db.table`, optional trailing `*`; empty allowlist = all). A witness
  never widens past the primary's flag, and TOAST companions follow their
  base table rather than matching on their own. **Tombstone GC holds per
  table**: a table's delete markers wait only on the witnesses that
  actually mirror it (their heartbeat watermark keys ARE their mirror
  set), each back to its own watermark for that table, so a witness stuck
  on one table no longer pins every mirrored table's tombstones. A witness
  registered but short of a first completed cycle reports no watermarks and
  holds everything back to its registration. Deselecting a table lets its
  deletes be collected unseen — re-adding one is a full resync, not a
  resume.
  The pull is MEMORY-BOUNDED regardless of dataset size: it pages at a
  fixed row count, applies under a value-free staleness guard (no full
  rows through the read cache), and byte-paces its memtable flushes — so
  even a from-empty full resync of a multi-GB table (no incremental delta
  to lean on) holds a flat, bounded footprint instead of stacking frozen
  memtables. A standalone witness has no background flusher, so the pull
  drains its own memtables by bytes applied, not page count.
  TIME-SERIES tables mirror too (routed by SHOW TABLES' `kind` column):
  samples pull via time-windowed TsQuery scattered over every member,
  unioned + deduped into any-aged merges; recreated locally as plain
  TIMESERIES tables (source series key, NO retention — the backup keeps
  what the primary ages out; rollups mirror as plain TS tables).
  TS pulls are PAGED SERVER-SIDE (`TsQueryPaged` internode
  verb — the TS twin of ScanPage): each member serves a window it bounds
  itself at ~50k samples (server-side boundary search over a capped
  store walk; only the server knows how many samples a window holds —
  client-guessed windows would either blow the 64 MB internode frame
  server-side or OOM a small witness on a frame-fitting-but-huge
  response), pages are trimmed to the
  members' common window, unioned, merged, and watermarks saved per
  landed page (crash-resumable; watermarks keep DATA-time semantics).
  Mixed-version fallback: primaries without the verb get one legacy
  client-side adaptive-window walk per cycle.
  TS block handles hold NO file descriptor (chunk files
  open per read call — FD count is flat regardless of block count; a
  block-heavy boot logs "opening N blocks..." so it's diagnosable).
  Witness observability: a TS catch-up logs progress every
  200 pages, and every node exports per-witness staleness gauges —
  `skaidb_witness_last_seen_age_seconds` and
  `skaidb_witness_oldest_sync_age_seconds` (refreshed at most once a
  minute; alert on the latter, e.g. > 3600 — a witness can heartbeat
  forever while one table stays stuck).
  The single-row
  `witness_gc_config` table holds `grace_period_secs` (default 7 days) —
  cluster-consistent because it is a table row, settable with a plain
  `UPDATE` — and it is ACTIVE: every minute each node sizes a deepest-
  level tombstone-retention window from the registry (how far back the
  least-caught-up live witness is, from its heartbeat watermarks, capped
  at the grace period) so a delete marker is never purged before every
  live witness has pulled it — the delete would otherwise resurrect on
  the backup. A witness quiet past the grace period stops holding GC
  (it must full-resync, and for missed deletes be rebuilt); with no
  registered witnesses tombstones drop immediately, as always. Registered
  witnesses + live drivers appear on the UI overview tab.
- **Read-only mode** (`server.read_only`, default false, **live-mutable**:
  `SET CONFIG server.read_only = 'true'`): rejects every client mutation —
  INSERT/UPDATE/DELETE, DDL, user management, transactions, ES `_bulk`,
  `POST /insert`, Prometheus remote_write — with error "read-only node:
  mutations are disabled"; reads (SELECT/SHOW/DESCRIBE/EXPLAIN/search) and
  the Admin/Monitor control plane work normally. RBAC is checked first, so
  an ungranted role still sees its usual permission error. The node's
  configured superuser role is exempt (internal telemetry and a witness's
  data-pull applier run as it) — don't hand its credentials to
  applications. Intended for witness nodes and maintenance write-freezes.
- **Listener admission control** (all three keys **live-mutable**):
  * `server.max_connections` (default 1024, `0` = unlimited) — live
    connections each of the REST and binary listeners will hold; the limit
    is per listener, not shared. Both spawn a thread per connection, so the
    ceiling bounds thread and FD exhaustion. Refused peers are counted in
    `skaidb_connections_refused_total{endpoint,reason="over_capacity"}`.
  * `auth.failed_login_rate_limit` (default 5/s per source IP with 2×
    burst, `0` = disabled) — only FAILED authentications are charged, so a
    client that authenticates successfully is never throttled however fast
    it goes (Grafana scraping every panel from one address is unaffected).
    Once a source is over budget, REST answers **429** — including for
    correct credentials, which is what stops guessing, so the status says
    "back off" rather than "wrong password". A request with no
    `Authorization` header at all is never charged, keeping the browser
    challenge-then-retry flow intact. Counted in
    `skaidb_auth_refused_total{endpoint,reason="rate_limited"}`.
  * `server.status_auth_required` (default false) — require `MONITOR` on
    `*` for `GET /status`. Off by default because probes, `skaidbsh status`
    and node-watching agents read it anonymously; turn it on when the port
    is reachable from somewhere untrusted, since the response maps node
    ids, internal member addresses, database names, RF, and the security
    posture. `/health`, `/healthz`, `/ready` and `/readyz` stay open either
    way, so liveness checks are unaffected.
  * `server.handshake_timeout_secs` (default 30, `0` = wait forever) —
    how long an unauthenticated BINARY connection may sit before the server
    drops it. Armed before the SCRAM handshake and cleared once it
    succeeds, so a pooled driver connection may idle between statements
    indefinitely while a peer that never authenticates cannot pin a thread.
  * `server.pid_file` (default empty = off) — write + exclusively lock a
    pidfile at start (a second instance on the same path fails fast); the
    LOCK is the liveness signal, so a stale file after `kill -9` is taken
    over without any PID-alive heuristic. Removed on graceful shutdown.
    For external supervisors re-discovering the process. Restart-only.
  * `server.crash_log_path` (default empty = stderr only) — panic reports
    (timestamp, message, forced backtrace, build version) APPEND here in
    addition to stderr; supervisors ship the file after a process death.
    SIGKILL/OOM-kill is uncatchable — pair with exit-status monitoring.
    Restart-only.

---

## 6. Vector search

```sql
CREATE VECTOR INDEX docs_emb ON docs (embedding) DIM 768 USING cosine;
INSERT INTO docs (id, embedding, cat) VALUES (1, [/* 768 floats */], 'news');
SELECT id, _distance FROM docs NEAREST (embedding, [/* query */], 5)
WHERE cat = 'news';
```

HNSW (snapshot-persisted; reload + watermark replay on open — a MISSING/
corrupt snapshot triggers a from-scratch rebuild that the SERVER runs in
the BACKGROUND: node serves immediately, NEAREST on that index errors
"rebuilding — retry shortly" + SHOW INDEXES says `building` until done,
log lines at both ends), metrics
`cosine` (default) / `l2` / `dot`; `_distance` injected; `QUANTIZED` for
int8 in-RAM vectors with exact rescore (see §5 DDL note); ONE vector
index per (table, path) — a duplicate is rejected at CREATE;
`ALTER VECTOR INDEX v SET (ef = n)` retunes search-time recall/latency
live (persisted; build-time knobs need a rebuild);
`WHERE` filters candidates (over-fetch + filter); `LIMIT/OFFSET` apply
after. No JOIN/UNION/aggregates/ORDER BY with NEAREST. Vectors are float
arrays of one consistent dimension. The index is in-memory by default;
`storage.vector_file_backed = true` (restart-scoped, node-local) keeps
each frozen graph ON DISK read-on-demand instead — per-node RAM drops to
an 8B offset + the key; writes since the freeze live in an in-RAM DELTA
merged at search; saves rewrite only the delta frame (base never
rewritten, crash falls back to the base watermark + normal replay); an
index created empty auto-promotes its delta to the base at first save; a
delta ≥ max(10k nodes, base/4) is consolidated AUTOMATICALLY by a paged
background merge into a new base file (stream-copy + per-node file
inserts with in-place rewiring — no full-graph RAM, writes chased
mid-merge, atomic swap, crash leaves only a deletable partial); the
initial BUILD still peaks at full-graph RAM — steady state is what
shrinks.
Distributed: scatter, merge by distance.

---

## 7. Cluster semantics

- **Journal-ack writes**: a replicated write acks after WAL
  append + fsync + memtable insert — point reads see it immediately
  (read-your-writes kept). Secondary-index/vector/FTS maintenance applies
  asynchronously (normally sub-ms lag): an index-served read or index-only
  count can trail a write briefly, like FTS NRT visibility. Crash recovery
  replays the un-applied suffix from the WAL (per-table watermarks).
- **Background flush/compaction**: a full memtable freezes (WAL
  segment seal — microseconds) and SSTable builds/compaction merges run on
  a background worker; the write path never builds tables. Sustained
  overload degrades to inline flushing past 4 frozen memtables.
- **DDL acks at schema-apply**: CREATE INDEX (and rename-triggered
  rebuilds) return once the schema exists everywhere; each node pages its
  own backfill in the background. `SHOW INDEXES` shows
  `secondary (building)` until that node's pages complete; the planner
  never uses a building index.
- **Vector DDL acks at schema-apply too**: CREATE VECTOR INDEX
  with an explicit DIM (and rename-triggered vector rebuilds) queue a
  paged backfill; `SHOW INDEXES` shows `local = building` and searches on
  that index error "rebuilding — retry shortly" until the pages complete.
  Only the DIM-inference form (no explicit DIM) scans inline.
- **Non-blocking FTS startup**: a node opens and serves everything
  immediately; search-index catch-up/rebuild pages in the background.
  `MATCH` against a still-rebuilding index errors with "rebuilding after
  restart — retry shortly" instead of blocking startup.

- **Topology**: static seed list in config (`cluster.seeds`), or runtime
  `add-node`/`remove-node` (online resharding: dual-ring placement during
  the change, data migrates, epoch bumps). `vnodes_per_node` (256) balances
  the ring.
- **Replication**: `replication_factor` copies per key (row key or TS
  series). Consistency per operation: `ONE`/`QUORUM`/`ALL` (defaults from
  config; `\consistency` in the shell per session). Writes: quorum acks,
  hinted handoff for down replicas. Reads: quorum + read repair.
  Anti-entropy (`repair`) converges replicas both directions; `reclaim`
  drops data a node no longer owns (rows and TS series) once an owner
  confirms an identical copy.
- **Read paths for search/aggregation** ("exact-or-decline" everywhere):
  RF ≥ members → any node's local index holds everything, serves locally.
  RF < members (sharded) → aggregations, AVG, and fast-field sorted top-k
  scatter per-shard partials filtered to each node's primary-owned
  key-space (`_ring` placement-hash fast field; epoch-gated;
  all-members-or-fallback); relevance top-k scatters and dedups by key;
  per-hit explain routes to a replica of the key. EXACT paths (index
  candidate unions, filtered gathers, sorted top-k) DECLINE when a shard
  cannot contribute and fall back to the full gather; RANKED searches
  (vector/FTS) instead degrade — they skip the shard and count it in
  `skaidb_cluster_search_shard_missing_total`, since failing every search
  over one peer blip would be worse than fewer hits. Keyword-grouped metric
  aggregations (incl. via a text column's `.keyword` twin) push down as a
  manual fast-field fold and their partials merge by bucket key; anything
  unmergeable (distinct counts, date-histogram metrics, residual filters
  on sorted scatters) falls back to an exact row gather.
- **Transactions do not work on clusters** (each statement autocommits).
- Every acked write is durable (WAL) and searchable cluster-wide within the
  refresh interval. Search/vector hit re-reads honor the session's
  `SET CONSISTENCY`: at RF=2 with a member down, QUORUM reads refuse
  (1/2 replicas) but `SET CONSISTENCY ONE` serves complete results from
  the surviving replicas.
- **Graceful shutdown**: SIGTERM flushes memtables + commits search
  writers (fast restart, no index rebuild). **Full-copy counts**: at
  RF >= members, unfiltered `COUNT(*)` answers from local key stats (no
  gather) — **including on a `ttl` table**, where the stat
  applies the expiry predicate itself and stays exact. Compaction deletes
  retired SSTables only after the manifest commit (crash-safe).
- **Memory pressure** (limits from cgroup/system RAM, non-reclaimable
  usage): above 75% a node actively releases (flushes memtables, commits
  search writers, SHRINKS the shared block cache to its hottest quarter,
  and PACES time-series head flushes — one head per pass, largest first,
  so TS ingest drains while headroom remains instead of dumping every
  head at once at shed) AND paces BACKGROUND work (IWM lanes:
  anti-entropy yields ~250 ms between tables instead of running flat out
  — the degradation order's first step, so deferrable work gives way
  before anything a client can see); above 85% it also sheds writes with a retryable
  "memory pressure" error (clears at 70%) AND drops the point-read caches
  (entry-capped, byte-blind — multi-KB rows can pin far more than the
  budget assumed) and every table's decompressed-block cache (anti-entropy
  sweeps fill them with blocks nothing may re-read for hours; a cold cache
  refills, an OOM kill does not) AND background work DEFERS entirely
  (pacing would still allocate, just slower; a disk-blocked node defers
  for the same reason plus having nowhere to write). Compaction is
  PROTECTED, never throttled — it frees the very resources the node is
  short of. Shedding
  logs loudly (anon/file + jemalloc allocated/resident/retained; a
  distress line every 60 s while stuck). **STANDALONE nodes (witnesses,
  single-node) run the same tier** — release/shed sampler on the local
  backend, client mutations refused under shed while reads and internal
  writers (witness bookkeeping) pass. Anti-entropy passes log duration
  when they reconcile rows or run ≥60 s. The systemd unit sets
  `MemoryHigh=85%` as a kernel-side backstop and jemalloc
  background-purge decay (1 s) so RSS tracks the live set.

---

## 8. Admin & configuration

**HTTP admin** (POST, Basic auth, `ADMIN` on `*`):

```
/admin/status        cluster detail (ring, peers, liveness)
                     (GET /status also carries peers[] w/ hints_pending + lag_ms;
                      UI members panel shows per-node backlog + lag)
                     (GET /status also carries witnesses[]: witness_id, alias,
                      region, registered_at, last_seen_at, seen_age_secs,
                      sync_age_secs — see §Witness mode for which to alert on)
/admin/repair        anti-entropy pass          {"ok":true,"repaired":n}
/admin/reclaim       drop unowned keys/series   {"ok":true,"reclaimed":n}
/admin/add-node      {"addr":"host:7100"}   (repeat add of a member = no-op
                     that also heals a stuck transition; refused while another
                     resharding is active)
/admin/remove-node   {"id":"host:7100"}     (absent id = ok; refused while
                     another resharding is active, or while tables pin the node)
/admin/resharding    GET or POST → {active, epoch, rows_moved} — poll after
                     add/remove-node: active:false is the "finished" edge,
                     epoch the committed membership version, rows_moved this
                     node's cumulative migrated-rows counter. Standalone
                     answers {active:false} (one orchestrator loop, both shapes).
/admin/config        full config, secrets masked (`auth.superuser_password`,
                     `auth.internode_token`, `witness.password`,
                     `inference.api_key` → `***`; key/cert PATHS stay visible)
/admin/config/get    {"key":"section.field"}
/admin/config/set    {"key":"...","value":"..."}  → {applied, persisted,
                     restart_required} — live-mutable keys apply instantly
/admin/slow          slow-query log (masked SQL)
```

**Live-reload matrix** (the config-diff contract for external
orchestrators that rewrite skaidb.toml): every key is settable via
`/admin/config/set` / `SET CONFIG`; the response's `applied` field is the
authority — `applied:true` = took effect live, `applied:false` +
`restart_required:true` = persisted for the next start only. Exactly
these keys apply live (`skaidb_config::RUNTIME_MUTABLE_KEYS` — a test
keeps this list in sync):

<!-- live-keys:begin (generated from RUNTIME_MUTABLE_KEYS; edited manually = test failure) -->
- `[observability]` `self_scrape`, `self_scrape_interval_secs`, `node_stats`, `node_stats_interval_secs`, `slow_query_ms`, `query_log_enabled`, `query_log_masked`, `login_log_enabled`, `error_log_level`, `per_table_metrics`, `log_format`, `log_file`, `query_log_file`, `slow_query_log_file`, `error_log_file`, `login_log_file`
- `[cluster]` `anti_entropy_interval_secs`, `anti_entropy_max_interval_secs`, `anti_entropy_adaptive`, `hint_max_age_secs`, `hint_max_disk_mb`, `bootstrap_duty_pct`
- `[ui]` `enabled`
- `[server]` `read_only`, `max_connections`, `status_auth_required`, `version_disclosure`, `handshake_timeout_secs`
- `[auth]` `failed_login_rate_limit`
- `[witness]` `duty_pct`
- `[mqtt]` `max_connections`, `max_packet_bytes`, `max_inflight`, `outbox_messages`, `max_queued_per_session`, `max_retained`, `session_expiry_max_secs`, `connect_rate_limit`, `max_topic_levels`, `max_topic_bytes`, `max_subs_per_session`, `sys_topics_enabled`
- `[iwm]` `disk_low_watermark`, `disk_resume_watermark`, `disk_reserve`, `sentinel`, `conn_rate`, `conn_burst`, `ops_rate_overloaded`, `jemalloc_purge_on_relax`, `max_concurrent_scans`, `heavy_scan_rows`, `disconnect_cancellation`
<!-- live-keys:end -->

Everything else is restart-required — notably (the keys a supervisor
rewrites most): `server.bind_addr` / `server.quic_port` /
`server.rest_port` / `server.rest_tls_port` (listeners bind once),
`server.data_dir`, `server.pid_file`, `cluster.seeds`,
`cluster.replication_factor`, `cluster.vnodes_per_node`,
`auth.scram_enabled` / `auth.superuser*` / `auth.internode_*`, and every
TLS material path (`encryption.tls_cert_file`, `encryption.tls_key_file`,
`auth.internode_tls_*`) — though changing the FILES those paths point at
needs no restart at all: a watcher polls their mtimes (~30 s) and
reloads the certificate live for both client-facing TLS and internode
cert mode — new connections present the new cert, established sessions
continue, and a half-written or mismatched replacement keeps the old
material serving until the next tick. ACME-style rotation is therefore
just "replace the files". A restart-only set is NOT an error: it
persists (`persisted:true`) and reports `restart_required:true` — the
supervisor should restart the node at its next safe window.

**Config** (TOML at `/etc/skaidb/skaidb.toml` on packaged installs; every
key also reachable via `config set`): `[server]` bind_addr, quic_port,
rest_port, data_dir, node_role, read_only (reject client mutations,
superuser exempt — witness/maintenance mode), version_disclosure (default
true; false blanks the exact build on the UNAUTHENTICATED surfaces —
`skaidb_build_info` labels on /metrics and `version` in /ui/meta — for an
internet-reachable node; authenticated SHOW CONFIG still shows it); `[cluster]` seeds, internode_port,
replication_factor, vnodes_per_node, default_read/write_consistency,
anti_entropy_interval_secs (+ anti_entropy_max_interval_secs,
anti_entropy_adaptive — adaptive cadence, OFF by default: reconciled
pass → interval snaps to the floor, converged → ×1.5 toward the ceiling
(0 = 4× floor; doubles as the full-sweep bound), deferred/failed →
unchanged; divergence events (hint stored, replay drained, member join)
pull the next pass in, debounced to the floor; all three live-mutable
via SET CONFIG; metrics skaidb_ae_passes_total{outcome},
skaidb_ae_rows_reconciled_total, skaidb_ae_interval_seconds,
skaidb_ae_last_pass_seconds/at_seconds — alert when now minus last-pass
exceeds the ceiling); `[auth]` scram_enabled, superuser,
superuser_password (first-boot self-bootstrap: the superuser exists from
config alone — no human step), `[[auth.bootstrap_users]]` (name,
password_file, grants — additional users created IDEMPOTENTLY at every
start for non-interactive provisioning: `CREATE USER IF NOT EXISTS` with
the password file's trimmed contents, then `GRANT <clause> TO <name>`
per grants entry, e.g. `"MONITOR ON *"`; an existing user's password is
never clobbered — rotate via `ALTER USER`), … gssapi_enabled + gssapi_keytab (+ optional
gssapi_service_principal) — accept Kerberos (SASL GSSAPI) client auth for
external users (`CREATE USER "u@REALM" GSSAPI`); needs a `kerberos`-feature
build (glibc/macOS/Windows — the static-musl binary ships WITHOUT it, and
`gssapi_enabled=true` on such a build fails startup loud) and a readable
keytab. SCRAM stays available alongside it. internode_auth (`none`/`token`/`cert` — `cert` is mutual
TLS and the only mode that ENCRYPTS internode traffic; `token` authenticates
only. `internode_tls_{cert,key,ca}` for cert mode; mint them with
`skaidbsh certs gen --out DIR --nodes N`. Effective mode shows at `/status` as
`internode_auth`); `[storage]`
memory_target (`"auto"`, `"1GB"` — budgets memtable + read cache + block
cache + scan budgets + FTS writer heaps + TS heads; cgroup-aware. The
PACKAGED conffile ships `"auto"` — fresh deb/rpm installs
budget to half the node's RAM; the built-in default without a config file
stays empty/off, and existing installs keep their conffile), memtable_size_mb,
read_cache_entries, scan_row_budget (rows one statement may examine,
default 250000, 0 = off), scan_byte_budget (bytes one statement may
materialize into a result set — bounds coordinator memory, not rows;
default 268435456 [256 MB], 0 = off), statement_timeout_secs (default 120,
0 = off), wal_archive_dir (point-in-time recovery; empty = off),
wal_archive_retention_secs (the recovery window, default 604800 = 7 days,
0 = forever), wal_archive_max_lag_secs (forces a WAL seal so a quiet table's
history reaches the archive — this is PITR's resolution; default 300),
ttl_reclaim_interval_secs (how often a TTL table holding expired
rows is frozen+flushed and compacted so the space actually comes back;
default 900, 0 = off — a TTL bounds VISIBILITY, and flush is size-triggered,
so without this a low-write expiring table holds every expired row in RAM
for years; the sweep runs on STANDALONE servers too via a dedicated
driver — before 2026-08-17 its only caller was the cluster flusher, so
standalone/witness nodes silently never swept), block_target_kb (uncompressed target size in KB of newly written
SSTable data blocks, default 16 — the measured knee of the
scan-throughput-vs-point-read-amplification tradeoff; bigger blocks scan
faster and compress better, smaller blocks decode fewer bytes per cold
point read; applies to new files only, compaction rewrites old ones over
time, and a self-terminating background sweep rewrites the COLD tail
(tables under the flush threshold with old-target files — one table per
maintenance pass, no knob, re-arms automatically on any future target
change); watch `skaidb_storage_legacy_block_bytes/_files` trend to zero,
0 = built-in default), parallel_scan_threads (worker-thread ceiling
for parallel range scans — large unindexed/PK-range streamed GROUP BY
folds split at SSTable block boundaries; default 0 = auto: half the
cores capped at 4; 1 disables; small ranges always stay serial;
restart-applied); `[iwm]` (Intelligent Workload Management; all keys live-mutable)
sentinel (`off`|`manual`|`auto`, default `manual`: `manual` = SHOW
QUERIES/KILL QUERY only; `auto` also lets the Query Sentinel kill the
worst-scoring statement — rows_examined × elapsed, ≥5s and ≥1024 rows,
never `internal` — every 5s WHILE the node is shedding writes; victims
error "terminated by IWM sentinel (…score…)", kills are logged and
counted in `skaidb_iwm_sentinel_kills_total`), conn_rate + conn_burst
(new-connection token bucket across REST+binary listeners, tokens/sec;
0 = off; burst 0 = 2× rate; HALVED while overloaded; refused sockets
close and count in `skaidb_connections_refused_total{reason="rate_limited"}`
+ `skaidb_iwm_conn_rejections_total`; `server.max_connections` stays the
hard ceiling behind it), ops_rate_overloaded (statements/sec ceiling
engaged ONLY while shedding writes; surplus get retryable
"overloaded: rate limited, retry"; control plane + internal exempt;
`skaidb_iwm_ops_rejections_total`), jemalloc_purge_on_relax (opt-in, live-mutable: purge jemalloc
dirty pages — arena.<all>.purge — when the IWM tier RELAXES; the release
tier just freed caches so resident−allocated is widest while a cgroup
ceiling still counts it; debounced 60s; counted in
skaidb_alloc_purges_total, before/after logged per purge), disconnect_cancellation (cancel a
statement whose driver client closed the connection; default FALSE —
opt-in because the first release of it dropped healthy connections in
production, and live-mutable so it can be switched off without a
restart), max_concurrent_scans +
heavy_scan_rows (how many HEAVY read scans may run at once, 0 = off
(default); a statement counts as heavy only once it has EXAMINED
heavy_scan_rows rows — 0 = 10k — so point reads, index lookups and
selective LIMITs never take a slot and can never be refused one, and
stay responsive while scans saturate the cap; surplus heavy scans get
retryable "too many concurrent scans: retry shortly, or raise
iwm.max_concurrent_scans"; the gate NEVER blocks — a waiter could hold
the engine read lock and convoy writers behind it; a parallel scan
claims its one slot before forking its workers, and workers never claim
their own; slots release on every statement exit incl. unwind; gauges
`skaidb_iwm_heavy_scans_running` +
`skaidb_iwm_scan_admission_refusals_total`), disk_low_watermark
(free-space floor that
BLOCKS disk-growing writes: a size `"2GB"`/`"512MB"`/plain-MB number or a
percent `"5%"`; empty = default `min(5%, 2GB)`), disk_resume_watermark
(where blocked writes resume — hysteresis; empty = 2× the effective low),
disk_reserve (a reserve FILE `.skaidb-reserve` in the data dir — `"64MB"`,
`"1%"`; empty = 64 MB capped at 5% of the volume; `"0"`/`"off"` = none —
released the moment writes block so deletes, retention and compaction have
room to recover the node, recreated once free space beyond it is back above
the resume watermark; while released its size is subtracted from measured
free space, so the guard does not hand it to client writes).
While blocked, INSERT/UPDATE, replicated puts, and TS appends/merges
(remote_write too) get the retryable `"disk full: node is blocking writes,
retry"` (same retry class as memory shedding — a coordinator hints a blocked
replica on ITS OWN disk and redelivers after space clears); reads, DELETE,
DDL and compaction (which FREE space) keep working. The guard also trips
IMMEDIATELY on a real `ENOSPC` anywhere in storage (`/ready` goes 503 on
the error, not at the next 10 s sample). A full disk never fails a
committed write: a row is durable once it is in the WAL + memtable, and the
flush/compaction it triggers failing (ENOSPC) is logged, counted
(`skaidb_storage_build_failures_total`), held off for 10 s and retried —
the statement returns OK; the guard (tripped by the ENOSPC) is what tells
clients to stop. A failed SSTable build removes its partial output
(`.sst` + `.stamps`); a flush or background compaction whose manifest
write fails rolls back (the memtable keeps the rows / the inputs stay
installed); an inline compaction whose manifest write fails keeps serving
from the new run and holds its retired inputs on disk until the next
manifest that persists — retried on the 60 s maintenance tick, so the
space comes back without an operator; files in `sst/` that the MANIFEST
does not reference are removed at open (logged as "removed N orphaned
table file(s)"). Restarting on a full disk is safe (WAL replay). Recovery is
automatic once space is freed (drop/truncate a table or partition, delete
foreign files, grow the volume): the next sample recreates the reserve and
resumes writes. `/status` reports `iwm: {state (0 normal/1 elevated/2
overloaded/3 critical), shedding_writes, disk_write_block}`; metrics:
`skaidb_iwm_state`, `skaidb_iwm_disk_write_block`,
`skaidb_iwm_disk_rejections_total`, `skaidb_iwm_disk_enospc_trips_total`,
`skaidb_iwm_disk_reserve_present`, `skaidb_storage_build_failures_total`,
`skaidb_storage_enospc_total`; `SHOW STATUS` `build_failures`.
`[encryption]` client_tls (`off`/`opportunistic`/`required` — client-facing
TLS for the binary + REST ports; `opportunistic` serves TLS and plaintext on
one port [ClientHello sniff], `required` refuses plaintext) with
tls_cert_file + tls_key_file. Effective mode shows at `/status` as
`client_tls`. Clients pass `--tls --tls-ca <ca.crt>` (or `--tls-insecure` for
self-signed; `--tls-server-name`, default `skaidb`) to `skaidbsh`; the **Rust**
driver takes `Client::connect_many_tls(...)` (Python: `tls=True`,
`tls_ca=…` — see §1). **Kerberos (GSSAPI) client auth:**
`kinit`, then `skaidbsh --auth-mechanism gssapi --gssapi-spn skaidb/host@REALM
-u user@REALM` (no password — the ticket cache is used); the **Rust** driver
takes `Client::connect_gssapi_tls(endpoints, principal, spn, tls)` — GSSAPI
is Rust/`skaidbsh` only, no other driver implements it. Needs a
`kerberos`-feature client build (glibc; the musl `skaidbsh` errors that GSSAPI
is unavailable) and an external user (`CREATE USER "user@REALM" GSSAPI`) on a
server with `auth.gssapi_enabled`. Wrap it in TLS for confidentiality —
GSSAPI authenticates, it doesn't encrypt the SQL stream. **REST/UI SPNEGO:**
when `gssapi_enabled`, the REST endpoints also accept `Authorization:
Negotiate <base64 GSS token>` (RFC 4559) and advertise it in the 401
`WWW-Authenticate` (alongside Basic), so a Kerberos browser or
`curl --negotiate` gets single-sign-on to the external user's role;
single-leg only (Kerberos finishes in one token — stateless REST doesn't
carry a multi-round negotiation). **REST port when TLS is on:** with
`client_tls != off`, HTTPS REST moves to `server.rest_tls_port` (default
**7443**) and `rest_port` (7080) becomes a plaintext HTTP→HTTPS **308 redirect**
to it — so point REST/UI/monitoring clients at `https://…:7443` (`skaidbsh`
auto-targets 7443 when a `--tls*` flag is set). With `client_tls = off`, 7080
serves plaintext REST as before and 7443 is not bound.
`[encryption]` also does AT-REST: at_rest_enabled = true + at_rest_kek_source
= keyfile + at_rest_keyfile = <path> encrypts every table/index WAL + SSTable
**and vector-index snapshot** with AES-256-GCM (envelope: a keyfile KEK wraps
per-file DEKs). The vector snapshot holds each indexed row's KEY and full
embedding; it is sealed in 1 MiB frames, each bound to its file offset, so a
multi-GB index is encrypted without ever buffering itself in RAM. A snapshot
written before this (plaintext) still loads and is resealed on its next save.
`storage.vector_file_backed` cannot seal its base yet, so with at-rest on a
node keeps the sealed classic format and logs why — encryption wins over the
heap saving. Generate a
keyfile with `skaidbsh keyfile gen --out <path>` (32 bytes, 0600 — BACK IT UP
off-box; losing it loses all encrypted data). New files encrypt; existing
plaintext files stay readable (mixed migration — fully encrypt via a rolling
per-node wipe+rejoin). A missing/bad keyfile fails startup loud. Restart-scoped.
Shows at `/status` as `at_rest`. (kms KEK source not supported.)
**KEK ROTATION** (no data rewrite — rewraps each file's DEK header in place,
tagged by `kek_id`): (1) generate the new keyfile; (2) per node, set
`at_rest_keyfile = <new>` AND `at_rest_previous_keyfiles = ["<old>"]`
(unwrap-only) + restart — the ORDER IS MANDATORY: the new key alone opens
NOTHING and fails startup; (3) a background sweep rewraps ~130 files/min-tick,
watch gauge `skaidb_storage_kek_stale_files` → 0 (busy WALs converge at next
flush; the patch is crash-safe via a `.rewrap` journal sidecar); (4) at 0,
remove the old keyfile from `previous` + restart + destroy it. Pinned by
`kek_rotation_rewrap_pins_every_rollover_state` (skaidb-storage). Full
runbook: CLUSTERING.md "Rotating the at-rest KEK".
**QUARANTINE**: a table whose files are damaged (torn/corrupt SSTable or
WAL) is QUARANTINED at open instead of refusing the whole node: everything
else serves, statements on it error "table is quarantined (files damaged
…)" — never "not found"; its catalog entry is kept so a re-CREATE cannot
shadow the damage. Gauge `skaidb_storage_quarantined_tables` (alert >0).
Remediate: restore the table dir from backup / wipe-resync the node from
peers / `DROP TABLE`. Covers ENCRYPTED files: the file's kek_id proves
the right KEK is configured, so AEAD failure under the correct key =
disk rot = quarantine; a wrong/mis-rotated keyring (no kek_id match,
nothing unwraps) still fails startup LOUD — a key mistake never reads as
one damaged table. Permission/I/O errors also fail loud.
**RESYNC state**: a node that (re)joins from a WIPED data dir (far less data
than its peers) is flagged `resyncing` while it backfills. A resyncing node does
NOT serve full-scan/aggregate/count results from its own (incomplete) copy — it
gathers from complete peers — so clients get correct results at every
consistency level even mid-backfill. `/status` exposes the node's own
`resyncing`/`resync_progress` (filesize-based, 0..1) and a `resyncing_endpoints`
list; `skaidbsh`/the driver drop those endpoints from the failover pool, and the
UI shows the node as `resync`. The flag clears when the startup catch-up repair
completes. This is what makes the at-rest wipe+rejoin safe against live reads.
Detection reads the node's settled footprint, and an UNREADABLE reading (the
engine lock was busy) counts as unknown, not as zero — a zero reading would
always satisfy the "holds less than half a peer" test and falsely flag a
healthy node. Large memtable flushes on a memory-tight node are the usual
cause of a contended stats call.
`[observability]` slow_query_ms, query_log_*,
log_format/log_file (EVERY log line is timestamped: text lines get an
ISO-8601 UTC prefix, json lines a "ts" field; file sinks are write-behind:
`[query]` lines are buffered and land within 250 ms, while slow-query,
error and login/auth lines flush immediately, and every buffer is flushed
on clean shutdown and before a `config set` swaps a log file — so a
`tail -f` of the query log lags a statement by at most a quarter second
and never sees a partial line), per_table_metrics,
prometheus_port, self_scrape,
self_scrape_interval_secs, node_stats, node_stats_interval_secs; `[ui]` enabled;
`[witness]` enabled, primary_sql_addrs, primary_internode_addrs, user,
password (masked in `config show`), databases, tables, exclude_tables,
interval_secs, witness_id, region — see Witness mode above. Bootstrap pacing: a joining node's
rebalance push and a witness's pull both self-pace adaptively (each
chunk/page is followed by a rest at least as long as it took), so
bootstrap traffic never takes more than ~50% of the serving node's
capacity by construction.
**Live-mutable** (no restart): all `observability.*` log/slow-query keys,
`observability.self_scrape*`, `observability.node_stats*`, `ui.enabled`,
`server.read_only`.

Every admin endpoint above also has a SQL spelling (section 3: `SHOW
CLUSTER`, `SHOW CONFIG`/`SET CONFIG`, `SHOW SLOW QUERIES`, `REPAIR
CLUSTER`, `RECLAIM`, `ALTER CLUSTER`) with identical RBAC and audit.

**Docker**: `docker/` ships a Dockerfile + compose files (single node and
3-node cluster); every config key is settable as a `SKAIDB_*` env var
(env > config file > defaults), `SKAIDB_MEMORY_TARGET=auto` reads the
container's cgroup limit. See DOCKER.md.

**skaidbsh commands**: SQL plus `\status \metrics \cluster [raw] \repair
\reclaim \node add <addr> | remove <id> \config [get k | set k v]
\consistency one|quorum|all \ui [on|off]` and `USE db`.

**System requirements**: min 1 core / 512 MB / 1 GB disk (set
`storage.memory_target` on small boxes); recommended 2+ cores, 2 GB+, SSD
at 2–3× data (LSM compaction + WAL + FTS indexes); ×RF across the cluster.
Each table/index keeps its own WAL, grown 1 MiB at a time ahead of writes
(so fsyncs don't pay a file-extension metadata cost per commit — a
measured 3× single-row durable-write speedup on some storage); expect a
1 MiB floor per non-ephemeral table on disk.

**Web UI** at `/ui` (embed-in-binary, RBAC-aware), four tabs: overview
(identity, per-node health, throughput sparklines, drivers/REST/witnesses),
query (SQL console with schema browser, result charts, CSV/JSON export, and
a search builder covering the FTS modes + SUGGEST + NEAREST), data (tables
and indexes with placement, usage, and index DDL, plus a constraints card
listing every constraint by `type` — FOREIGN KEY with its actions and
supporting index, CHECK / NOT NULL / DEFAULT / IDENTITY / GENERATED with
their definition — plus validity and clause, a sequences card with every
sequence's owner, counter and options, and a views card with every view's
kind, refresh schedule, last refresh, row count and definition;
`/ui/inventory` carries them per database as `constraints`, each entry with
`type`, `sequences` and `views`), admin (repair/reclaim/
add/remove node, slow queries, config editor — `Admin` roles only). Tab
selection lives in `location.hash`. Disable with
`config set ui.enabled false` (live, 404s).

---

## 9. Elasticsearch-compatible subset

An ES "index" = a skaidb table; its search index = the mapping; `_id` = the
single-column primary key (string on the wire; auto-generated if omitted).
Auto-creates unknown indexes on `_bulk` (pk `id`, dynamic mapping:
string→text, int→long, float→double, bool→bool).

```
POST /{index}/_bulk      index/create/delete NDJSON
POST /{index}/_search    query DSL: match, match_phrase, prefix, wildcard,
                         regexp, fuzzy, term, terms, range, exists, bool
                         (must/filter/must_not/should — should beside
                         must boosts via BOOSTED; minimum_should_match 0|1),
                         multi_match (best_fields/most_fields/cross_fields),
                         query_string, more_like_this,
                         geo_distance / geo_bounding_box → SQL geo
                         predicates (ES unit suffixes "5km"→metres;
                         {lat,lon} | [lon,lat] | "lat,lon" | WKT POINT;
                         corner pairs or flat edges; geo index prunes
                         transparently); from/size, multi-key
                         sort (sorted hits carry `sort` values),
                         search_after deep paging (sort [<key>,
                         {"_id":"asc"}] + echo the last hit's sort array;
                         full-text queries only, not with from/knn),
                         _source include/exclude (trailing-* globs),
                         highlight (number_of_fragments > 1 → arrays),
                         "explain": true, exact totals;
                         aggs: terms, date_histogram + sum/avg/min/max/
                         value_count/cardinality (EXACT distinct)/
                         percentiles (exact percentile_cont)/top_hits;
                         composite (multi-source terms/date_histogram,
                         asc keys, after/after_key paging; no top_hits);
                         VECTOR: top-level knn {field, query_vector |
                         query_vector_builder(text→managed EMBED), k,
                         filter} → NEAREST; retriever {rrf {retrievers:
                         [standard, knn]}} → NEAREST + WHERE-search RANK
                         BY RRF; retriever {text_similarity_reranker
                         {retriever: standard|knn|rrf, field→ON,
                         inference_id→WITH, inference_text→QUERY,
                         rank_window_size→TOP (default 10)}} → RERANK
                         (_score = rerank score). num_candidates ignored
                         (ef is on the index); _score = rrf_score()
                         (hybrid) or 1/(1+distance) (knn); total =
                         #hits (≤ k)
POST /{index}/_count
GET  /{index}/_doc/{id}
GET  /{index}/_mapping
```

Everything translates to SQL statements internally — RBAC, replication,
and all pushdowns apply unchanged. Not Kibana-compatible; clients that
hard-check `X-elastic-product` need that check off.

---


## 10. MQTT broker

skaidb serves MQTT 3.1.1 + 5.0 natively (`[mqtt] enabled = true`; ports
1883 plain / 8883 TLS; WebSocket auto-detected on the same ports). IoT
clients authenticate with catalog users; `mqtt.allow_anonymous` gates
credential-less CONNECTs even when server auth is off. Broker state —
retained messages, persistent sessions, subscriptions, offline QoS 1/2
queues — lives in the replicated `_mqtt` database, so it survives node
restarts and (clustered) node loss; clients can resume a session on any
node. SQL-inspectable: `SELECT topic FROM _mqtt.retained`.

- Topic ACLs: `GRANT PUBLISH|SUBSCRIBE ON TOPIC '<filter>' TO role`
  (MQTT wildcard semantics; a role with no topic grants is unrestricted;
  superuser never restricted).
- Topic→table capture (`[[mqtt.sink]]`): `row` mode → INSERT per message
  (JSON fields → columns, wildcard captures → column values);
  `timeseries` mode → numeric JSON leaves into the remote_write fast path
  (PromQL-queryable, auto-created TS table). Enforces the publisher's
  `Insert` privilege + the read-only gate.
- `$SYS/broker/…` Mosquitto-compatible stats per node (10 s tick).
- Full details: `docs/MQTT.md`.

## 10b. MCP server (`skaidb-mcp`)

- Local **stdio** MCP server shipping in the same packages as `skaidb` and
  `skaidbsh`; no listener, no port. One connection, as a dedicated
  least-privilege role.
- **Two gates, one boundary**: `--capabilities` (read | write | ddl |
  schedule | admin, default `read`, cumulative) decides what the SERVER will
  attempt; the role's GRANTs decide what the DATABASE permits. Only the
  second is a security boundary — the tier only ever narrows, so
  `--capabilities admin` against a `SELECT`-only role yields permission
  errors, not escalation. `server.read_only` is node-level and cannot scope
  one connection; read-only means `GRANT SELECT` alone.
- Statements are classified by the **real parser**, matched on structure:
  `EXPLAIN` inherits the tier of what it explains, a keyword inside a string
  literal is not a keyword, `CALL`/`USE`/`BEGIN` are writes not reads, and
  `SHOW GRANTS` is admin (reconnaissance). Unparseable input is refused.
- Also enforced: `WHERE` required on `UPDATE`/`DELETE` (`WHERE 1 = 1` to
  mean all), a default `LIMIT` with **explicit truncation notice**, and the
  statement must match the tool it arrived through. Tools above the tier are
  not advertised at all.
- `schedule` and `admin` are the dangerous tiers and warn at startup: a job
  outlives the session and runs as its **definer**; `GRANT` lets a role
  grant itself anything.
- Password from `SKAIDB_MCP_PASSWORD` or a `0600` `password_file` — never a
  CLI flag (`ps`). Refuses a role holding global ADMIN unless
  `--allow-superuser`; a role that cannot read `SHOW GRANTS` is treated as
  the expected non-superuser case.
- Limits: grants are table-level (no column scoping; a procedure cannot
  project around it — invoker rights — but a definer-run **job** maintaining
  a projection table can); `--timeout-secs` bounds the agent's wait, not the
  node's work; a write that fails on a dead socket may still have applied.
- **Prompt injection**: rows are model inputs, so any table an untrusted
  party can write to is an injection vector. Capability restriction is the
  only real control.
- Full details: `docs/MCP.md`.

## 10c. Backup & restore surface

- **Witness** = continuous off-site mirror (see §7/CLUSTERING.md): near-live,
  queryable read-only, GC-coordinated deletes. Machine/site-loss answer.
- **`skaidbsh backup-pull -o dir/`** (ADMIN): pull-to-client backup —
  `GET /admin/backup.tar` streams the node's data dir as chunked tar
  (curl-able); immutable bulk streams unlocked (convergent passes), one
  short exclusive cut at the end + manifest prune. Per node.
- **`BACKUP TO '/path'`** (ADMIN): server-side, engine-lock consistent
  snapshot; INCREMENTAL when the target already holds a backup (unchanged
  files reused, stale ones deleted — result is always a full faithful
  snapshot at delta cost); on a cluster each node backs up ITS OWN shard;
  the path is the SERVER's filesystem — nothing streams to the client.
  `RESTORE FROM` standalone only; cluster restore = stop node, restore dir
  offline, start, repair converges.
- **Point-in-time recovery** (`storage.wal_archive_dir`, off by default):
  sealed WAL segments are archived instead of deleted after their flush, and
  `RESTORE FROM '<path>' TO TIMESTAMP '<when>'` replays them onto a backup up
  to that instant. Replay is IDEMPOTENT (records keep their original HLC and
  the engine is LWW), so only the END of the window is exact — that cut is
  what leaves the bad write unreplayed. Retention = the recovery WINDOW;
  `wal_archive_max_lag_secs` = its RESOLUTION (flush is size-triggered, so a
  quiet table would otherwise hand the archive nothing for a day). REFUSED
  when no archive is configured, the instant is unparseable, or the window
  crosses a SCHEMA CHANGE (the archive holds row mutations only, so DDL in
  the window cannot be replayed and its rows would be dropped). Replay runs
  through the same apply path a REPLICATED write takes, so secondary/FTS/
  vector/geo indexes are all maintained — a recovered row answers MATCH and
  NEAREST, not just a scan (change streams therefore re-observe replayed
  rows). TS tables replay too, cut on the sample's ARRIVAL time, not its own
  `ts`, and they ROLL their WAL segment to the archive on the same
  `wal_archive_max_lag_secs` timer as row tables (a roll, not a flush —
  blocks and compaction cadence untouched), so recovery resolution is
  uniform. On a
  cluster: stop every member, restore each to the SAME instant offline,
  start — independent replay converges, so no coordination protocol.
  Watch `skaidb_wal_archive_backlog_segments`: non-zero = the archive
  refused a segment, so the node RETAINED it (growing disk beats a gapped
  archive that still looks restorable).
- **`skaidbsh export`** / **`skaidbsh import`**: client-side logical
  dump/load over the wire (works with `--local` too). export → schema.sql +
  per-table .jsonl/.csv; import → replays schema, batched multi-row
  INSERTs, idempotent (plain INSERT replaces on PK). JSON round-trips
  value-identical; CSV loses empty-string-vs-NULL; timestamps→epoch-ms
  ints, uuid/decimal→strings (schemaless, loads fine). schema.sql carries
  every index's canonical DDL verbatim from SHOW INDEXES' `definition`
  column — vector (DIM/metric/QUANTIZED/EMBED), search, geo, UNIQUE and
  global indexes all reconstruct exactly on import, and free-standing
  sequences as CREATE SEQUENCE. Constraints (FKs, CHECKs as DROP IF EXISTS
  + ADD, NOT NULL / DEFAULT as ALTER COLUMN SET, identity columns as DROP
  IDENTITY IF EXISTS + ADD GENERATED, generated columns as DROP EXPRESSION
  IF EXISTS + ADD GENERATED … STORED) go to constraints.sql, replayed
  AFTER the data so load order never matters, followed by `SELECT
  setval(…)` per sequence so imported counters continue where the export
  stopped; import loads GENERATED ALWAYS tables with OVERRIDING SYSTEM
  VALUE and drops generated columns from the rows (the ADD recomputes
  them).
- NOT built (open gaps): remote object-store backup targets (S3/GCS);
  everything above — PITR/WAL archiving, incremental `BACKUP TO`,
  `BACKUP CLUSTER`, streamed `GET /admin/backup.tar` — is built and
  restore-drilled.

## 11. Recipes & pitfalls checklist (for agents)

1. **Quotes**: strings `'single'`, identifiers `"double"`. A double-quoted
   "string" becomes an identifier lookup and usually a type error.
2. **One statement per request**; no `;`-chaining.
3. **REST is stateless** — use `{"sql":..., "db":...}` or `db.table`; `USE`
   only helps on binary-protocol sessions.
4. **Search visibility**: after INSERT, a search from *another* connection
   may lag up to `refresh_ms` (+200 ms). Same-session searches see their
   own writes. For tests, create the index `WITH (refresh_ms = 0)`.
5. **`ORDER BY score()` requires `LIMIT`** and is DESC-only.
6. **Search + ordinary predicates**: combine with top-level `AND` only;
   `OR`/`NOT` across the search/ordinary boundary is rejected by design.
7. **TS tables**: every insert needs all SERIES KEY labels + `ts`; UPDATE/
   DELETE are rejected; use RETENTION/rollups for lifecycle.
8. **Transactions**: embedded only. On a cluster, design idempotent
   statements instead.
9. **Joins**: no cluster pushdown — keep one side small.
10. **Schema evolution**: just write new fields; missing fields read NULL.
    `ALTER TABLE ... RENAME COLUMN` exists when a rename must be physical.
11. **Counting**: `COUNT(DISTINCT x)` is always exact;
    `APPROX_COUNT_DISTINCT(x)` opts into a sketch on the search pushdown.
12. **Diagnosing relevance**: `EXPLAIN SCORE ... FOR <pk>` (SQL) or
    `"explain": true` (ES) → full BM25 breakdown; `HIGHLIGHT()` shows what
    matched; `SUGGEST` catches typos.
13. **Monitoring**: `SHOW STATUS` (SQL) == `GET /metrics` (Prometheus);
    `/status` for topology; enable `observability.self_scrape` to dashboard
    the node from itself; Grafana points its Prometheus datasource at
    `http://node:7080`. Flusher health: `skaidb_storage_memtable_max_bytes`
    (largest single-engine memtable) stays at or under
    `skaidb_storage_flush_threshold_bytes` when healthy — the threshold is
    memory-plan-sized, so compare the two rather than assuming a constant;
    `skaidb_flusher_last_tick_age_seconds` (heartbeat of the background
    flusher/maintenance thread) and `skaidb_maintenance_longest_job_seconds`
    (SHOW MAINTENANCE as a gauge) catch a wedged thread directly.
14. **Backups/repair**: `BACKUP TO '/path'` takes a crash-consistent
    node-local backup; `RESTORE FROM` restores it (standalone — on a
    cluster restore the node offline and let repair converge it). The
    table is the source of truth for every derived index; `REBUILD
    SEARCH INDEX` and automatic rebuild-on-open cover index damage.
    `REPAIR CLUSTER` converges replicas; `RECLAIM` frees space after
    topology changes.
15. **Upgrades**: packaged installs via apt/dnf; every node restarts into
    the new version; search indexes rebuild automatically when their
    on-disk schema version changes (one-time cost proportional to table
    size).
