Graphus speaks the Bolt 5.x protocol (versions 5.0–5.4) with PackStream v1 serialization, exposed over two transports that share the same Cypher engine and the same security catalog:
| Transport | Use case | TLS | Authentication |
|---|---|---|---|
| Bolt over UDS | local inter-process comms (IPC) | none (local) | OS peer-credential and LOGON |
| Bolt over TCP | network access, Neo4j drivers | required | Bolt LOGON (username + password) |
Because Graphus implements standards-compliant Bolt + PackStream, the entire Neo4j driver ecosystem (Python, Go, Java, JavaScript, .NET, …) connects over TCP without modification.
The networked transport, for drivers and remote clients.
- Address:
bolt_tcp_addr(the Docker image publishes0.0.0.0:7687). - TLS is mandatory. If
bolt_tcp_addris set without a TLS certificate, the server refuses to start. Configure the certificate withGRAPHUS_TLS_CERT_PATH/GRAPHUS_TLS_KEY_PATH(see configuration.md). The Docker entrypoint provisions a self-signed pair on first boot. - Authentication: the driver sends
HELLOthenLOGONwith thebasicscheme; the password is verified against the stored Argon2id hash.
A Neo4j driver selects TLS behaviour through the URI scheme:
| Scheme | Meaning | When to use |
|---|---|---|
bolt+s:// |
TLS, certificate verified against a trusted CA | production, CA-issued certificate |
bolt+ssc:// |
TLS, self-signed certificate accepted (no CA) | the Docker quickstart's self-signed cert |
bolt:// |
plaintext (no TLS) — rejected on TCP by Graphus | not usable over TCP |
The official Neo4j Go driver works directly. See
examples/clients-go/bolt-tcp:
driver, _ := neo4j.NewDriverWithContext(
"bolt+ssc://localhost:7687",
neo4j.BasicAuth("graphus", "graphus-local", ""),
)
defer driver.Close(ctx)
res, _ := neo4j.ExecuteQuery(ctx, driver,
"MATCH (n) RETURN count(n) AS n", nil,
neo4j.EagerResultTransformer,
neo4j.ExecuteQueryWithDatabase("graphus"))go run ./bolt-tcp -uri bolt+ssc://localhost:7687 \
-user graphus -password graphus-local -database graphusThe Python driver example is in the README.
The local inter-process transport — a Unix domain socket. It avoids the network stack entirely, so it is the fastest path for a client on the same host.
- Socket path:
uds_path(the Docker image uses/data/graphus.sock). - No TLS — UDS is a kernel-protected local trust domain, gated by peer credentials instead.
- Two authentication gates, both required:
-
Peer-credential gate. At accept time the server reads the connecting process's OS uid and resolves it to a Graphus user. An unmapped uid is refused before any Bolt bytes flow — the socket is simply closed. Map the OS uid that is allowed to connect with
admin_uidin[auth]:[auth] admin_uid = 1000 # this OS uid may open the socket, mapped to admin_user
-
Bolt
LOGON. After admission, the session authenticates with username + password, exactly as over TCP.
-
The official Neo4j drivers connect to host:port and do not expose Unix-socket
dialing. To use UDS you therefore speak Bolt directly over the socket. Two options:
-
The
graphus-clitool — an interactive Bolt shell over UDS:graphus-cli --uds /data/graphus.sock --user graphus --password graphus-local
-
A raw Bolt client.
examples/clients-go/bolt-udsis a complete, dependency-free Go client that implements the handshake, HELLO, LOGON, RUN, PULL, GOODBYE, and a PackStream decoder — a faithful, readable reference for the wire protocol:go run ./bolt-uds -socket /data/graphus.sock \ -user graphus -password graphus-local
- Handshake, and your slot order is honoured. The client opens with the 4-byte magic preamble
60 60 B0 17followed by four 32-bit version proposals. Graphus reads them in the order you sent them and answers the first it can serve within the 5.0–5.4 window, replying with that 4-byte version (or00 00 00 00to reject). So listing5.1ahead of5.3gets you 5.1 — the slot order is how the handshake lets a client state a preference, and it is binding. Within a single range-encoded proposal (which offers a span of minors) the highest supported minor of that span is chosen, since a range means "any of these". The modern Manifest-v1 handshake is also supported, and its marker slot competes by position on the same terms: a legacy proposal listed ahead of it wins. The top of the window can be capped with thebolt_max_protocol_minorstartup option (both handshake forms honour the same cap); see configuration.md. - Authentication depends on the negotiated version. From Bolt 5.1 the
HELLOonly negotiates and a separateLOGONauthenticates. At Bolt 5.0 theHELLOdoes both: the authentication token (scheme,principal,credentials) travels in theHELLOextramap and a successfulHELLOlands directly inREADY. Graphus serves both flows, and the official drivers pick the right one automatically from the negotiated version. - Per-version message set. Older minors define fewer messages, and Graphus rejects a
message the negotiated version does not define (a
Neo.ClientError.Request.InvalidFAILURE, like any other undecodable message) rather than acting on it:LOGONandLOGOFFexist from 5.1,TELEMETRYfrom 5.4; every other message spans the whole 5.0–5.4 window. - Server agent. The
HELLOreply'sSUCCESScarries aserveragent string. Graphus announcesGraphus/<version>by default — 100% Bolt-conformant and accepted by every modern Neo4j driver (which treat it as informational). For strict/legacy clients that demand the literalNeo4j, set thebolt_server_agentstartup option (e.g.neo4j-compat→Neo4j/5.13.0); see configuration.md. This never affects conformance or negotiated capabilities. - Messages. Each Bolt message is a PackStream structure framed in chunks (a 2-byte
big-endian length per chunk, terminated by
00 00). The request set used by a client isHELLO,LOGON,RUN,PULL/DISCARD,BEGIN/COMMIT/ROLLBACK,RESET,GOODBYE(andROUTE/TELEMETRY); the server replies withSUCCESS,RECORD,FAILURE,IGNORED. - Errors. A server-side problem arrives as a Bolt
FAILUREcarrying a Neo4j-stylecodeand a human-readablemessage, after which the connection isFAILEDuntil aRESET. - An out-of-order message closes the connection. A message the current state defines no
transition for at all —
COMMITwith no open transaction,LOGOFFoutsideREADY,COMMITorROLLBACKwhile a result is still streaming — is answered withFAILURE {code: "Neo.ClientError.Request.Invalid"}and the connection is then closed. It is not recoverable withRESET, matching the reference server, which treats an illegal transition as connection-terminating. Any open explicit transaction is rolled back first, so nothing is left half-applied or pinned. This is a different case from a message that is legal here and simply fails (a bad query, a refused impersonation): those leave the connectionFAILEDand aRESETrecovers it as usual. The official drivers already avoid out-of-order messages — the Python driver's_commitconsumes pending results before committing — so a conformant client never reaches this. - Impersonation (
imp_user) is refused, never ignored.BEGIN,RUNandROUTEmay carry animp_userfield naming the principal the client wants the server to act as. Graphus does not implement impersonation, so it refuses any message that carries one, withFAILURE {code: "Neo.ClientError.Security.Forbidden"}, and does not run the statement. It is refused rather than ignored becauseimp_userdrops privileges: a server that accepted the field and ran as the connection's own principal would hand a middle-tier application (one pooled connection as a service principal, impersonating its end user per request) the service principal's full rights while the application believed it was scoped to one tenant. The refusal is unconditional and identical for every value — the named principal is never looked up, so the response reveals nothing about who exists. Any present, non-null value counts, including the empty string, a non-string value, and the connection's own principal; only an explicitnullmeans "no impersonation requested". The connection entersFAILEDand recovers withRESETlike any other statement failure. Applications needing per-request identity should open a connection per principal, or authenticate the end user withLOGON(Bolt 5.1+ re-authentication). - Transaction timeout (
tx_timeout) is honoured, and clamped downward only.BEGINand an auto-commitRUNmay carrytx_timeout, a transaction budget in milliseconds. Graphus applies it as follows:- A positive value is honoured as an upper bound. On
BEGINit bounds the whole transaction, not each statement: every statement in it is limited to what remains of the budget, and aCOMMITarriving after the budget has run out is refused and the transaction rolled back — so a timed-out transaction never leaves half-applied state. On an auto-commitRUN, where the statement is the transaction, it bounds that statement. - The clamp is downward only. The effective per-statement budget is the smaller of the
client's value and the server's configured
timing.statement_timeout_ms(2 minutes by default); a client asking for more than the server allows gets the server's bound. The server'stiming.max_transaction_age_mssweep likewise still applies. A client can therefore always self-limit, and never buy itself more time than the operator allows. - Zero or negative means "the client sets no bound of its own", matching the reference server (which documents a zero duration as "the transaction does not have a timeout" and skips expiry for any non-positive value). The server's own bounds still apply, so this is not a way to run unbounded. The official drivers reject a negative value client-side.
- A non-integer
tx_timeoutis refused withFAILURE {code: "Neo.ClientError.Request.Invalid"}rather than silently dropped. - When the budget expires, the failure carries
Neo.ClientError.Transaction.TransactionTimedOutClientConfiguration— the reference server's title for a bound the client configured. It is a non-retryableClientError, because replaying a transaction that exhausted its own budget would simply exhaust it again. A statement cancelled mid-execution by the deadline currently surfaces the generic cancellation failure (Neo.ClientError.Statement.ArgumentError, messagequery cancelled) — the same non-retryable classification, with a less specific title.
- A positive value is honoured as an upper bound. On
- Retryability. The
FAILUREcode's classification segment tells the driver whether to replay a managed transaction (session.executeRead/executeWrite):TransientErroris replayed for up tomaxTransactionRetryTime(30 s by default), everything else fails immediately. Graphus sendsNeo.TransientError.Transaction.Outdatedfor a serialization abort andNeo.TransientError.General.DatabaseUnavailablefor an unavailable database — both retriable — and non-retriableClientErrorcodes for the permanent faults:Neo.ClientError.Statement.AccessMode(a write statement inside aBEGIN {mode: "r"}transaction),Neo.ClientError.Transaction.TransactionNotFound(aRUN/COMMITnaming a transaction that was never opened or is already spent),Neo.ClientError.Transaction.TransactionTimedOut(one the server'stiming.max_transaction_age_mssweep stopped — the server-configured twin of the client-configuredtx_timeoutcode above), andNeo.ClientError.Request.Invalid(a message illegal for the session's transaction state:RUNwith no transaction open,BEGINwhen one already is,COMMIT/ROLLBACKwith none). The full contract, and why each code was chosen, isspecification/06-bolt-and-error-shapes.md§2.5. - Result summary. After a query's records, the trailing
SUCCESScarries the summary:type— the query type (rread,wwrite,rwread-write,sschema/admin) — andstats, the side-effect counters (nodes-created/-deleted,relationships-created/-deleted,properties-set,labels-added/-removed,indexes-added/-removed,constraints-added/-removed,system-updates,contains-updates, andcontains-system-updates), present only when non-empty. The official driver surfaces these assummary().query_typeandsummary().counters.*. The counters use Neo4j's operation-count model; the full contract isspecification/06-bolt-and-error-shapes.md§3.1. - Query plan (
EXPLAIN/PROFILE). A statement sent with theEXPLAINprefix carries its plan in the trailingSUCCESSunderplan; one sent withPROFILEcarries it underprofile, annotated with each operator's measuredrowsanddbHits. Exactly one of the two keys is ever sent (never both), and neither appears for an ordinary statement. Each plan node is a dictionary withoperatorType,args,identifiersand — for a non-leaf —children, which is the shape the official drivers parse (summary().plan/summary().profile). APROFILE'sargsadditionally carry the candidates each access path examined and rejected, and the serializability markers it emitted (CandidatesExamined,CandidatesRejectedByVisibility,CandidatesRejectedByFilter,ReadMarkers,PredicateMarkers), each present only when non-zero. See cypher.md.
For the exact wire encoding, the authoritative reference is the graphus-bolt crate
(handshake.rs, framing.rs, message.rs, packstream.rs) — and the Go UDS example,
which transcribes it.
See also: getting-started.md · security.md · rest-api.md · configuration.md.