weftgraph 0.1.0

Graph Storage gear: typed, multi-tenant knowledge graph with search and traversal over a pluggable store
Documentation
# weftgraph

> **`weftgraph` is a code name, not a product name.** This crate is a preview
> release of a graph-storage gear built for CF/Gears and consumed by
> Constructor Studio, published from the fork
> [vasylcf/gears-rust]https://github.com/vasylcf/gears-rust so that `cargo
> add` is all a consumer needs. It is deliberately *not* published under the
> `cf-gears-*` namespace: the name a first-party graph-storage gear eventually
> takes is Constructor Fabric's to choose, and this crate must not stand in
> its way. In-tree the crate is `cf-gears-graph-storage`, and the library it
> builds keeps its `graph_storage` name, so moving to an official crate later
> is a one-line change in a consumer's manifest.

The Graph Storage gear: a typed, multi-tenant knowledge graph with bounded
traversal and hybrid (lexical + vector) retrieval, over PostgreSQL 19 with
SQL/PGQ and pgvector.

The gear is a stateless gateway over a pluggable store. Its public API is the
`cf-gears-graph-storage-sdk` crate: the `GraphStorageClientV1` trait for
in-process consumers, the REST surface under `/graph-storage/v1`, and the
plugin contracts (`GraphStoreV1`, `GraphEngineV1`, `EmbeddingProviderV1`).

- [PRD]../docs/PRD.md, [DESIGN]../docs/DESIGN.md, [ADRs]../docs/ADR/
- Base ontology schemas the gear registers at boot: [`schemas/`]./schemas/
- Known gaps between these documents and the code: [below]#known-limitations

## Requirements

PostgreSQL 19 with the `vector` extension. `CREATE PROPERTY GRAPH` is probed at
boot: on a server without it the property-graph migration is skipped and every
traversal hop is served by the portable two-query backend, with a logged reason.

## Configuration

```yaml
graph-storage:
  database:
    server: "pg_graph"          # the PostgreSQL 19 server alias
    dbname: "graph_storage"
  config:
    traversal_hop: auto         # auto (default) | pgq | two_query -- `pgq` is a
                                # demand: without SQL/PGQ the gear is not ready
    embedding_dimension: 384    # fixed at migration time
    embedding_provider: onnx    # fake | onnx | remote
    # onnx (feature `onnx`)
    embedding_model_path: /app/models/minilm/model.onnx
    embedding_tokenizer_path: /app/models/minilm/tokenizer.json
    # remote (feature `remote`)
    # embedding_remote_base_url: "https://api.openai.com/v1"
    # embedding_remote_model: "text-embedding-3-small"
    # embedding_remote_api_key_env: "GRAPH_STORAGE_EMBEDDING_API_KEY"
```

One deployment runs one embedding provider. The gear records the provider's
embedding-space identity on first use and, on a later boot with a different
provider, blocks the vector arm until the graph is re-embedded — lexical search,
traversal and ingest keep working.

## Features

| feature  | what it links                                                  |
| -------- | -------------------------------------------------------------- |
| `onnx`   | in-process ONNX provider (`cf-gears-graph-storage-onnx-embedding-plugin`) |
| `remote` | OpenAI-compatible endpoint provider (`cf-gears-graph-storage-remote-embedding-plugin`) |

Both are off by default: a deployment links the provider it selected.

## Testing

```sh
# Everything that needs no database: unit tests, the conformance suite against
# the in-memory store, the domain service, the REST surface.
make test-graph-storage

# The same conformance suite against a real PostgreSQL 19, plus the cases only
# a server can answer (the SQL/PGQ hop, its parity with the fallback, the
# cross-tenant trap). It needs a server with SQL/PGQ *and* pgvector, which no
# published image carries yet, so build the one this gear pins:
docker build -f gears/graph-storage/docker/pg19-pgvector.Dockerfile \
  -t pg19-pgvector:latest gears/graph-storage/docker
GEARS_TEST_PG_GRAPH_IMAGE=pg19-pgvector:latest make test-graph-storage-pg
```

Without `GEARS_TEST_PG_GRAPH_IMAGE` the PostgreSQL lane skips with a named
reason and starts nothing; `GEARS_TEST_PG_GRAPH_REQUIRED=1` (which the Make
target sets) turns that skip into a failure, so CI cannot pass by running
nothing.

Every case in that lane gets its own server — two of them are operator surgery
on server-wide state — so it runs at a bounded concurrency
(`GRAPH_PG_TEST_THREADS`, default 2, and `GEARS_TEST_PG_GRAPH_STANDS` for the
in-process runner). More than that on an ordinary machine and the connection
pools start timing out, which reads as a flaky gear and is a busy host.

A case that panics can leave its container behind — the removal is
asynchronous, and nothing awaits it — so after a failed run
`docker ps --filter ancestor=pg19-pgvector:latest` is worth a glance; a
handful of forgotten servers is enough to make the next run look flaky too.

## Using it from a producer

Answers to the questions producers have asked while building on the gear.
Each is the shipped behaviour, with the contract that fixes it.

- **Registering an ontology is one batch, atomically** (`fr-type-registration`):
  one type that conflicts refuses the whole batch and registers nothing, and
  the refusal names the type. A type that drifted compatibly (a description, an
  optional field) is updated in place with `options.on_existing: update`, and
  `POST /types/compatibility` says beforehand what a change would cost;
  an incompatible change needs a new major or `options.revalidate`. Registering
  one type at a time is a valid fallback, not a workaround.
- **`expected_version` is the one conditional write.** A stored version is 1 or
  more and advances on every update; `Some(n)` requires exactly `n` and is a
  conflict otherwise, including when no node is stored under the key.
  `Some(0)` means "there must be none": two writers claiming one key with it
  get exactly one success, which is how a version number is claimed across
  replicas without a lock. No read returns the version yet (README § Known
  limitations), so a read-then-update is last-writer-wins until it does. Edges
  carry no version: their identity is derived from their endpoints.
- **Scope replacement removes what carries the scope.** `replace_scope:
  {attribute, value, generation}` removes, after the batch's own writes, the
  nodes of `scope_managed` types whose payload field `attribute` equals
  `value` and that the batch did not re-supply, and the `static` edges the
  scope declared (edges record the scope that declared them). A node whose
  payload does not carry the attribute is not the scope's and is never removed
  -- a replacement that "does nothing" is a payload without the field. The
  generation is monotonic per scope; an equal generation with different
  content is a conflict.
- **A tombstoned node key is not reusable before purge**, and purge is not
  built; a tombstoned edge is revived by the next upsert that names it
  (DESIGN § Soft Delete Contract, rules 4 and 6).
- **Adjacency on a node read is bounded per direction** by
  `node_read_max_adjacency` (100 by default, at most 1 000): outgoing and
  incoming are cut separately, so one read carries up to twice that many
  entries, with a truncation flag and no cursor. To list every edge of a hub,
  walk it: `POST /graph/traverse` with the node as the seed and depth 1 returns
  its edges, up to `traversal_max_nodes` (1 000 by default, at most 10 000)
  neighbours.
- **`$filter` and `$orderby` reach payload attributes the type declares** in
  its `index` trait (`payload/severity`). Equality on a string, boolean or
  date-time value is served by one GIN over the payload; equality on a number,
  every range comparison and ordering over a payload path are admitted and
  unindexed, which is a scan of the type's rows.
  A type that declares no `index` paths can be filtered by `node_key`, `name`,
  `created_at` and `updated_at` only, which is a scan of the tenant. The audit
  envelope's `updated_at` is when the gear last wrote the row, not when the
  object changed; an object's own time belongs in its payload.
- **Readiness for a probe is the platform's `/readyz`**, which runs this gear's
  healthcheck and takes the pod out of traffic when a component is unhealthy;
  the gear's own `GET /graph-storage/v1/health/ready` is the detailed state
  document and answers `200` whatever the state, the same split the platform
  makes between `/readyz` and `/health`. Point a probe at the gateway's
  `/readyz`, not at the gear's route.
- **Nodes and edges in one batch** commit or fail together
  (`fr-bulk-ingest`); an importer that writes nodes in one call and edges in
  another has, between the two, a graph with nodes and no edges.
- **Migrations are additive** from `v0.1.0`: no shipped migration's DDL has
  changed since it shipped (later tags add migrations), so a database created
  by any tag upgrades in place. Only a database from the vendored prototype
  needs to start empty.

## Known limitations

What the documents require and this iteration does not yet deliver, so a
reader is not left to discover it. Each is marked in the documents where it
bites ("Found while building the prototype").

**Deferred features** (the API and schema leave room; nothing is built): content
chunking and heavy-content offload; labels; change events; the admission layer
beyond per-request bounds (per-tenant and global concurrency, queues, reserved
connections, aggregate response bounds); tenant offboarding and deletion
monotonicity; the analytics topology role and metric annotation; the
index-activation lifecycle and per-path index DDL; the re-embedding lifecycle
that opens a new embedding epoch; observability counters; the retained
type-revision history; runtime plugin registration and selection (the built-in
store, engine and embedding provider are constructed directly, and no
independently developed implementation can reach the service yet; #4873).

**Narrower than documented** (built, with a stated gap):

- *Ordinary ingests do not take the shared scope lock the ingest protocol
  describes* — a scope replacement fences on a monotonic generation instead,
  which is the only serialization the secure ORM's surface allows. What
  protects an ordinary write from a concurrent replacement is that the
  replacement re-checks scope membership in the statement that removes a
  node, so a node an ingest has just moved out of the scope is not deleted.
  One residual remains: a new edge written to a node a replacement is
  removing at that moment can be lost (DESIGN § Concurrent Ingest Protocol). Producer
  identity *is* carried into the store: an idempotency receipt is keyed by
  `(tenant, producer, idempotency_key)`, and a scope records its owning
  producer and refuses a replacement submitted by anyone else. Source-namespace
  ownership (`fr-source-ownership`) *is* enforced.
- *Traversal takes explicit seed keys only*, not search hits. Retention under
  a neighborhood budget *is* degree-ordered, and a traversal *does* echo the
  seeds it admitted.
- *Traversal has two hop backends*, the SQL/PGQ pattern and the two-query hop
  (`traversal_hop: auto | pgq | two_query`); the iterative-CTE hop of ADR-0001 was
  built on the development stand and is not shipped.
- *Four specified tables are not created*: `chunk`, `label`, `label_assignment`
  and `ingest_audit` (chunking, labels and the audit record are deferred).
- *Hybrid search fails, rather than degrading to its lexical arm,* when the
  embedding provider is unavailable; lexical hits carry no snippets.
- *Compound reads on the built-in store are not one snapshot* (the platform
  offers no caller-held transaction), and the service opens a snapshot for
  traversal only. Search and projection responses still report the revision
  they observed.
- *The traversal edge-scan budget is per hop*, not per walk: a walk can scan
  the per-hop ceiling at every depth. A hop that reaches it does say so
  (`EdgeScanCap`), and the ceiling covers the whole hop -- a neighbourhood
  asking for degree-ordered retention reads its degrees out of what the
  incidence scan left, rather than out of a second allowance of the same size.
- *Every error names the node resource*, including the ones the type surface
  raises: `DomainError` does not carry which resource it is about, and one
  conversion serves every operation. A consumer switching on `resource_type`
  cannot tell a rejected type registration from a rejected ingest.
- *Reason codes for `not_found`, `unimplemented`, `deadline_exceeded`,
  `cancelled`, `unavailable`, `data_loss` and `unknown` are not on the wire*:
  the platform's builders for those categories carry no reason slot.
- *The in-process `GraphStorageClientV1` is narrower than REST*: no edge read,
  no compatibility dry run, no registration options or migrations, no
  source-namespace operations. Widening it is a `ClientV2` question.
- *Readiness does not state the active provider identity and dimension*, and
  five matrix rows report `not_implemented`.
- *The `source_epoch` is minted once and never rotates*; the snapshot-identity
  contract holds for idempotency receipts only.
- *Idempotency receipts never expire*: `idempotency_retention_days` is
  validated and read by nothing, because an expiry needs a protocol before a
  cleanup (a deleted receipt makes a late retry look new; #4874).
- *Endpoint-constraint validation runs inside the ingest transaction but not
  under row locks* — the platform's secure ORM exposes no locking surface.
- *Re-ingesting a tombstoned edge revives it* rather than refusing, so a
  delete is undone by the next batch that names the same edge. Deleting an
  already-tombstoned row settles as a no-op; only a row that never existed is
  `404`.
- *Base-ontology schemas are published once per tenant and have no update
  path*: an edit to a base schema does not reach a database that already
  published it.
- *No published image carries both PostgreSQL 19 and pgvector*, so the lane
  builds its own from [`docker/pg19-pgvector.Dockerfile`]../docker/pg19-pgvector.Dockerfile;
  `test-containers` should publish one. PostgreSQL 16, the documented
  baseline, has no lane at all.
- *Vector search needs two session parameters the gear cannot set for itself.*
  HNSW is an approximate index and pgvector applies filters **after** the
  approximate scan, while every query this gear issues carries a tenant scope,
  a `deleted_at` predicate, an embedding epoch and optionally a type set. At
  the default `hnsw.ef_search` of 40 a filter admitting a tenth of the rows
  leaves about four candidates, so a small tenant sharing an index with a large
  one can get an empty page while its own matching vectors are in the table --
  and an empty page is indistinguishable from an empty graph. A deployment
  serving vector search must therefore set, in its database configuration:

  ```yaml
  params:
    hnsw.iterative_scan: relaxed_order   # `strict_order` if exact ordering matters
    hnsw.ef_search: "200"                # tune against measured selectivity
  ```

  toolkit-db forwards unrecognized `params` keys to `PostgreSQL` as runtime
  parameters, which is the only route available: `DBRunner` exposes no
  statement surface, so the gear cannot issue `SET LOCAL` per query
  (gears-rust #4871) — and per query is the granularity this actually wants,
  since an unfiltered search should not pay for iterative scanning.
  `a_filtered_vector_search_under_returns_without_iterative_scan` demonstrates
  the collapse and the fix against a live server.
- *The `remote` embedding provider has no per-tenant egress policy in front of
  it.* ADR-0004 requires one; it is not implemented. The provider sends every
  tenant's node and query text to the one configured endpoint, and builds its
  own HTTP client rather than going through the `oagw` gear, so that gear's
  centralized egress policy, credential injection from `credstore`, rate
  limiting and audit trail are not in this path. Tracked as
  [#4877]https://github.com/constructorfabric/gears-rust/issues/4877;
  `mini-chat`'s OpenAI provider routes through `ServiceGatewayClientV1` and is
  the reference for the fix.

  **This limitation is reachable only by a deployment that opts in.** `remote`
  is a Cargo feature that is off by default: a binary built without
  `--features remote` does not link the plugin at all, and naming `remote` in
  such a build fails at boot with a message saying so rather than falling back
  to another provider. A deployment built with `onnx` alone therefore has no
  external embedding egress path, and #4877 neither applies to it nor blocks
  it. Shipping `onnx`-only is the supported way to run this gear before #4877
  lands.
- *A migration cannot rebuild the vector index without blocking the `node`
  table.* The platform's migration runner wraps every migration in a
  transaction and ignores `use_transaction()`, and `PostgreSQL` refuses
  `CREATE INDEX CONCURRENTLY` inside one, so an index change can only be
  written the way m0003 writes it: `DROP INDEX` and `CREATE INDEX` in one
  transaction. That holds an `ACCESS EXCLUSIVE` lock on `node` from the drop
  until the commit, so reads and writes both wait for the whole HNSW build (a
  `CREATE INDEX` alone would hold a `SHARE` lock, which still blocks writes).
  m0003 itself costs nothing: every migration of this gear ships in the same
  release, so it runs on an empty table at first install. A later migration
  that changes the index on a populated table needs a maintenance window, or
  has to be run as an operator step outside the runner, until the runner can
  run a migration outside a transaction
  ([#5011]https://github.com/constructorfabric/gears-rust/issues/5011).