<!-- nornir:class: design -->
# `knut-graphdb` — one graph client, folded from two
> **Class:** `design`. The trait every consumer reads a graph endpoint through, and
> what the 2026-09-01 fold of korp's `GraphStore` into knut's `GraphDb` changed.
> **Source:** `crates/knut-graphdb/` — `GraphDb`, `Cypher`, `FalkorBackend`,
> `CozoBackend`.
## The sin it fixes
There were **two FalkorDB clients**, each holding half a seam:
| | knut `GraphDb` | korp `GraphStore` |
|---|---|---|
| list the graphs on an endpoint | — | `graph_names` |
| open one for its catalogue | — | `open` |
| read a scene | `query` (Cypher in the signature) | `read` (no query language) |
| expand a node | `neighbours` | — |
| shortest path | `shortest_path` | — |
| write rows | `upsert` | — |
| colour the result | — | `scene` + `Legend` |
Neither was complete, and because `graphar_flight::FalkorExecutor` knew only
`GRAPH.QUERY`, korp additionally opened **three `redis::Client`s of its own** — for
`GRAPH.LIST`, `GRAPH.DELETE` and `GRAPH.CONFIG GET`. A FalkorDB read could therefore
be issued from four different files with four different error vocabularies.
They are one trait now, and it lives in knut. **`korp → knut`; `knut → korp` is
zero**, guarded by `crates/knut-graphdb/tests/no_korp_dependency.rs` (18 manifests +
269 sources, 3.6 MB, reported).
## The trait
```
kind · endpoint · graph_names · open · read · neighbours · shortest_path · upsert
```
Five are required (`kind`, `endpoint`, `graph_names`, `open`, `read`); the rest are
provided and refuse honestly with `GraphError::Unsupported`. **The smallest complete
backend is five functions long and contains no stub that can panic.**
> A `GraphDb` is a **CLIENT**. It takes an endpoint and talks to it. It never starts a
> process and it never knows containers exist.
### Cypher is optional, and it is not on the base trait at all
`read(graph, limit)` takes a graph **name** and a row cap. No query language appears
anywhere in a base signature. A backend with no query language — a GraphAr file set, a
knut projection, an in-memory scene, **Cozo** — is a complete implementor rather than
a degraded one: it has no method to stub, so it has no `unimplemented!()` to panic
from. Cypher is the separate `Cypher` extension trait plus a `CypherSupport`
advertisement, and `no_cypher_here()` is the typed refusal a caller holding only a
`&dyn GraphDb` gets.
That claim is checked by the type system, not by a comment. Move `cypher` onto
`GraphDb` as a required method and `CozoBackend` stops compiling with `E0046`.
### `open()` LISTS BEFORE IT PROBES, and that order is load bearing
FalkorDB **creates a graph key on the first `GRAPH.QUERY` against an unknown name.**
An `open` that probed first would therefore make the probe itself the reason the
answer is "yes": the existence check could never fire, and a typo'd graph name would
leave a stray empty graph on the endpoint.
Observed live by korp's `graph-trait` lane on 2026-08-31 and re-observed on
2026-09-01 while proving the guard red: a probe planted before the check made
`open("knut_absent_2283864")` return `Ok(GraphSchema { node_count: 0, .. })` — the
blank pane that was really a 404 — and left `knut_absent_2283864` in `GRAPH.LIST`
afterwards. It costs one extra round trip per open. Correctness wins.
### Empty ≠ unreachable ≠ missing ≠ timed out
Four distinct `GraphError` variants, never prose:
| answer | type |
|---|---|
| reachable, zero graphs | `Ok(vec![])` |
| unreachable endpoint | `GraphError::Connect` |
| the graph is not there | `GraphError::NotFound` |
| past the time bound | `GraphError::Timeout` |
| the backend has no query language | `GraphError::NoQueryLanguage` |
A backend that folds a connect failure into an empty list is wrong on this seam.
## Colour is NOT here, and that is deliberate
A scene's colour legend stayed **korp-side** (`korp::graphstore::GraphScene`, built
from `korp::graphnav::label_rgb`). Three reasons, in order of weight:
1. **It would import an ontology into a base crate.** The hues are pinned to korp's
own labels — `Person`, `Vessel`, `PoliceRecord` — which is exactly the dependency
`GraphNode::natural_key` exists to avoid: knut hands back the *raw* key and korp
resolves it. A palette keyed by korp's labels is the same cycle wearing a hat.
2. **A second consumer would inherit the wrong palette.** knut's other graph
consumers (thund, popviz, knut-flight) have their own label vocabularies.
3. **The legend belongs with the thing that paints it.** korp's is built by
construction from the labels the 2D `facett_graphnav` canvas and the 3D
`facett_graph3d` cloud actually paint, and korp's own test asserts the seam's
legend equals the hue the 3D path draws. Moved here, that check could not exist.
What this crate owes a renderer is the **labels**. The renderer owns the colours.
## The backends
| backend | feature | reaches | Cypher |
|---|---|---|---|
| `FalkorBackend` | `falkordb` (default) | one FalkorDB / Valkey endpoint, optionally over mutual TLS | yes |
| `CozoBackend` | `cozo` | an embedded CozoDB `mem` store — no container, no redis, no socket | **no, and it is complete** |
`CozoBackend` became genuinely **multi-graph** in the fold: its two stored relations
are keyed by graph name, so it answers `graph_names` / `open` / `read` the way a real
endpoint does. korp gained a container-free graph backend it did not have.
## One client, provably
`graphar_flight::FalkorExecutor` is the only file in the constellation that builds a
`redis::Client` for graph work, and it now carries the whole verb set —
`GRAPH.QUERY`, `GRAPH.LIST`, `GRAPH.DELETE`, `GRAPH.CONFIG GET` — precisely so no
caller has to open a connection to send one. That is what the second client was.
`korp/tests/graphstore_one_falkor_client.rs` scans both repos' production sources
(233 files, 8.97 MB, reported) and asserts: **korp speaks zero FalkorDB**, exactly one
file serves the graph seam, and the only other speaker is `graphar-falkordb`'s bulk
loader — a different protocol (`GRAPH.BULK`), named so a third cannot appear silently.
## What is NOT folded
`graphar-falkordb`'s **bulk loader** stays its own client. It speaks the binary
`GRAPH.BULK` insert format, not the query protocol, and it is a writer of whole
datasets rather than a reader of scenes. Folding it would put two unrelated protocols
behind one type.