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:
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:
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 (detailcarriesgoverned: 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, whereMERGE-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:
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 --rawfileembeds each export as a JSON string with no shell-quoting hazards, and the payload never passes throughargv.- The HTTP-status check and the
.successcheck 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.markdowngoes to the job summary, so the full report — metrics table and collapsible sections — is one click from the red X.data.driftedObjectslists 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:
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:
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:
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 triggerci. - 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.