Hard Invariants¶
This is a load-bearing file. These five invariants are the project's constitution-level constraints. Violating any of them produces a broken or banned architecture.
AGENTS.mdcarries the canonical short list (the front door); this file is the authoritative detailed source — rule, why, enforcement status, violation symptom, and recovery for each.Enforcement-status vocabulary: - guard-checked (
scripts/check-invariants.sh) — the guard script greps the tree and flags a violation. Run three ways:make check-invariantslocally, the agent skills under.claude/skills/, and an advisory, non-blocking CI job (Invariant Guardsinci.yml). It surfaces violations; it does not gate merge — so a contributor who isn't using an agent is never blocked by it. - test-pinned (<test>) — a Go test asserts the behavior;make test-unitfails when violated. This is a blocking CI gate (theunit-testsjob). - runtime-enforced — a validator ininternal/validation/rejects the bad CR inline during reconcile (aFailed/Invalidstatus), and/or the running operator refuses at boot. The CR never takes effect. - PROSE-ONLY — at risk — enforced only by convention and the absence of the forbidden construct; nothing actively rejects a violation.Honesty note: the
scripts/check-invariants.shguard now exists and covers INV-1, INV-2, INV-4, and INV-5 (grep) plus a static config/sample check for INV-3. It is advisory (non-blocking CI + skills +make check-invariants), NOT a required merge gate — the blocking CI gates arecheck-drift(generated artifacts) andunit-tests. INV-3 additionally gained a runtime check (image_validator.gorejects-communitytags, pinned byimage_validator_test.gounder the blockingunit-testsjob) plus theCALL dbms.components()backstop. INV-4 is test-pinned (blocking); the INV-5 single-StatefulSet shape is pinned on the production builder (see INV-5). Highest residual risk: a bare Docker Hub tag (neo4j:5.26.0, which is the community image) is not rejected statically — only the explicit-communitymarker is — so that case relies on the runtime edition backstop, by deliberate choice (see INV-3).
Recovery — how to fix a violation¶
Quick reference (folded in from the former AGENT-GUARDRAILS table); per-invariant detail follows below.
| Inv | Recovery |
|---|---|
| INV-1 | Move the check into an internal/validation/*_validator.go and call it inline from the reconciler. Delete any *_webhook.go / webhook config. |
| INV-2 | Use make dev-up / make test-integration. Remove references to other local-K8s tools. |
| INV-3 | Pin an -enterprise tag. Don't weaken the validators to admit community. |
| INV-4 | Keep the version-gated discovery config in the cluster builder (buildVersionSpecificDiscoveryConfig); don't hand-roll discovery flags elsewhere. |
| INV-5 | Use the existing {cluster}-server builder in internal/resources/cluster.go and the Neo4jBackup/Neo4jRestore controllers. Never add a long-running backup pod/sidecar. |
INV-1: NO-WEBHOOKS¶
- id:
NO-WEBHOOKS - rule: The operator MUST NOT use admission webhooks. No
ValidatingWebhookConfigurationorMutatingWebhookConfigurationmanifests, no*_webhook.gofiles, no webhook server wiring incmd/main.go. All validation lives ininternal/validation/(e.g.cluster_validator.go,image_validator.go,plugin_validator.go, …) and is called inline from the reconcilers. - WHY: Webhooks add a TLS-served admission endpoint that must be reachable by
the API server, fail closed on cert/network problems, and create a
bootstrapping dependency the project deliberately avoids. Controller-side
validation runs in the reconcile loop where the operator already has full
context, degrades gracefully (a bad CR goes to a
Failed/Invalidstatus instead of blocking the whole API group), and needs no cert plumbing. This was a foundational design decision, not an accident. - enforcement status: guard-checked (
scripts/check-invariants.sh) — greps for*_webhook.go/config/webhook/paths andValidating/MutatingWebhookConfigurationreferences in non-test Go. Advisory (non-blocking CI + agent skills +make check-invariants), not a merge gate. No test or runtime check rejects a webhook, so the guard is the only automated signal — if it is bypassed nothing else complains until the webhook plumbing breaks at install time. - violation symptom: Kubebuilder's
create webhookscaffolding, or hand-added webhook code, compiles and passes all current tests — there is no failing signal. The damage shows up later: cert-manager/CA dependency at install time, CRs silently rejected at admission when the webhook pod is down, and aconfig/webhook/directory thatmake manifestsstarts regenerating. An agent "adding validation" via a webhook will think it succeeded.
INV-2: KIND-ONLY¶
- id:
KIND-ONLY - rule: Kind is the only supported Kubernetes distribution for
development, test, and CI. Never minikube, k3s, k3d, Docker Desktop K8s, or a
real cloud cluster in any dev/test/CI path. Clusters: dev =
neo4j-operator-dev, test =neo4j-operator-test. Use themaketargets (dev-up,test-integration,operator-setup); never hand-roll a different provisioner. - WHY: Every Makefile target, the CI workflows, the resource-shrinking logic
(
getCIAppropriateResourceRequirements()/applyCIOptimizations()), and the cert-managerca-cluster-issuersetup assume Kind's networking, image-load semantics (kind load), and node layout. Another distro silently changes pod DNS, storage classes, and load-balancer behavior, producing flaky failures that look like operator bugs but are environment drift. - enforcement status: guard-checked (
scripts/check-invariants.sh) — greps the Makefile,hack/,scripts/, and.github/workflows/(the operational paths that provision clusters) forminikube/k3s/k3d. Advisory (non-blocking). Prose mentions in docs/CONTRIBUTING and per-distro storageClass hints inexamples/are intentionally not scanned — they are not provisioners. - violation symptom: An agent adds a
minikube start/k3d cluster createpath to a script or workflow and it "works on my machine" but the operator can't load its image, discovery RBAC behaves differently, or integration tests time out on image pulls. No existing test or script complains — the new path just exists alongside the Kind path and rots.
INV-3: ENTERPRISE-IMAGES¶
- id:
ENTERPRISE-IMAGES - rule: Only Neo4j Enterprise images may be used:
neo4j:5.26-enterprise,neo4j:2025.01.0-enterprise, etc. Never a community image. The operator assumes Enterprise features (clustering, RBAC, online backup) unconditionally; there is noeditionfield in any CRD. - WHY: The operator emits Enterprise-only Cypher and config (clustering
topology,
SHOW SERVERS, role/privilege management,neo4j-admin database backup). Against a community image these silently fail or the pod won't form a cluster at all. The product is an Enterprise operator by definition. - enforcement status: runtime-enforced + guard-checked.
internal/validation/image_validator.gorejects any image whose tag explicitly marks it community (isCommunityTag— matchescommunitycase-insensitively, e.g.5.26.0-community), returning a cleanspec.image.tagvalidation error; pinned byimage_validator_test.gounder the blockingunit-testsjob.scripts/check-invariants.shadds a static check that no CRD/manifest inconfig/+api/pins a…-communityimage (advisory). The running operator'sCALL dbms.components()check remains the backstop. The numeric version is still validated vianeo4j.ParseVersion/version.IsSupported()(5.26.x or 2025.x+).edition_validator.goremains an intentional no-op (the edition field was removed). Known gap (by design): the validator rejects only the explicit-communitymarker — it does NOT require an-enterprisesuffix — so a bareneo4j:5.26.0(the community image on Docker Hub) passes static/inline validation and is caught only by the runtime edition backstop. This avoids false-rejecting legitimately retagged Enterprise images in a private registry. - violation symptom: A CR with
image: { repo: neo4j, tag: 5.26-community }is now rejected up front byimage_validator.gowith a clearspec.image.tagerror. The remaining trap is a bare tag —image: { repo: neo4j, tag: 5.26.0 }— which on Docker Hub is the community image yet passes inline validation; it is caught only later when the running operator'sCALL dbms.components()reportscommunity, surfacing as a degraded status rather than a clean up-front rejection.
INV-4: V2_ONLY-DISCOVERY¶
- id:
V2_ONLY-DISCOVERY - rule: Cluster discovery uses the V2 protocol exclusively (LIST resolver,
static pod FQDNs, port 6000). For SemVer 5.26.x the operator MUST emit
dbms.cluster.discovery.version=V2_ONLYexplicitly; for CalVer 2025.x+ (including 2026.x+) the flag is omitted because V2 is the only protocol — the CalVer key isdbms.cluster.endpointsinstead ofdbms.cluster.discovery.v2.endpoints. Never use the deprecated V1 protocol (port 5000) or any 4.xcausal_clustering.*discovery config. - WHY: V1 discovery is deprecated and removed in the supported versions; the
static-FQDN LIST resolver is the only reliable mechanism in Kind where pod IPs
are ephemeral. Emitting
V2_ONLYon 5.26 but omitting it on CalVer is load-bearing: CalVer rejects the flag, and 5.26 won't reliably pick V2 without it. - enforcement status: test-pinned (
TestListDiscoveryConfigurationininternal/resources/cluster_startup_test.go; blockingunit-testsjob) and additionally guard-checked —scripts/check-invariants.shgreps non-test Go for an emitteddiscovery.version=V1/V1_ONLY(advisory). The version-specific block is built bybuildVersionSpecificDiscoveryConfig()(internal/resources/cluster.go), which branches onisCalverImage(tag); the test asserts 5.26 emitsdbms.cluster.discovery.version=V2_ONLYand CalVer does not. - violation symptom: Changing the discovery block so 5.26 drops the
V2_ONLYflag, or so CalVer adds it, failsTestListDiscoveryConfigurationundermake test-unit. If the test were bypassed, the live symptom is clusters that never form — pods log discovery errors andSHOW SERVERSshows isolated single-member views (see split-brain detection).
INV-5: SERVER-ARCH¶
- id:
SERVER-ARCH - rule: A cluster is a single StatefulSet named
{cluster}-serverwithreplicas: N; pods are{cluster}-server-0 … {cluster}-server-N-1. Servers self-organize into primary/secondary roles — never name pods or StatefulSetsprimary-*/secondary-*. Backups are Job-per-Neo4jBackup-CR exclusively: there is NO centralized{cluster}-backupStatefulSet, NO long-running backup pod or sidecar, NOspec.backupsfield, and NOBuildBackupStatefulSet/BackupsSpec/buildBackupSidecarContainersymbols (CLAUDE.md rule 79 — all REMOVED, never reintroduce). - WHY: The server-based model lets Neo4j's own cluster machinery assign
primary/secondary roles dynamically; pinning roles to pod names re-creates the
banned 4.x
core/read-replicatopology and breaks server mode-constraint hints. The centralized backup StatefulSet was a long-running pod that held a lease on backup storage and a sidecar that bloated every Neo4j pod; both were deliberately deleted in favor of ephemeral Jobs that run, write one artifact, and exit. Reintroducing either resurrects retired, conflicting code paths. - enforcement status: the single-StatefulSet shape is test-pinned on the
production builder:
TestServerStatefulSet_IsOneStatefulSetWithReplicasEqualToServers(internal/resources/server_architecture_test.go) asserts thatBuildServerStatefulSetForEnterpriseyields ONE StatefulSet named{cluster}-serverwithreplicasequal tospec.topology.servers, and noprimary/secondaryin its name.TestListDiscoveryConfiguration(internal/resources/cluster_startup_test.go) additionally asserts the pod FQDNs{cluster}-server-Nthat the discovery endpoints use. BewareTestBuildStatefulSetForEnterprise_WithFeatures(cluster_test.go) and its…_ParallelManagementsibling: they drive the Deprecated plural builderBuildServerStatefulSetsForEnterprise(N one-replica StatefulSets), so they do NOT prove this invariant —internal/resources/CLAUDE.mdtells you not to use that builder. The shape is also exercised end to end by the integration specs intest/integration/multi_node_cluster_test.go(extended: fetches{cluster}-serverand assertsreplicas) andplugin_test.go(core). The removed-backup half is guard-checked —scripts/check-invariants.shgreps forBuildBackupStatefulSet, abackups:/spec.backupsfield in the CRD surface (config/+api/), and-primary-/-secondary-pod-name construction ininternal/resources/(advisory). The other removed symbols (BackupsSpec,buildBackupSidecarContainer) are kept gone by file/symbol absence only. - violation symptom: Renaming the StatefulSet, changing its replica count away from
spec.topology.servers, or introducingprimary-*/secondary-*names failsTestServerStatefulSet_IsOneStatefulSetWithReplicasEqualToServersundermake test-unit. Re-adding aspec.backupsfield or aBuildBackupStatefulSetbuilder, however, compiles and passes all current tests — the live symptom is a resurrected long-running backup pod competing with the Neo4jBackup Job path for the same storage, andmake manifestsregenerating CRD fields that were intentionally deleted.