# Neo4j — Quickstart: Your First Neo4j Schema Audit: A Complete Worked Example

Take a small graph - three labels, a handful of properties, three relationship types. By the end of this article that graph has a governed model in CoreModels and a recorded baseline audit that tells you, in machine-readable form, exactly what is under governance and where the graph is structurally weak. Total effort: two Cypher statements and two HTTP calls.

# Your First Neo4j Schema Audit: A Complete Worked Example

Take a small graph - three labels, a handful of properties, three relationship types. By the end of this article that graph has a governed model in CoreModels and a recorded baseline audit that tells you, in machine-readable form, exactly what is under governance and where the graph is structurally weak. Total effort: two Cypher statements and two HTTP calls.

The one thing that never happens in this workflow is a connection from CoreModels to your database. There is no Bolt URI to configure and no service account to provision. You run the extraction; we read the artifacts you hand us.

Throughout, the API host is `https://coremodels.example.com`, `$PROJECT_ID` is the 32-character hex id of the CoreModels project that will govern the estate, and `$TOKEN` is your CoreModels login token. The vendor key is `neo4j`, and the connector declares all three capabilities: **Import**, **Audit**, and **Generate**.

## What you need

- A Neo4j instance with the **APOC** library installed - `apoc.meta.schema()` is the extraction workhorse.
- A CoreModels project. Import requires the **Admin** role on it; audit needs only **Viewer**.
- `curl` and `jq`.

## Step 1 - Extract the two artifacts

Run these against the instance you want to govern, in Neo4j Browser or `cypher-shell`, and save the results:

```cypher
CALL apoc.meta.schema() YIELD value RETURN value;   // save the value as meta_schema.json
SHOW CONSTRAINTS;                                    // export rows as constraints.json
```

| Artifact name | Content | Required |
|---|---|---|
| `meta_schema` | the `value` returned by `apoc.meta.schema()` - one JSON object keyed by label and relationship-type name | **yes** |
| `constraints` | the `SHOW CONSTRAINTS` rows as a JSON array | no, but recommended |

Our worked example graph produces a `meta_schema.json` like this:

```json
{
  "value": {
    "Person": {
      "type": "node",
      "count": 1200,
      "properties": {
        "email":     { "type": "STRING",    "existence": false, "indexed": true,  "unique": true },
        "name":      { "type": "STRING",    "existence": true,  "indexed": false, "unique": false },
        "age":       { "type": "INTEGER",   "existence": false, "indexed": false, "unique": false },
        "joined_at": { "type": "DATE_TIME", "existence": false, "indexed": false, "unique": false }
      },
      "relationships": {
        "WORKS_FOR": { "direction": "out", "count": 900,  "labels": ["Company"] },
        "KNOWS":     { "direction": "out", "count": 4000, "labels": ["Person"] },
        "OWNS":      { "direction": "out", "count": 40,   "labels": ["Company", "Asset"] }
      }
    },
    "Company": {
      "type": "node",
      "count": 200,
      "properties": {
        "name":    { "type": "STRING",  "existence": false, "indexed": true,  "unique": false },
        "founded": { "type": "INTEGER", "existence": false, "indexed": false, "unique": false }
      },
      "relationships": {
        "WORKS_FOR": { "direction": "in", "count": 900, "labels": ["Person"] }
      }
    },
    "Asset": {
      "type": "node",
      "count": 60,
      "properties": {
        "serial": { "type": "STRING", "existence": false, "indexed": false, "unique": false }
      },
      "relationships": {}
    },
    "WORKS_FOR": { "type": "relationship", "count": 900 },
    "KNOWS":     { "type": "relationship", "count": 4000 },
    "OWNS":      { "type": "relationship", "count": 40 }
  }
}
```

And `constraints.json`:

```json
[
  { "name": "person_email_unique", "type": "UNIQUENESS",             "entityType": "NODE", "labelsOrTypes": ["Person"],  "properties": ["email"] },
  { "name": "person_name_exists",  "type": "NODE_PROPERTY_EXISTENCE", "entityType": "NODE", "labelsOrTypes": ["Person"],  "properties": ["name"] },
  { "name": "company_name_key",    "type": "NODE_KEY",                "entityType": "NODE", "labelsOrTypes": ["Company"], "properties": ["name"] }
]
```

Two things to know before moving on. First, the parser is deliberately tolerant about the envelope: a bare object, the `{"value": ...}` wrapper that `YIELD value` produces, or a one-row array all parse to the same thing - save whatever your tooling gives you. Second, `apoc.meta.schema()` builds its picture by sampling the store, so its `existence` and `unique` flags are an observation rather than the database's authoritative word. That is exactly why the `constraints` artifact exists: `SHOW CONSTRAINTS` is authoritative, and its `UNIQUENESS`, `NODE_PROPERTY_EXISTENCE`, and `NODE_KEY` rows are merged into the fields on top of what APOC reported. In our example, that is what turns `Company.name` from "sampled as not unique" into a governed uniqueness fact.

## Step 2 - Import the estate

Import is an Admin-role call on the interactive surface. Build the body with `jq --rawfile` so both JSON documents are embedded as strings without shell-quoting hazards:

```bash
jq -n --rawfile meta meta_schema.json --rawfile cons constraints.json \
  '{artifacts: {meta_schema: $meta, constraints: $cons}}' > import-request.json

curl -sS -X POST \
  "https://coremodels.example.com/graph/integrations/neo4j/import/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @import-request.json
```

For the graph above, the response is:

```json
{ "success": true, "vendor": "neo4j", "projectName": "neo4j",
  "datasetsAdded": 3, "datasetsSkippedExisting": 0, "fieldsAdded": 0,
  "lineageEdgesAdded": 0, "lineageEdgesSkipped": 0, "nodesEnriched": 13,
  "snapshotStored": true, "lossiness": [], "errors": [] }
```

Reading it counter by counter:

- **`datasetsAdded: 3`** - the three node labels became governed Types. Relationship-type entries (`WORKS_FOR`, `KNOWS`, `OWNS` at the top level) duplicate what the node entries already carry, so they are counted as parser facts and skipped rather than governed twice.
- **`datasetsSkippedExisting: 0`** - import is additive. Labels already governed are left exactly as they are; nothing is mutated or overwritten. On a re-import this number goes up, and that is the system working as designed.
- **`fieldsAdded: 0`** - this counter is specifically *fields added to already-governed labels*, the additive re-import path. On a first import the properties arrive complete with their new Types through the schema writer, so it stays zero. Add a property to `Person` later, re-import, and it becomes `1`.
- **`nodesEnriched: 13`** - every governed node that received Neo4j metadata: 3 Types plus 10 Elements. The ten are Person's four properties and three outgoing relationships, Company's two properties, and Asset's one.
- **`lineageEdgesAdded: 0`** - expected, and not a gap. `apoc.meta.schema()` describes structure, not data flow; a typed relationship is schema meaning, and it is governed as a reference, not as a lineage edge.
- **`snapshotStored: true`** - the parsed estate snapshot was persisted. This is what makes artifact-free re-audits possible later.
- **`lossiness` / `errors`** - lossiness records what a *successful* import had to approximate; errors mean it could not proceed at all. Both empty here.

What landed in the graph: each label is a Type with materialization `node` and its sampled node count on metadata; each property is an Element carrying APOC's native type (`STRING`, `INTEGER`, `DATE_TIME`, …), with `existence` becoming a NotNull check and `unique` becoming a Unique check; and each **outgoing** typed relationship becomes a reference Element named after the relationship type, targeting the first target label. `(:Person)-[:WORKS_FOR]->(:Company)` is governed as a `WORKS_FOR` reference on `Person` pointing at `Company` - the graph-native foreign key. Incoming directions are not duplicated, which is why `Company` gets two Elements, not three.

## Step 3 - Run the baseline audit

The audit compares fresh artifacts against the governed model. It is read-only and runs at Viewer role. `recordHistory: true` adds the run to the project's rolling drift trail - by default the verb records nothing at all:

```bash
jq -n --rawfile meta meta_schema.json --rawfile cons constraints.json \
  '{artifacts: {meta_schema: $meta, constraints: $cons}, recordHistory: true}' > audit-request.json

curl -sS -o audit-response.json -X POST \
  "https://coremodels.example.com/graph/integrations/neo4j/audit/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @audit-request.json

jq '{errorCount, warningCount, infoCount, codes, metrics}' audit-response.json
```

Because you just imported these artifacts, drift is zero - which makes this the ideal moment to learn the report's anatomy:

```json
{
  "errorCount": 0,
  "warningCount": 1,
  "infoCount": 1,
  "codes": { "label-no-unique-id": 1, "polymorphic-relationship": 1 },
  "metrics": {
    "Datasets (estate)": "3",
    "Datasets governed": "3 / 3",
    "Fields governed": "10 / 10",
    "Governed nodes with canonical mappings": "0 / 13 (0%)",
    "Last import": "2026-08-04T09:12:44.1180000+00:00"
  }
}
```

`errorCount > 0` is the gate signal used by CI; warnings and infos inform without blocking. The metrics line worth noticing is the last one: zero of thirteen governed nodes carry a *canonical* mapping - a mapping to a standard other than the vendor itself. Right now the model knows these nodes as Neo4j objects and nothing more. Attaching canonical meaning is the work that turns an imported estate into a governed vocabulary, and this metric tracks it.

## Step 4 - Read the findings

Findings arrive in three sections. **Coverage** reports what the estate has and the governed model does not (`dataset-unmapped`, `field-unmapped`). **Drift** reports disagreement between them (`field-removed`, `field-type-drift`, `enum-constraint-removed`, `enum-narrowed`, `enum-widened`, `contract-drift`, `dataset-removed`). **Conformance** carries the connector's own best-practice rules - Neo4j contributes two:

| Code | Severity | Meaning |
|---|---|---|
| `label-no-unique-id` | Warning | the label has no uniqueness constraint on any property, so `MERGE`-based ingestion can silently create duplicates |
| `polymorphic-relationship` | Info | the relationship targets multiple labels - only the first is governed as a reference |

Both fired on our example:

```json
[
  { "section": "Conformance", "severity": "Warning", "code": "label-no-unique-id",
    "subject": "Asset",
    "message": "Node label has no uniqueness constraint - MERGE-based ingestion can silently create duplicates.",
    "detail": null },
  { "section": "Conformance", "severity": "Info", "code": "polymorphic-relationship",
    "subject": "Person.OWNS",
    "message": "Relationship targets multiple labels - only the first is governed as a reference.",
    "detail": "targets: Company,Asset" }
]
```

`Person` and `Company` escaped the first rule because the constraints artifact gave them uniqueness facts; `Asset` has none, and that is precisely the condition under which a nightly `MERGE (a:Asset {serial: $s})` starts producing duplicates the day two loaders disagree about serial formatting. The `Person.OWNS` note is not a defect - polymorphic edges are legitimate modeling - but a reader of the governed model would otherwise believe the reference is single-target, so we say it every time.

The same report comes back pre-rendered for humans:

```bash
jq -r '.markdown' audit-response.json > audit-report.md
```

That file leads with a verdict line and the three counts, followed by a metrics table and collapsible per-section finding lists - paste-ready into a pull request or a review doc.

## Where this goes next

You now have a governed model and a recorded baseline. Four verbs extend it: `POST .../reaudit/$PROJECT_ID` re-checks the stored snapshot against the *current* governed model whenever meaning changes, with no artifacts needed; `GET .../history/$PROJECT_ID` returns the rolling trail; `GET .../badge/$PROJECT_ID` serves an SVG status badge from the latest recorded run; and `POST .../generate/$PROJECT_ID` emits `coremodels_constraints.cypher`, an idempotent script that makes Neo4j itself enforce the governed shape - and once a steward governs `Asset.serial` as unique in CoreModels, regeneration includes the uniqueness constraint `Asset` is missing today.

For the condensed reference version of these commands, see the Neo4j quickstart in the CoreModels integration docs.
