The graph plane: Neo4j as a data plane, not a driver wrapper¶
What it is¶
A deployment declares which labels, relationship types and property keys exist; a caller
builds a statement from that vocabulary and runs it under the tenant it is authenticated
for. The driver is an optional extra (graph) imported inside open, so the library
imports and type-checks without it.
vocabulary = GraphVocabulary(
labels=frozenset({"User"}),
relationship_types=frozenset({"OWNS"}),
property_keys=frozenset({"name"}),
)
client = Neo4jClient(GraphConfig(uri="neo4j+s://graph:7687", database="acme",
auth=SecretRef(handle="graph/password")),
provider=provider, policy=GraphPolicy())
await client.open(context=context) # binds the tenant this client serves
await client.run(query=node_create(vocabulary.label("User"), tenant=str(tenant),
node_id="u1",
properties={vocabulary.property_key("name"): "Ada"}),
context=context)
GraphRepository is the surface an application should prefer: its methods take no tenant
at all — the scope is read from the context, and a statement that does not carry it is
refused before a connection is opened. GraphTransaction is the multi-statement form:
one ending, retryable() as a fact rather than an assumption, and a commit failure that
lands in UNCERTAIN rather than PENDING.
The security properties, and where each one is proven¶
- An identifier is validated where it is interpolated.
Label,RelationshipTypeandPropertyKeyvalidate at construction, and every builder re-checks the type at the position — because an annotation enforces nothing, and a plain string in that position becomes Cypher.tests/unit/test_graph_adversarial.pyattacks each position with twelve hostile spellings; the RED run that motivated the guard failed 106 of 111 cases. - A value is never statement text. The statement is a function of identifiers alone: the same query with a hostile and a benign value produces byte-identical text, and the value travels in the parameters.
- The tenant is a parameter, not a filter, and the check lives in exactly one place
(
graph/tenant.py), called by the client and by the transaction. A statement for another tenant is refused before the driver is touched — the refusal tests assert the driver recorded zero calls. - A refusal is recorded before it is raised, with the actor the call was made for:
the event fills its actor from the ambient context, so the statement runs inside the
context scope, and the vocabulary is the existing
CROSS_TENANT_ACCESS_REJECTEDrather than a second name for the same fact. - A retry never repeats a committed write. The reliability composition reuses the
library's classifier, backoff, shared budget and breaker; the retry loop asks the
transaction's own
retryable(), which is false after a commit and after a failed one. - Telemetry cannot become the failure. Spans and events go through the existing seams: an audit outage does not change the caller's outcome, and nothing exported carries a tenant id or a statement.
Limits, stated rather than implied¶
- Schema DDL has no path through this client. A statement with no tenant parameter is refused — which is the point — so constraints and indexes are an operator's job, declared out of band. The live suite creates its constraint with the driver directly, the way a migration would, and then proves the library's own statements meet it.
GraphQueryis the low-level seam. It carries text and parameters, so code that builds one by hand chooses its own Cypher. What it cannot do is run unscoped: the tenant check applies to it like any other statement. Prefer the builders, orGraphRepository.- Tenant isolation is the statement's scope, not the engine's. Neo4j has no row-level security; what the library guarantees is that every statement it builds filters on the tenant parameter it bound from the context, and that a statement which does not is refused.
Running the live layer¶
docker compose -f tests/infra/docker-compose.yml up -d neo4j
export JDLIB_INFRA_NEO4J_URL=bolt://127.0.0.1:7687
export JDLIB_INFRA_NEO4J_PASSWORD=jdliblab
.venv/Scripts/python.exe -m pytest tests/infra/test_neo4j_infra.py -q -W error
Without JDLIB_INFRA_NEO4J_URL the suite skips with that variable named in the reason.
The live layer proves what unit tests cannot: that the builders produce Cypher a real
server accepts, that the parameter they bind is what it filters on, that the driver's own
exception classes land on the right taxonomy, and that a hostile value is stored as data.