The Schema You Never Wrote Down
A duplicate-node incident is one of the quietest failures in software. Nothing throws. No log line turns red. A `MERGE (p:Person {email: $email})` runs a few hundred thousand times a day and, for eleven months, does exactly what everyone expects. Then a second ingestion path is added — this one merges on `name`, because that is what the upstream file happened to carry — and the graph starts growing two `Person` nodes where there used to be one. Traversals still return results. Dashboards still render. The number of people in the company just slowly stops being true.
The Schema You Never Wrote Down
A duplicate-node incident is one of the quietest failures in software. Nothing throws. No log line
turns red. A MERGE (p:Person {email: $email}) runs a few hundred thousand times a day and, for
eleven months, does exactly what everyone expects. Then a second ingestion path is added — this one
merges on name, because that is what the upstream file happened to carry — and the graph starts
growing two Person nodes where there used to be one. Traversals still return results. Dashboards
still render. The number of people in the company just slowly stops being true.
The reason it stayed silent is not carelessness. It is that nobody had written down that email
identifies a Person. The rule existed — in a Cypher file, in the head of the engineer who wrote
the loader, in the shape of the data — but not in any artifact a machine could check.
Neo4j does not have a schema problem; it has a schema-ownership problem
Being schema-optional is one of the best things about a property graph. During modeling it lets you follow the domain instead of fighting a migration tool. You add a property, a relationship type, a second label, and the database simply cooperates. That cooperation is the feature.
The liability arrives later, when the graph is load-bearing. At that point a schema absolutely
exists — every query, every loader, every retrieval prompt depends on one — but it is emergent
rather than declared. It is whatever the last few weeks of writes happened to produce. And the
usual way to look at it, CALL apoc.meta.schema(), is honest about what it is: a sampled
description of what is currently in the store. It answers "what is there right now", not "what is
supposed to be there" — which is the question every incident review actually asks.
That gap produces the same handful of failures across very different teams.
The failure modes, concretely
Identity nobody enforced. The duplicate-Person story above. Neo4j will happily let two nodes
with the same email coexist, because no uniqueness constraint says otherwise, and MERGE-based
ingestion turns that permission into duplicates without a single error. When a Neo4j
estate is audited, this surfaces as label-no-unique-id, a Warning, one per label
with no uniqueness constraint anywhere on it, with the reason attached in plain language: MERGE-based
ingestion can silently create duplicates.
Relationships that quietly widened. A model starts clean: (:Person)-[:OWNS]->(:Company). Six
months later a new use case attaches (:Person)-[:OWNS]->(:Asset), because the relationship type
read naturally in both cases. Nothing breaks. But every traversal written against the original
assumption — including the retrieval hops behind a GraphRAG answer — can now land on a label it was
never designed for. There was no moment where anyone had to approve that widening, because there was
no declared statement to widen. We surface it as polymorphic-relationship, an Info finding that
names the relationship and lists the labels it now targets.
Types that went soft. A dateOfBirth written as a DATE_TIME by the original loader and as a
STRING by a later CSV import. A property that is a scalar on most nodes and a list on some. The
sampled type will look fine in a report for a long time — right up until a date comparison in Cypher
starts excluding rows instead of failing loudly.
A definition with no address. Ask three people what a Customer is in your graph and you may
get three answers, all correct in context: the label, the subgraph the sales query traverses, the
thing the retrieval index is built over. None of those is a document a reviewer can open or a new
engineer can read. Meaning lives in code and in people, which means it drifts at the speed of both.
"Just add constraints" is half an answer
Neo4j constraints are the right enforcement mechanism, and teams that have them are better off. But
a constraint is not a model. It cannot record that status draws from a controlled vocabulary a
steward owns, or that this label is the same entity as a table in the warehouse, or that a property
carries an ontology term the rest of the organization has agreed on. Property-existence constraints
require Neo4j Enterprise, so part of the enforcement story is edition-dependent. And constraints say
nothing about change: they either hold or they reject a write. They will not tell you that a
property was retyped between two releases.
What is missing is a place where meaning is declared, separately from the database that operationalizes it — and a check that runs whenever either side moves.
What that looks like without handing over credentials
The first objection is the reasonable one: the graph is production, and nobody wants another service
holding Bolt credentials. So CoreModels does not have any. There is no live connection to your
instance, in either direction. You run two statements — CALL apoc.meta.schema() and
SHOW CONSTRAINTS — and save the results as two artifacts: meta_schema, which is required, and
constraints, which is optional but sharpens the picture. Those artifacts are what you hand us,
over an authenticated POST https://coremodels.example.com/graph/integrations/neo4j/import/{projectId}
with Authorization: Bearer $TOKEN.
From there the shape of the graph becomes a governed model. Each node label becomes a Type. Each
property becomes an Element carrying its declared type plus its uniqueness and existence facts — the
SHOW CONSTRAINTS rows refine what the sampled schema could only guess at. Each outgoing typed
relationship becomes an explicit reference named after the relationship type, pointing at the label
it targets: the graph-native foreign key, kept as connective tissue rather than flattened into a
column.
The same two artifacts, re-extracted a month later, can then be audited against that model instead
of imported into it. The audit is read-only, sorts findings into Coverage, Drift and Conformance,
and reports an errorCount a CI job can fail the build on.
The point is not that the audit is clever. The point is that "did the meaning of this graph change?" becomes a question with an answer — produced by a machine, on a cadence you choose, in a form you can paste into a pull request.
Every team running a serious Neo4j instance already has a schema, in loaders and queries and retrieval prompts and memory. The problem was never its absence. The problem is that nobody owns the written copy, so nothing can be checked against it. That is the gap this connector exists to close.
If you want the exact extraction statements and the full set of verbs, our Neo4j quickstart walks through them end to end.