kglite-c — the C ABI for cross-language bindings¶
Status: Implemented in
crates/kglite-c/and published as a source crate. The generated header is committed atcrates/kglite-c/include/kglite.h. Precompiled C ABI libraries are not currently attached to releases; consumers build the platform library from the crate source.
Future-language bindings for kglite (Go via cgo, JavaScript via napi,
JVM via JNI, .NET via P/Invoke, …) reach the engine through a stable
C ABI rather than directly against kglite::api::* Rust items. This
doc fixes the design conventions before implementation so the
shape doesn’t drift.
Phase H ran in 5 sub-phases:
Sub-phase |
What |
|---|---|
H.1 (this doc) |
Design conventions + sample signatures |
H.2 |
|
H.3 |
Datasets + embedder ABI |
H.4 |
~500 LOC Go PoC consumer (validates the surface) |
H.5 |
Release coordination + |
Goals (from CLAUDE.md North Star)¶
The C ABI exists to satisfy the four binding-author goals:
Quick + easy — a new non-Rust wrapper is <1500 LOC total of FFI glue + language-native idioms. Currently a Go binding would be ~3000 LOC (Rust glue + cgo + Go ergonomics). C ABI cuts the Rust-glue layer (~330 LOC saved per non-Rust binding) by paying that cost once, here.
Standardized — every non-Rust binding consumes the same C header. A Go binding’s
Cypher()method and a JS binding’s.cypher()method are both ~30 LOC wrappers over the samekglite_execute_cypher_read(...).Centrally maintained — when core adds a feature (a new Cypher function, a new error code, a new api item), every non-Rust binding sees it through a pin-bump of
kglite-c, not per-binding source changes.Flexible — the C ABI is additive (new functions, new error codes, opaque-handle struct evolution). Existing bindings never break on a new feature.
Architecture¶
Rust-side wrappers
─────────────────────
crates/kglite-py/ ─→ kglite::api::* (Rust types, traits)
crates/kglite-bolt-server/ ─→ kglite::api::*
crates/kglite-mcp-server/ ─→ kglite::api::*
Non-Rust bindings
─────────────────────
external/kglite-go/ ─→ kglite-c ─→ kglite::api::*
external/kglite-js/ ─→ kglite-c ─→ kglite::api::*
external/kglite-jvm/ ─→ kglite-c ─→ kglite::api::*
external/kglite-dotnet/─→ kglite-c ─→ kglite::api::*
kglite-c is a wrapper crate. It depends on kglite and exposes
#[no_mangle] extern "C" functions wrapping the same primitives
that kglite::api::* does — same lifecycle, same Cypher pipeline,
same error codes, just C-shaped.
Conventions¶
1. Naming¶
Every C function is prefixed kglite_. Module / type structure
maps to underscored prefixes:
Rust path |
C prefix |
Example |
|---|---|---|
|
|
|
|
|
… |
|
|
… |
|
|
… |
Opaque-handle struct types use Kglite PascalCase + the unit name:
KgliteGraph, KgliteSession, KgliteTransaction,
KgliteCypherResult, KgliteEmbedder.
2. Opaque-handle pattern¶
Every Rust type that crosses the C boundary becomes a
forward-declared C struct. The C-side caller only ever sees
*mut KgliteX pointers; the Rust-side Box/Arc lives behind
them.
// kglite.h (excerpt, generated by cbindgen)
typedef struct KgliteGraph KgliteGraph;
typedef struct KgliteSession KgliteSession;
typedef struct KgliteTransaction KgliteTransaction;
typedef struct KgliteCypherResult KgliteCypherResult;
typedef struct KgliteEmbedder KgliteEmbedder;
// crates/kglite-c/src/lib.rs (excerpt)
/// Wrapper struct so the C side has a stable type name. The actual
/// state lives inside; cbindgen treats the struct as opaque.
#[repr(C)]
pub struct KgliteGraph {
inner: std::sync::Arc<kglite::api::DirGraph>,
}
#[no_mangle]
pub extern "C" fn kglite_load_file(path: *const c_char) -> *mut KgliteGraph {
let Ok(path_str) = unsafe { CStr::from_ptr(path) }.to_str() else {
return std::ptr::null_mut();
};
match kglite::api::load_file(path_str) {
Ok(arc) => Box::into_raw(Box::new(KgliteGraph { inner: arc })),
Err(_) => std::ptr::null_mut(),
}
}
#[no_mangle]
pub extern "C" fn kglite_graph_free(g: *mut KgliteGraph) {
if !g.is_null() {
unsafe { let _ = Box::from_raw(g); }
}
}
The caller must call kglite_<Type>_free(*mut T) for every handle
they receive. Forgetting leaks; calling twice is UB.
3. Memory ownership rules¶
Every function documents who owns what:
Arguments passed by
*const c_char/*const Tare borrowed for the call only. The Rust side copies what it needs. Caller remains responsible for freeing the original.Arguments passed by
*mut T(opaque handle) are borrowed for the call. Caller still owns; must free later.Return values that are
*mut T(opaque handle) are OWNED by the caller. Must be freed via the type’skglite_<type>_free(...)function.Return values that are
*const c_charare OWNED by the caller. Must be freed viakglite_free_string(*const c_char).Function returns by-value primitive (u32 / i64 / bool / KgliteStatusCode) — no allocation, no ownership concern.
Every function’s documentation explicitly names the owner of every allocated pointer it touches.
4. String handling¶
C has no native string type; we use UTF-8 *const c_char
(null-terminated) for both directions.
Inputs (caller → kglite): caller passes *const c_char. Rust
side validates UTF-8 (CStr::from_ptr(ptr).to_str()). Invalid
UTF-8 returns an error code; never panics.
Outputs (kglite → caller): kglite allocates the string,
returns *const c_char. Caller MUST call:
void kglite_free_string(const char* s);
…to release it. Internally this calls CString::from_raw to drop
the owned CString. There is exactly ONE kglite_free_string,
not per-context variants.
5. Error pattern¶
Errors use the errno-style “return code + out-param” pattern:
typedef enum KgliteStatusCode {
KGLITE_OK = 0,
// 1-99: client errors (KgErrorCode::CypherSyntax etc.)
KGLITE_ERR_CYPHER_SYNTAX = 1,
KGLITE_ERR_CYPHER_TIMEOUT = 2,
KGLITE_ERR_CYPHER_EXECUTION = 3,
KGLITE_ERR_CYPHER_TYPE_MISMATCH = 4,
KGLITE_ERR_SCHEMA = 5,
KGLITE_ERR_VALIDATION = 6,
KGLITE_ERR_EXPR = 7,
KGLITE_ERR_NODE_NOT_FOUND = 8,
KGLITE_ERR_CONNECTION_NOT_FOUND = 9,
KGLITE_ERR_PROPERTY_NOT_FOUND = 10,
KGLITE_ERR_FILE_NOT_FOUND = 11,
KGLITE_ERR_FILE_FORMAT = 12,
KGLITE_ERR_FILE_IO = 13,
KGLITE_ERR_INVALID_ARGUMENT = 14,
KGLITE_ERR_MISSING_ARGUMENT = 15,
KGLITE_ERR_INTERNAL = 16,
// 100+: C-ABI-specific errors not in KgErrorCode
KGLITE_ERR_INVALID_UTF8 = 100,
KGLITE_ERR_NULL_POINTER = 101,
} KgliteStatusCode;
Functions that can fail return KgliteStatusCode and take output
parameters:
KgliteStatusCode kglite_session_execute_read(
const KgliteSession* session,
const char* query,
const char* params_json, // JSON object as UTF-8 string
KgliteCypherResult** out_result, // OUT: owned handle on success
const char** out_error_msg // OUT: owned string on failure (free via kglite_free_string)
);
The C-side caller’s pattern:
KgliteCypherResult* result = NULL;
const char* err_msg = NULL;
KgliteStatusCode rc = kglite_session_execute_read(
sess, "MATCH (n) RETURN count(n)", NULL,
&result, &err_msg
);
if (rc != KGLITE_OK) {
fprintf(stderr, "execute failed: %s\n", err_msg);
kglite_free_string(err_msg);
return rc;
}
// ... use result ...
kglite_cypher_result_free(result);
Every writable output slot is initialized before input validation: owned
handles/strings become NULL, lengths/counts become zero, and the optional
out_error_msg becomes NULL. On success, result slots receive their owned
values. Engine failures normally set owned error text; boundary-only validation
codes such as invalid UTF-8 may leave the initialized error slot null.
All exports contain Rust panics raised by otherwise valid calls. A panic in a
status-returning function becomes KGLITE_STATUS_CODE_INTERNAL and, when the
function has an out_error_msg slot, an owned diagnostic string. Direct value
accessors return their documented null/zero fallback and destructor-style
functions return without unwinding into the host. This does not make invalid,
dangling, or incorrectly typed caller pointers safe; those remain outside the
C ABI contract.
The three kglite_status_code_* accessors likewise require a declared
KgliteStatusCode value, not an arbitrary integer cast to the enum. Supplying
an invalid enum discriminant is an invalid C call and cannot be repaired by a
Rust panic guard without changing the ABI signature.
Status codes 1-16 match KgErrorCode variants 1:1 (in declaration
order). Helper for bindings that want to map status code back to
the canonical name / category:
const char* kglite_status_code_name(KgliteStatusCode code); // "CypherSyntax", "Internal", etc.
const char* kglite_status_code_neo4j_status(KgliteStatusCode code); // "Neo.ClientError.Statement.SyntaxError", etc.
uint16_t kglite_status_code_http_status(KgliteStatusCode code); // 400, 404, 500, etc.
These wrap KgErrorCode::{as_str, neo4j_status_code, http_status_code}.
6. Sync only — bindings own async¶
The C ABI is fully synchronous. Any async core call is exposed
through its _blocking companion (the _blocking pattern added in
Phase 5). Raw async crossings are NOT in v1 — Go uses goroutines,
JS uses worker threads, JVM uses thread pools, each over their own
runtime.
If a binding wants async I/O, it spawns the sync C call from a
thread / coroutine / async-task in its own runtime. This is
exactly how the Bolt server (tokio) wraps the sync execute_read
today.
7. JSON at the boundary for complex types¶
Value, CypherResult, CypherQuery AST, Blueprint schema —
these are nested Rust structures with no clean C representation.
Rather than design a complex value-tree C ABI, we use JSON at
the boundary:
Parameters: caller passes JSON-string
params_jsontokglite_session_execute_read. The C side parses via the liftedkglite::api::param::json_value_to_kglite_value(Phase B.2, just shipped).Results:
KgliteCypherResulthas accessor functions that return JSON strings:const char* kglite_cypher_result_columns_json(const KgliteCypherResult*); const char* kglite_cypher_result_rows_json(const KgliteCypherResult*);
The caller parses the JSON in their language (Go:
encoding/json; JS:JSON.parse; JVM: Jackson / Gson; .NET:System.Text.Json).
JSON-at-boundary chosen because every modern language has a fast, correct JSON parser in stdlib. The alternative (a custom binary record format) would require us to ship a parser library in each target language. Not worth it for v1.
Future v2 can add a Cap’n Proto / Protobuf / Arrow variant for performance-critical row streaming.
8. Versioning¶
kglite-c versions track kglite’s minor version
(kglite 0.11.x → kglite-c 0.11.x). Patch versions independent.
A kglite_abi_version() function returns the ABI version:
typedef struct {
uint32_t major;
uint32_t minor;
uint32_t patch;
} KgliteAbiVersion;
KgliteAbiVersion kglite_abi_version(void);
Bindings call this on startup; if major doesn’t match what they
were compiled against, they fail loudly rather than risk segfaults.
Within a major version: additive changes only (new functions, new status codes, new opaque types). Existing function signatures never change. Removing a function bumps the major.
Top-10 entry points (H.2 implementation target)¶
For Phase H.2 we ship these 10 functions first. Everything else follows the same conventions and can be added incrementally (H.3 adds datasets + embedder).
Lifecycle (3)¶
KgliteStatusCode kglite_load_file(
const char* path,
KgliteGraph** out_graph,
const char** out_error_msg
);
KgliteStatusCode kglite_save_graph(
const KgliteGraph* graph,
const char* path,
const char** out_error_msg
);
void kglite_graph_free(KgliteGraph* graph);
Session + transactions (3)¶
KgliteStatusCode kglite_session_new(
KgliteGraph* graph, // moves ownership: callers don't free the graph after this
KgliteSession** out_session
);
KgliteStatusCode kglite_session_snapshot(
const KgliteSession* session,
KgliteGraph** out_snapshot // borrowed snapshot, must be freed
);
void kglite_session_free(KgliteSession* session);
Cypher pipeline (2)¶
// Run a read query, returning a result handle that the caller
// iterates / serializes / frees.
KgliteStatusCode kglite_session_execute_read(
const KgliteSession* session,
const char* query,
const char* params_json, // may be NULL or "{}"
KgliteCypherResult** out_result,
const char** out_error_msg
);
// Run a mutating query. The session's underlying graph is updated
// in place (the commit-swap semantics are kglite's existing
// transaction model).
KgliteStatusCode kglite_session_execute_mut(
KgliteSession* session,
const char* query,
const char* params_json,
KgliteCypherResult** out_result,
const char** out_error_msg
);
Result accessors (2)¶
// Return the column-name list as JSON: ["col1", "col2", ...]
const char* kglite_cypher_result_columns_json(const KgliteCypherResult*);
// Return all rows as JSON: [{"col1": val, ...}, ...]
// For large result sets, consider future v2 streaming accessor.
const char* kglite_cypher_result_rows_json(const KgliteCypherResult*);
void kglite_cypher_result_free(KgliteCypherResult*);
Error introspection (1)¶
// All three accessors are inline-safe (no allocation):
const char* kglite_status_code_name(KgliteStatusCode code);
const char* kglite_status_code_neo4j_status(KgliteStatusCode code);
uint16_t kglite_status_code_http_status(KgliteStatusCode code);
// Free a string returned by any kglite function.
void kglite_free_string(const char* s);
ABI version (1)¶
KgliteAbiVersion kglite_abi_version(void);
That’s the H.2 surface — 15 functions (lifecycle 3 + session 3 + Cypher 2 + result 3 + error 3 + ABI 1). Enough to demonstrate the binding pattern end-to-end with the H.4 Go PoC.
H.3 surface (embedder, after H.2)¶
Once H.2 is shipped + the Go PoC validates the surface, H.3 adds:
Datasets¶
The pre-packaged dataset loaders (SEC EDGAR, Sodir, Wikidata) are no
longer part of the kglite core API surface — they live in the
separate kglite-datasets project, and the C ABI no longer exports
kglite_datasets_* functions. kglite loads the graphs those loaders
produce via the ordinary lifecycle functions (kglite_load_file,
etc.). A non-Rust binding that wants dataset ingestion binds to
kglite-datasets separately.
Embedder (concrete impls only in v1)¶
Trait objects (Arc<dyn Embedder>) can’t cross C. So v1 ships
factory functions for known concrete implementations:
#ifdef KGLITE_FEATURE_FASTEMBED
KgliteStatusCode kglite_embedder_fastembed_new(
const char* model_name, // e.g. "BAAI/bge-m3"
KgliteEmbedder** out
);
#endif
KgliteStatusCode kglite_graph_set_embedder(
KgliteGraph* graph,
KgliteEmbedder* embedder // moves ownership
);
void kglite_embedder_free(KgliteEmbedder*);
v2 adds a user-supplied-embedder pattern (function pointer + opaque context), so bindings can plug in OpenAI / Cohere / custom embedders. Out of scope for H.3.
Blueprint (post-datasets)¶
KgliteStatusCode kglite_blueprint_load_file(
const char* path,
KgliteBlueprint** out
);
KgliteStatusCode kglite_blueprint_build(
KgliteBlueprint* blueprint,
const char* input_root,
KgliteGraph** out_graph,
const char** out_error_msg
);
Risks and open questions¶
1. Crate name: kglite-c or kglite-ffi?¶
Convention varies — polars uses polars-c, ringbuf uses
ringbuf-c. kglite-c is shorter and matches the pattern most
Rust→C-ABI crates use. Going with kglite-c.
2. Header packaging — generated or committed?¶
cbindgen generates kglite.h at cargo build time. Two options:
Committed: ship the generated header in-tree (e.g. at
crates/kglite-c/include/kglite.h), update via CI. Consumers point at it directly without runningcargo build.Generated-only: don’t commit; consumers run
cargo build -p kglite-cto produce it.
Recommendation: committed, regenerated via a make refresh-c-header
target. Consumers building from source can use the committed copy.
CI verifies the committed header matches what cbindgen would produce
fresh.
3. Embedder trait objects in v1¶
As noted in §7 — v1 concrete-only (FastEmbedAdapter). User-supplied embedders need a function-pointer + context pattern. Deferred to v2 because:
v1 has a concrete embedder that works (fastembed-rs).
Function-pointer ABI is straightforward but adds ABI surface (one more function pointer signature to keep stable).
We don’t have a concrete user-supplied-embedder consumer yet to validate the design.
If a binding asks for it in H.4 PoC, we add it sooner.
4. Result streaming (large result sets)¶
H.2 ships kglite_cypher_result_rows_json(result) which materializes
everything as one JSON blob. For ~1M-row results that’s 10s of MBs
of JSON. Reasonable for v1; large results aren’t the common case.
If a binding hits this, v2 can add:
KgliteStatusCode kglite_cypher_result_row_count(const KgliteCypherResult*, size_t* out);
KgliteStatusCode kglite_cypher_result_get_row_json(const KgliteCypherResult*, size_t idx, const char** out_json);
…for pull-row-by-row consumption.
5. Cross-platform build (.so / .dylib / .dll)¶
crate-type = ["cdylib", "staticlib"] produces shared and static
libraries for the selected build target. The C header is
platform-independent and is committed in the source crate.
Build the platform artifacts from source with:
cargo build --release -p kglite-c
That produces libkglite_c.so / libkglite_c.dylib /
kglite_c.dll and the corresponding static library for the active
target. Precompiled C ABI libraries are not currently attached to releases.
Implementation order (Phase H sub-phases)¶
Sub-phase |
Effort |
What |
|---|---|---|
H.1 (this doc) |
done |
Conventions + sample signatures |
H.2 |
2-3 days |
|
H.3 |
2-3 days |
Datasets + embedder ABI (concrete-only) |
H.4 |
3-5 days |
Go PoC consumer — ~500 LOC of cgo over |
H.5 |
1-2 days |
crates.io publish + |
Total Phase H: ~1.5-2 weeks of focused work.
Companion docs¶
docs/rust/implementing-a-binding.md— guide for binding authors, with cgo / napi / JNI examples calling the shipped C ABICLAUDE.md“boundary principle” section — the design rules these conventions emerge from