Projecting a Property Graph onto a Governed Model: Inside the Neo4j Connector
CoreModels has one estate model and every vendor parses into it: **datasets** (table-shaped things) with **fields** and normalized **checks**, plus **lineage** edges and **projections**. A dbt project, a warehouse schema, and a schema registry all land in the same shape.
Projecting a Property Graph onto a Governed Model: Inside the Neo4j Connector
CoreModels has one estate model and every vendor parses into it: datasets (table-shaped things) with fields and normalized checks, plus lineage edges and projections. A dbt project, a warehouse schema, and a schema registry all land in the same shape.
A property graph has none of those words. It has labels, properties, relationship types, and constraints — and no schema in the contract sense, since Neo4j will happily store a Person with an email today and one without it tomorrow. So the question for this connector is not "how do we read APOC output?" but "which parts of a property graph are meaning, which are observation, and which have no home in the neutral model at all?" This article answers it mapping by mapping, including where the answer is "it doesn't fit, and we say so."
What the parser reads, and what it refuses
The required meta_schema artifact is the value of CALL apoc.meta.schema(): one JSON object keyed by label and relationship-type name. Parsing is tolerant at the envelope — a bare object, the {"value": …} wrapper, or a one-row array all unwrap identically. Inside, the connector reads a specific subset:
{
"Person": {
"type": "node",
"count": 1200,
"properties": {
"email": { "type": "STRING", "existence": false, "indexed": true, "unique": true },
"joined_at": { "type": "DATE_TIME", "existence": false, "indexed": false, "unique": false }
},
"relationships": {
"OWNS": { "direction": "out", "labels": ["Company", "Asset"] }
}
},
"OWNS": { "type": "relationship", "count": 40 }
}
Entries with "type": "relationship" are counted and skipped — they duplicate the node-side view — and anything neither node nor relationship is ignored. The label and relationship-type totals survive as parser facts (labels, relationshipTypes) and surface on the integration state node.
Two refusals are deliberate: a payload that parses but is not APOC-shaped fails with Expected one JSON object keyed by label/relationship name., and one yielding no node entries fails with No node labels found in the meta schema. — an empty import would look like governance while governing nothing.
The snapshot also carries a fingerprint: the first 16 hex characters of a SHA-256 over the artifact content, so identical exports are recognizable as identical across audits, history entries, and status checks.
The constraints artifact is corrective, not decorative
apoc.meta.schema() samples the store, so its existence and unique flags are observations. SHOW CONSTRAINTS is authoritative, which is the whole reason the optional constraints artifact exists.
For each row the connector reads the constraint type, the first entry of labelsOrTypes, and the first entry of properties, then merges: a type containing UNIQUE or NODE_KEY adds a Unique check, one containing EXISTENCE or NODE_KEY adds a NotNull check — never duplicating a check APOC already reported, and a NODE_KEY row contributes both. Two consequences: a composite constraint only refines its first property, and a constraints payload that is not valid JSON never fails the import — it is ignored with a lossiness entry, because a corrective input must not break the primary one.
Labels, properties, types
Each node label becomes a dataset and, from there, a governed Type, with materialization node and APOC's sampled node count as vendor metadata (neo4j.count). Each property becomes an Element carrying its native type verbatim; the native-to-governed mapping is small and exact:
| Neo4j native type | Governed primitive | Exact? |
|---|---|---|
INTEGER, LONG | Integer | yes |
FLOAT, DOUBLE | Double | yes |
BOOLEAN | Boolean | yes |
DATE, DATE_TIME, DATETIME, LOCAL_DATE_TIME, ZONED_DATE_TIME | DateTime | yes |
STRING | String | yes |
anything else — LIST OF …, POINT, DURATION | String | approximated |
The last row earns its keep. Spatial values, durations, and lists have no exact governed counterpart, so they map to String and the approximation is declared as a lossiness record naming the field and its original type; the verbatim native type is preserved in vendor metadata either way.
Property flags become checks: existence: true becomes NotNull, unique: true becomes Unique. indexed: true becomes metadata (neo4j.indexed), not a check — an index is a performance fact, not a meaning fact.
Relationships are the graph-native foreign key
The most graph-specific decision: each outgoing typed relationship becomes a reference Element, named after the relationship type, targeting the governed Type of its first target label. (:Person)-[:WORKS_FOR]->(:Company) is governed as a WORKS_FOR reference on Person pointing at Company, with native type relationship(WORKS_FOR) and a Relationship check.
That is the same construct a warehouse connector uses for a foreign key, which is the point: "this dataset's rows point at that dataset's rows" is one concept, whether the pointer is a key column or an edge. Three restrictions keep it honest:
- Outgoing only. An edge is one fact; governing it from both endpoints would double-count it, so the target label gets no mirror-image Element.
- First target wins. When a relationship targets several labels, only the first becomes the reference target and the full list is recorded as
neo4j.polymorphic— reported by every audit aspolymorphic-relationship(Info), so nobody reads the reference as single-target by mistake. - Off-snapshot targets degrade. A reference pointing at a label absent from this snapshot is written as a plain value, with a SemanticNarrowing lossiness record naming the missing target.
Identity: what the graph stores, and what it never stores
CoreModels node ids must be alphanumeric — the id index tokenizes on separators, so an id containing _ or . could never be matched by the exact-id read that follows every create. The connector folds separators away and camel-cases the next word: Person → Type id Person, Person.joined_at → Element id PersonJoinedAt, Person.WORKS_FOR → PersonWORKSFOR.
The native identity is never in the id. It lives in the node's Label, in a mapsTo assertion (standard neo4j, URI = the vendor reference), and in the vendor metadata mixin. That assertion is the join key everything downstream uses — audits match governed nodes to estate objects through it, never by label, so renaming a governed element is stewardship, not drift.
Because folding separators can collapse two identities onto one id, the mapper checks for collisions before writing and refuses the import rather than silently merging two labels: "Distinct vendor identities collapse onto the same graph id after sanitization … Rename one of them in the vendor estate."
What rides the vendor-metadata mixin
Schema shape — Types, Elements, references — goes through the governed schema writer. Estate facts the schema deliberately does not absorb ride a per-vendor metadata mixin (Neo4j Metadata), one value per node, with property ids formed as the vendor key plus the property name: neo4jDataType, neo4jChecks, neo4jMeta, neo4jMaterialization, neo4jRelationName, neo4jUniqueId, neo4jResourceKind, neo4jContract, neo4jTags, neo4jDescription, neo4jAccess. For Neo4j that carries the verbatim native type of every property, the serialized checks, the materialization (node), the physical name, and the meta bag — neo4j.count, neo4j.indexed, neo4j.polymorphic.
The distinction has a practical edge: import never mutates governed nodes, but vendor-metadata values are refreshed every time. Bookkeeping tracks the estate; meaning waits for a human. Two bookkeeping nodes are written alongside — the state node (import time, fingerprint, counts, facts) and the estate snapshot node (the parsed snapshot, gzip-and-base64 encoded, which is what makes artifact-free re-audits possible); a third, the audit history node, appears once audit runs are recorded.
What a Neo4j import does not produce
Four absences are structural:
- No taxonomies.
apoc.meta.schema()reports types, not value sets, so no field arrives with accepted values. Plan for the consequence: if a steward governs a Neo4j-imported property with a controlled list, every subsequent audit reportsenum-constraint-removed(Warning) — the estate genuinely does not declare the constraint, so enforcement has to live elsewhere. It can: see generation, below. - No lineage edges. A fresh import reports
lineageEdgesAdded: 0— the artifact describes structure, not data flow, and a typed relationship is schema meaning, not lineage. - No projections. The artifact has no semantic-model or exposure concept, so no Components are created.
- No relationship properties. Relationship-type entries are skipped as duplicates and the node-side view carries direction and target labels only, so a property on an edge —
WORKS_FOR.since— is not governed today.
The contract flag is likewise not a Neo4j concept: datasets are recorded with enforcement false consistently, so contract-drift has nothing to fire on.
The audit rules, and one scoping limit
On top of the engine's coverage checks (dataset-unmapped, field-unmapped) and drift checks (dataset-removed, field-removed, field-type-drift, enum-constraint-removed, enum-narrowed, enum-widened, contract-drift), the connector contributes two conformance rules:
label-no-unique-id(Warning) — every label with no uniqueness check on any property. The rationale is operational:MERGEis Neo4j's idempotent-ingestion primitive, andMERGEon a non-unique property matches nothing and creates a duplicate instead.polymorphic-relationship(Info) — every reference whose relationship targets multiple labels, with the full target list indetail.
One scoping limit deserves daylight. dataset-removed only fires for governed datasets whose identity namespace belongs to the estate being compared — the mechanism that lets one project govern several estates of a vendor without cross-firing. Neo4j identities are bare labels with no dotted namespace, so a label that vanishes entirely falls outside that scope and is not reported; property removals on surviving labels are caught normally. If whole-label deletions matter, compare the audit's Datasets (estate) metric against governedDatasets from the status route.
Generation: meaning back into enforcement
Generation reads the governed schema plus the checks recorded on the vendor mixin and emits one artifact, coremodels_constraints.cypher:
// Generated by CoreModels — governed graph schema constraints.
// Meaning changes belong in CoreModels; regenerate this script rather than editing it.
// NOT NULL (property existence) constraints require Neo4j Enterprise.
CREATE CONSTRAINT person_email_unique IF NOT EXISTS FOR (n:Person) REQUIRE n.email IS UNIQUE;
CREATE CONSTRAINT person_name_exists IF NOT EXISTS FOR (n:Person) REQUIRE n.name IS NOT NULL;
CREATE INDEX person_status_idx IF NOT EXISTS FOR (n:Person) ON (n.status);
Every statement is IF NOT EXISTS, so the script is idempotent. Governed Unique checks become uniqueness constraints; governed NotNull checks become existence constraints, with the header stating honestly that those are an Enterprise feature. A property constrained by a governed controlled list gets an index unless it is already unique — the answer to the taxonomy gap above. Names are normalized defensively: labels to PascalCase ASCII alphanumerics, properties to camelCase, constraint names to a lowercased label_property plus a suffix. Reference Elements are excluded — in Neo4j a reference is a relationship, not a property. And with no governed uniqueness or existence facts at all, generation refuses (No governed uniqueness/existence facts found to emit as constraints.) rather than emitting an empty file that looks like enforcement.
Lossiness, limits, gotchas
- APOC version variance. Output details differ across APOC versions. The parser reads tolerantly, but validate your version's export on a first run before a scheduled pipeline depends on it.
- Sampling. APOC's counts and flags are observations. For constraint truth, always send
constraints. - The snapshot cap. The stored snapshot is capped at roughly 1.5 million encoded characters. Over it, import reports
snapshotStored: falsewith a lossiness record; fresh-artifact audits still work, re-audit has nothing to run against. - Required is a mixin fact, not a metamodel slot. CoreModels has no first-class schema-level required/nullable slot; the fact is stashed as an element-facts mixin value — visible, but not enforced by the graph — which is why generation reads not-null from the vendor mixin's checks instead.
- No live connection. There is no Bolt or HTTP session to your database. Live sync is a declared-but-deferred capability; the boundary is artifacts only, and your credentials stay yours.
The pattern under all of it: translate what translates exactly, approximate what cannot, and say so — as lossiness at import, metadata on the governed nodes, and coded findings at audit time.
For the commands behind this mapping, see the Neo4j quickstart in the CoreModels integration docs.