# Neo4j — Automation: Automating Neo4j Drift Control: The Gate, the Trail, the Badge, the Heartbeat

Most CI integrations start with an artifact the build already produces. Neo4j does not produce one - there is no compile step for a property graph, no manifest, no migration file that reviewers can read. So the first job in automating Neo4j governance is not writing the gate. It is deciding where `meta_schema.json` comes from in your pipeline.

# Automating Neo4j Drift Control: The Gate, the Trail, the Badge, the Heartbeat

Most CI integrations start with an artifact the build already produces. Neo4j does not produce one - there is no compile step for a property graph, no manifest, no migration file that reviewers can read. So the first job in automating Neo4j governance is not writing the gate. It is deciding where `meta_schema.json` comes from in your pipeline.

Once that is settled, everything else is mechanical: one POST, one number, and three recording loops that keep the number honest between pull requests.

Throughout: host `https://coremodels.example.com`, `$PROJECT_ID` is the 32-character hex project id, and the credential is a CoreModels **user API key**. Viewer access to the project is enough - the audit never writes.

## Getting the artifact into CI

Two patterns work, and they suit different teams.

**Version-control the export.** Treat `meta_schema.json` (and optionally `constraints.json`) as reviewed artifacts in the repository that owns your loaders and Cypher migrations. A schema-affecting change then arrives *with* an updated export, and the audit checks exactly what the pull request claims the graph will look like. This is the pattern with the strongest review story: the diff of the export is itself a readable schema change.

**Extract from staging in a prior job.** If your pipeline already deploys to a staging instance, run the extraction against it before the audit step:

```cypher
CALL apoc.meta.schema() YIELD value RETURN value;
SHOW CONSTRAINTS;
```

Write the `value` column to `meta_schema.json` and the constraint rows to `constraints.json`, then audit what staging actually reports. This catches drift that no one wrote down - a loader that started emitting a new property, a constraint dropped during an incident and never restored.

Either way, the Neo4j credential lives in your pipeline and never reaches CoreModels. We read the artifact you hand us; that is the entire trust boundary.

## The gate: one route, one number

CI calls the machine-to-machine surface, which accepts user API keys:

```http
POST /v1/{projectId}/integrations/neo4j/audit
```

The body is the standard artifacts shape. The response is wrapped in the API envelope, so everything lives under `data.*`, and the contract is a single line: **`data.errorCount > 0` means the change violates governed meaning - fail the build.**

It is worth being precise about which findings carry Error severity for a Neo4j estate, because the answer is narrower than the full code list suggests:

- **`field-removed`** - a property governed in CoreModels no longer exists on the label in the estate. Error.
- **`field-type-drift`** - a governed property's native type changed since the last import (`detail` carries `governed: STRING, estate: INTEGER`). Error.
- **`enum-constraint-removed`** - Warning, and the one to expect if a steward governs a property with a controlled list: `apoc.meta.schema()` never declares value sets, so the estate cannot restate the constraint.
- **`label-no-unique-id`** - Warning. A label with no uniqueness constraint, where `MERGE`-based ingestion can silently create duplicates.
- **`polymorphic-relationship`** - Info. A relationship targets multiple labels; only the first is governed as a reference.

One honest limit belongs in the same breath. The `dataset-removed` rule is scoped to the identity namespaces present in the estate being compared - a design that lets one CoreModels project govern several estates of the same vendor without cross-firing. Neo4j identities are bare labels with no dotted namespace, so a label that disappears from the graph entirely falls outside that scope and is not reported as removed. Property-level removals on surviving labels are caught normally by `field-removed`. If whole-label deletions matter to you, add a cheap count comparison to the same job: the audit's `data.metrics["Datasets (estate)"]` against `governedDatasets` from `GET graph/integrations/neo4j/status/{projectId}`. When the estate reports fewer datasets than the project governs, a label went away.

## The workflow

The packaged "CoreModels Schema Audit" composite action is shaped around dbt's `manifest`/`catalog` artifacts, so for Neo4j you call the same `v1` endpoint directly. It is a dozen lines of `curl` and `jq`:

```yaml
name: neo4j-schema-audit

on:
  pull_request:

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: CoreModels Schema Audit
        env:
          COREMODELS_API_URL: ${{ vars.COREMODELS_API_URL }}
          COREMODELS_API_KEY: ${{ secrets.COREMODELS_API_KEY }}
          COREMODELS_PROJECT_ID: ${{ vars.COREMODELS_PROJECT_ID }}
        run: |
          set -euo pipefail

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

          http_status=$(curl -sS -o audit-response.json -w "%{http_code}" -X POST \
            "$COREMODELS_API_URL/v1/$COREMODELS_PROJECT_ID/integrations/neo4j/audit" \
            -H "Authorization: Bearer $COREMODELS_API_KEY" \
            -H "Content-Type: application/json" \
            --data-binary @audit-request.json)

          if [ "$http_status" != "200" ]; then
            echo "::error::CoreModels audit call failed with HTTP $http_status"
            head -c 2000 audit-response.json || true
            exit 1
          fi

          if [ "$(jq -r '.success' audit-response.json)" != "true" ]; then
            echo "::error::CoreModels audit returned an error"
            exit 1
          fi

          jq -r '.data.markdown' audit-response.json >> "$GITHUB_STEP_SUMMARY"

          # One annotation per actionable finding.
          jq -r '.data.findings[] | "\(.severity)|\(.code)|\(.subject)|\(.message)"' audit-response.json |
          while IFS='|' read -r severity code subject message; do
            case "$severity" in
              Error)   echo "::error title=$code::$subject - $message" ;;
              Warning) echo "::warning title=$code::$subject - $message" ;;
            esac
          done

          error_count=$(jq -r '.data.errorCount' audit-response.json)
          drifted=$(jq -r '(.data.driftedObjects // []) | join(",")' audit-response.json)
          if [ -n "$drifted" ]; then
            echo "Drifted objects: $drifted"
          fi

          if [ "$error_count" -gt 0 ]; then
            echo "::error::This change violates governed meaning in CoreModels."
            exit 1
          fi
```

Details that matter in that script:

- `jq -n --rawfile` embeds each export as a JSON string with no shell-quoting hazards, and the payload never passes through `argv`.
- The HTTP-status check and the `.success` check together distinguish "the audit ran and found problems" from "the call itself failed". An unknown project id or a malformed artifact must fail loudly, never masquerade as a clean run.
- `data.markdown` goes to the job summary, so the full report - metrics table and collapsible sections - is one click from the red X.
- `data.driftedObjects` lists the distinct subjects of Drift findings, which is the fastest human answer to "what exactly moved?"
- To gate harder, add `warning_count=$(jq -r '.data.warningCount' audit-response.json)` and fail on that too. Start on errors; tighten once the estate is clean.

Setup is two repository variables (`COREMODELS_API_URL`, `COREMODELS_PROJECT_ID`) and one secret (`COREMODELS_API_KEY`).

## `recordHistory` and the rolling trail

The request sets `recordHistory: true`, which appends a compact run record to the project's audit history with trigger `ci`. That single flag turns isolated checks into a trajectory. Read the trail on the interactive surface:

```bash
curl -sS -H "Authorization: Bearer $TOKEN" \
  "https://coremodels.example.com/graph/integrations/neo4j/history/$PROJECT_ID" |
  jq -r '.projects[].runs[] | "\(.at)  \(.trigger)  E\(.errorCount) W\(.warningCount) I\(.infoCount)  \(.fingerprint)"'
```

```
2026-08-04T05:00:11.9070000+00:00  scheduled  E0 W1 I1  3f9a1c0d47b28e65
2026-08-03T14:22:03.5510000+00:00  ci         E0 W1 I1  3f9a1c0d47b28e65
2026-08-01T09:40:55.2210000+00:00  reaudit    E1 W1 I1  8b1e07c4d2aa9f30
```

Each record carries the timestamp, the trigger, the three counts, the aggregated finding `codes`, and the artifact `fingerprint`. The fingerprint is what makes this trail cheap to reason about: two runs with the same fingerprint audited byte-identical exports, so a count change between them means the *governed model* moved, not the estate. The trail keeps the 50 most recent runs per estate, newest first.

Four triggers can record: `audit` (an interactive audit that opted in), `ci`, `reaudit`, and `scheduled`.

## The badge

The latest recorded run drives a self-contained SVG badge, served on both surfaces:

```bash
curl -sS -H "Authorization: Bearer $COREMODELS_API_KEY" \
  "https://coremodels.example.com/v1/$PROJECT_ID/integrations/neo4j/badge" > neo4j-audit.svg
```

Green when the last recorded run was clean, yellow when it had warnings only, red with the error count, gray when nothing has been recorded yet. The route is authenticated, so the usual pattern is to fetch it in the pipeline and publish the SVG wherever your README or dashboard can reference it.

The important property is what the badge *cannot* do: it reads recorded history, so it reflects the runs that actually happened. A badge that has been green for six weeks either means six weeks of clean runs or six weeks of no runs - which is exactly why the next two loops exist.

## Reaudit: the gate in the other direction

CI catches the estate moving away from the model. The opposite happens just as often: a steward tightens the governed model, and the *last-known estate* no longer conforms. Reaudit replays the audit engine over the snapshot stored at import time against the current governed model. No artifacts, no credentials:

```bash
curl -sS -X POST \
  "https://coremodels.example.com/graph/integrations/neo4j/reaudit/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{}'
```

It lives on the interactive surface (login token), not the `v1` surface, and it always records its run - no opt-in flag. Wire it wherever governed-model changes land: a webhook on your modeling workflow, or a step your stewards run after a review. One dependency: it needs the stored snapshot, so if import reported `snapshotStored: false` because the encoded snapshot exceeded the storage cap, there is nothing to re-audit and the call says so. Fresh-artifact audits still work in that case.

## The heartbeat

Between pull requests and model edits, nothing records at all. CoreModels ships a scheduled server-side re-audit for exactly that gap: it re-audits enrolled projects against their stored snapshots and records the runs with trigger `scheduled`. It is a deployment-level feature, **off by default**, enabled through configuration - `Integrations:ScheduledReaudit:Enabled`, with `:CronExpression` for the schedule and `:ProjectIds` for the list of projects that opted in. If you want it on, that is a conversation with whoever operates your CoreModels deployment, not a change in your repository.

## The four entry points

- **Every pull request** - the CI gate audits the proposed estate, writes the report to the job summary, annotates each finding, fails on `errorCount > 0`, records trigger `ci`.
- **Every governed-model change** - a reaudit checks the last-known estate against the new meaning, records trigger `reaudit`.
- **On a clock** - the heartbeat re-audits regardless of activity, records trigger `scheduled`.
- **Always** - the badge shows the latest recorded truth; the history shows the trajectory.

Four entry points, one audit engine, one trail. The build goes red the moment the graph and its meaning part ways - whichever side moved first.

For the extraction recipe and the full endpoint reference, see the Neo4j quickstart in the CoreModels integration docs.
