weftgraph
weftgraphis 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 so thatcargo addis all a consumer needs. It is deliberately not published under thecf-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 iscf-gears-graph-storage, and the library it builds keeps itsgraph_storagename, 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, DESIGN, ADRs
- Base ontology schemas the gear registers at boot:
schemas/ - Known gaps between these documents and the code: below
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
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
# Everything that needs no database: unit tests, the conformance suite against
# the in-memory store, the domain service, the REST surface.
# 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:
GEARS_TEST_PG_GRAPH_IMAGE=pg19-pgvector:latest
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 withoptions.on_existing: update, andPOST /types/compatibilitysays beforehand what a change would cost; an incompatible change needs a new major oroptions.revalidate. Registering one type at a time is a valid fallback, not a workaround. expected_versionis the one conditional write. A stored version is 1 or more and advances on every update;Some(n)requires exactlynand 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 ofscope_managedtypes whose payload fieldattributeequalsvalueand that the batch did not re-supply, and thestaticedges 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/traversewith the node as the seed and depth 1 returns its edges, up totraversal_max_nodes(1 000 by default, at most 10 000) neighbours. $filterand$orderbyreach payload attributes the type declares in itsindextrait (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 noindexpaths can be filtered bynode_key,name,created_atandupdated_atonly, which is a scan of the tenant. The audit envelope'supdated_atis 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 ownGET /graph-storage/v1/health/readyis the detailed state document and answers200whatever the state, the same split the platform makes between/readyzand/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_assignmentandingest_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:
DomainErrordoes not carry which resource it is about, and one conversion serves every operation. A consumer switching onresource_typecannot tell a rejected type registration from a rejected ingest. -
Reason codes for
not_found,unimplemented,deadline_exceeded,cancelled,unavailable,data_lossandunknownare not on the wire: the platform's builders for those categories carry no reason slot. -
The in-process
GraphStorageClientV1is narrower than REST: no edge read, no compatibility dry run, no registration options or migrations, no source-namespace operations. Widening it is aClientV2question. -
Readiness does not state the active provider identity and dimension, and five matrix rows report
not_implemented. -
The
source_epochis minted once and never rotates; the snapshot-identity contract holds for idempotency receipts only. -
Idempotency receipts never expire:
idempotency_retention_daysis 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;test-containersshould 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_atpredicate, an embedding epoch and optionally a type set. At the defaulthnsw.ef_searchof 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:params: hnsw.iterative_scan: relaxed_order # `strict_order` if exact ordering matters hnsw.ef_search: "200" # tune against measured selectivitytoolkit-db forwards unrecognized
paramskeys toPostgreSQLas runtime parameters, which is the only route available:DBRunnerexposes no statement surface, so the gear cannot issueSET LOCALper 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_scandemonstrates the collapse and the fix against a live server. -
The
remoteembedding 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 theoagwgear, so that gear's centralized egress policy, credential injection fromcredstore, rate limiting and audit trail are not in this path. Tracked as #4877;mini-chat's OpenAI provider routes throughServiceGatewayClientV1and is the reference for the fix.This limitation is reachable only by a deployment that opts in.
remoteis a Cargo feature that is off by default: a binary built without--features remotedoes not link the plugin at all, and namingremotein such a build fails at boot with a message saying so rather than falling back to another provider. A deployment built withonnxalone therefore has no external embedding egress path, and #4877 neither applies to it nor blocks it. Shippingonnx-only is the supported way to run this gear before #4877 lands. -
A migration cannot rebuild the vector index without blocking the
nodetable. The platform's migration runner wraps every migration in a transaction and ignoresuse_transaction(), andPostgreSQLrefusesCREATE INDEX CONCURRENTLYinside one, so an index change can only be written the way m0003 writes it:DROP INDEXandCREATE INDEXin one transaction. That holds anACCESS EXCLUSIVElock onnodefrom the drop until the commit, so reads and writes both wait for the whole HNSW build (aCREATE INDEXalone would hold aSHARElock, 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).