Schedule icon
Query icon
If icon
Log icon
Switch icon
SlackIncomingWebhook icon

Neo4j Constraint Compliance Gate

Audit a Neo4j label's constraint coverage with SHOW CONSTRAINTS, alert on gaps, and optionally create the missing constraint.

Categories
DataInfrastructure

A node label with no uniqueness constraint can silently accumulate duplicate "unique" keys - two Account nodes with the same account_id, two Merchant nodes with the same merchant_id - because Neo4j does not enforce uniqueness unless a constraint says so. This blueprint queries Neo4j's own schema metadata with SHOW CONSTRAINTS to check whether a target label's governance-critical property is actually covered, alerts on a gap or on a label with no data to govern, and can create the missing constraint with CREATE CONSTRAINT ... IF NOT EXISTS - schema-only, never touching existing node data.

How it works

  1. periodic_constraint_check (io.kestra.plugin.core.trigger.Schedule) runs every 6 hours, always forcing auto_remediate: "false" and dry_run: "true" through its own inputs: override regardless of this flow's defaults. Shipped disabled so you can validate a manual run first.
  2. check_label_population (io.kestra.plugin.neo4j.Query) runs MATCH (n:{{ target_label }}) RETURN count(n) AS node_count with storeType: FETCHONE, populating row.node_count.
  3. check_constraint_coverage (io.kestra.plugin.neo4j.Query) runs SHOW CONSTRAINTS YIELD name, type, entityType, labelsOrTypes, properties WHERE entityType = 'NODE' AND '{{ target_label }}' IN labelsOrTypes AND '{{ required_unique_property }}' IN properties RETURN count(*) AS matching_constraints with storeType: FETCHONE, populating row.matching_constraints.
  4. evaluate_population (io.kestra.plugin.core.flow.If) branches on node_count > 0.
    • else (UNREACHABLE): alert_label_unreachable reports that the label has no nodes, so coverage could not be evaluated.
    • then (label populated): nested evaluate_constraint_presence (io.kestra.plugin.core.flow.If) branches on matching_constraints > 0.
      • then (HEALTHY): log_constraint_healthy records a clean check.
      • else (AT_RISK): route_remediation (io.kestra.plugin.core.flow.Switch on auto_remediate) either considers remediation ("true", gated again by dry_run through check_dry_run) or only logs that remediation is disabled ("false"). When both gates allow it, remediate_constraint_gap runs CREATE CONSTRAINT ... IF NOT EXISTS FOR (n:{{ target_label }}) REQUIRE n.{{ required_unique_property }} IS UNIQUE. alert_constraint_gap always fires to Slack regardless of which remediation path ran.
  5. log_audit_complete always runs last, printing the headline numbers regardless of which branch fired.
  6. The errors block alerts Slack separately if the flow itself fails - an unreachable Neo4j instance or bad credentials must not read as "no gap".

What you get

  • A direct answer, from Neo4j's own SHOW CONSTRAINTS metadata, to "is this property actually enforced unique on this label" - not inferred from sampling node data.
  • A third state, UNREACHABLE, that keeps an empty label from being reported as either healthy or at risk.
  • Remediation that only ever adds a constraint by name with IF NOT EXISTS - it cannot drop an existing constraint or touch node data.
  • Two independent gates (auto_remediate, dry_run) between "gap detected" and "constraint actually created".
  • A final status log every run, gap or not, so the execution history doubles as a schema-compliance trend for that label.

Who it's for

  • Platform and data governance teams running Neo4j who need an early signal when a label meant to be unique-keyed was never given a backing constraint.
  • Teams migrating data into Neo4j where constraints are easy to forget until duplicate "unique" nodes are already in the graph.
  • Anyone evaluating the Neo4j plugin who wants a worked example of Query driving schema introspection (SHOW CONSTRAINTS) and schema mutation (CREATE CONSTRAINT), not just data reads.

Why orchestrate this with Kestra

SHOW CONSTRAINTS run by hand answers one moment in time; it does not decide a cadence, classify the result into a three-state risk level, gate a schema change behind two independent confirmations, or notify anyone. Kestra supplies the schedule, the HEALTHY/AT_RISK/UNREACHABLE classification as a first-class branch, two independent authorization gates before any schema mutation, and an execution history that shows exactly when a label went uncovered and whether it was fixed.

Prerequisites

  • A reachable Neo4j instance (Community or Enterprise; SHOW CONSTRAINTS and CREATE CONSTRAINT ... IS UNIQUE are both available on Community Edition).
  • A node label (target_label) that may or may not have nodes, and may or may not have a constraint on required_unique_property.
  • A Slack incoming webhook for alerts.

Local testing: docker run -d --name neo4j-gate -p 7475:7474 -p 7688:7687 -e NEO4J_AUTH=neo4j/<your-local-password> neo4j:5 docker network connect YOUR_KESTRA_NETWORK neo4j-gate (only needed if the Kestra Worker runs in a separate Docker network than this container; set neo4j_url to bolt://neo4j-gate:7687 from inside that network, or bolt://localhost:7688 from the host).

Secrets

  • NEO4J_USERNAME / NEO4J_PASSWORD: basic-auth credentials used by every io.kestra.plugin.neo4j.Query task in this flow.
  • SLACK_WEBHOOK_URL: Slack incoming webhook used by alert_constraint_gap, alert_label_unreachable, and the errors block.
  • In Kestra OSS (no Enterprise secrets backend), secrets are supplied as environment variables prefixed SECRET_, base64-encoded, and read back in flows with {{ secret('NAME') }} - for example SECRET_NEO4J_PASSWORD=$(echo -n '<your-local-password>' | base64). This keeps credentials out of the flow YAML but, per Kestra's own documentation, offers no encryption at rest or access control beyond the host environment; use the Enterprise secrets backend for stronger guarantees.

Inputs

  • neo4j_url (STRING, default bolt://localhost:7687): Bolt endpoint.
  • target_label (SELECT: "Account", "Device", "Merchant"; default "Account"): label audited. Bounded so it is never spliced into Cypher from unbounded input.
  • required_unique_property (STRING, default account_id): property expected to be constraint-covered.
  • auto_remediate (SELECT: "false", "true"; default "false"): must be "true" for remediation to even be considered.
  • dry_run (SELECT: "true", "false"; default "true"): must be explicitly "false", together with auto_remediate: "true", for CREATE CONSTRAINT to actually run.

Outputs

  • outputs.check_label_population.row.node_count: node count for target_label, every run.
  • outputs.check_constraint_coverage.row.matching_constraints: count of matching constraints from SHOW CONSTRAINTS, every run.

Quick start

  1. Start Neo4j locally with the command above and create a few Account nodes without a constraint: CREATE (:Account {account_id: "a1"}), (:Account {account_id: "a1"}) (two nodes sharing the same account_id on purpose).
  2. Add the NEO4J_USERNAME, NEO4J_PASSWORD, and SLACK_WEBHOOK_URL secrets.
  3. Run the flow manually with the defaults and confirm alert_constraint_gap fires (AT_RISK: nodes exist, no constraint yet).
  4. Run once with auto_remediate: "true" and dry_run: "true" and confirm log_dry_run_remediation describes the constraint without creating it.
  5. Run once with auto_remediate: "true" and dry_run: "false" and confirm remediate_constraint_gap runs, then verify with SHOW CONSTRAINTS in the Neo4j Browser that the constraint now exists.
  6. Delete all Account nodes and re-run to confirm alert_label_unreachable fires (UNREACHABLE) instead of either HEALTHY or AT_RISK.
  7. Enable periodic_constraint_check once you trust the check; it always runs in the safe auto_remediate: "false" / dry_run: "true" mode regardless of what you leave the flow's own defaults set to.

How to extend

  • Loop over several target_label/required_unique_property pairs with io.kestra.plugin.core.flow.Loop to run the same gate across multiple labels in one flow.
  • Add a second SHOW CONSTRAINTS check for NODE KEY or NODE PROPERTY EXISTENCE constraints alongside uniqueness, to cover required-field compliance as well as duplicate-key risk.
  • Replace the CREATE CONSTRAINT remediation with a report-only mode that writes the suggested Cypher to a file via io.kestra.plugin.core.storage.Write, for teams that want change control outside Kestra.
  • Route the UNREACHABLE branch to a different Slack channel than AT_RISK, since an unpopulated label often means an upstream load job never ran, not a schema problem.

Pitfalls

  • An unpopulated label is not the same as a healthy one. Reporting UNREACHABLE separately from HEALTHY and AT_RISK keeps an empty label - which could mean an ingestion job silently failed - from being misread as "constraint satisfied, nothing to worry about".
  • There is no structured "show constraints" or "create constraint" task in this plugin. Verified from the plugin's own source: only Query and Batch exist under io.kestra.plugin.neo4j. Both schema introspection (SHOW CONSTRAINTS) and schema mutation (CREATE CONSTRAINT) go through the same generic Query task as ordinary Cypher text.
  • target_label is a SELECT, not a free-text STRING. Because the label is spliced directly into MATCH (n:{{ target_label }}) and CREATE CONSTRAINT ... FOR (n:{{ target_label }}) (Cypher has no bind-parameter syntax for label names), bounding it to a fixed list is what keeps this safe from arbitrary Cypher injection through that field.
  • auto_remediate and dry_run are independent gates, not a single boolean. Both must be "true"/"false" respectively for CREATE CONSTRAINT to run; setting only one leaves the other still blocking.
  • Switch case keys "true"/"false" are quoted strings. auto_remediate renders as the literal string "true" or "false"; unquoted true:/false: YAML map keys would parse as booleans instead and would not match.
  • This blueprint is UNTESTED against a live Neo4j instance. The SHOW CONSTRAINTS YIELD ... WHERE ... RETURN count(*) and CREATE CONSTRAINT ... IF NOT EXISTS ... REQUIRE ... IS UNIQUE syntax is taken verbatim from Neo4j's own Cypher manual, and the Query task's properties (url, username, password, query, storeType) and outputs (row, rows, uri, size) are taken verbatim from the plugin's source on main - but no Docker container or Kestra engine was run to execute this flow end to end.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.