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 at crates/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

kglite-c skeleton: top-10 entry points + cbindgen header

H.3

Datasets + embedder ABI

H.4

~500 LOC Go PoC consumer (validates the surface)

H.5

Release coordination + implementing-a-binding.md rewrite


Goals (from CLAUDE.md North Star)

The C ABI exists to satisfy the four binding-author goals:

  1. 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.

  2. 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 same kglite_execute_cypher_read(...).

  3. 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.

  4. 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

kglite::api::load_file

kglite_load_file

kglite_load_file(*const c_char) *mut KgliteGraph

kglite::api::session::execute_read

kglite_session_execute_read

kglite::api::cypher::parse_cypher

kglite_cypher_parse

kglite::api::KgErrorCode::neo4j_status_code

kglite_error_code_neo4j_status

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 T are 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’s kglite_<type>_free(...) function.

  • Return values that are *const c_char are OWNED by the caller. Must be freed via kglite_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_json to kglite_session_execute_read. The C side parses via the lifted kglite::api::param::json_value_to_kglite_value (Phase B.2, just shipped).

  • Results: KgliteCypherResult has 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 running cargo build.

  • Generated-only: don’t commit; consumers run cargo build -p kglite-c to 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

kglite-c skeleton + 15 top-10 functions + cbindgen

H.3

2-3 days

Datasets + embedder ABI (concrete-only)

H.4

3-5 days

Go PoC consumer — ~500 LOC of cgo over kglite.h

H.5

1-2 days

crates.io publish + implementing-a-binding.md rewrite

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 ABI

  • CLAUDE.md “boundary principle” section — the design rules these conventions emerge from