Orchestrata Ultimate › Plans › neo4j-to-orchgraph-migration-plan

Migration Plan — Operational KG: Neo4j → orchgraph

Source: plans/neo4j-to-orchgraph-migration-plan.md at 8a2cb42ad · last changed 2026-07-27 · first committed 2026-07-26 · 569 lines.

Prose pending.

Plan text

Migration Plan — Operational KG: Neo4j → orchgraph

Goal: move the operational knowledge graph off Neo4j 5.26.28 Community (neo4j-db 10.92.53.11) onto the orchgraph Memgraph fork, then re-point every tool, service, and test at it. At the end of this plan everything is wired and working: full authoritative suite green against orchgraph, all live consumers (gateway, kernel, KG gRPC server, timers, session sync) writing to it, Neo4j stopped. Grounded in the 2026-07-26 graph-engine analysis (wiki projects/orchestrata-ultimate/graph-engine-analysis).

Conventions: every step has a deterministic verification; no step is done until it passes AND its wiring site is named. Dry-run-by-default for every mutating tool. All connection material from .env, read at call time.

Live baseline (measured 2026-07-26, post-cleanup)

value
Nodes / edges 80,790 / 133,781 (fits orchgraph in-memory: ~0.1 GiB/copy)
Labels 83
Uniqueness constraints 76
Indexes 121 (incl. Lucene FTS search_names — 1 call site, knowledge_graph/search.py, CONTAINS fallback already written)
APOC not installed; zero call sites (last one fixed in 0b248bee)
GDS installed, zero call sites — nothing to port
Driver coupling 32 files import the neo4j driver (src+scripts); 35 src + 28 script files read NEO4J_* env; 1 hardcoded IP in src
Repository seam core/graph/repository.py has a backend registry ("additional backends can slot in without changing callers")
Existing migration base scripts/import_neo4j_to_memgraph.py (ontology-mapped, semantic-graph-targeted — NOT full-fidelity; Phase 3 builds the real one)

Key risk classes: (1) label collision with the shared cluster, (2) Cypher dialect drift, (3) MVCC write-conflict aborts vs Neo4j's pessimistic locks, (4) test-fleet swap, (5) silent consumers still writing to Neo4j after cutover.


Phase 0 — Decisions + mechanical inventory

Step 0.1 — RE-RATIFIED 2026-07-26 (supersedes the dedicated-cluster recommendation):

multi-tenant database orchkg on the EXISTING cluster

(superseded text follows for the record)

Step 0.1 — DECISION: dedicated cluster, not the shared one (recommendation)

The existing orchgraph cluster is the SHARED semantic/RAG graph; the wiki indexer writes Project, Session, Decision, Tag, Page there — the operational KG uses Project, Session, Decision too. Co-mingling the operational KG into it collides label-for-label and violates the two-graph doctrine (LEVEL0 #1 confusion source).

Step 0.2 — RATIFIED 2026-07-26: NO auth; KG_* env seam stays

Step 0.3 — Mechanical coupling inventory (no hand-reading)

Step 0.4 — Cypher dialect gate (build EARLY, run continuously)


Phase 1 — Provision the target (Terraform, not hands)

Step 1.1 — orchgraph-kg cluster via Terraform

Step 1.2 — DDL from canonical schema (ontology before import — constitutional)

Step 1.3 — Backup + retention plumbing

DONE 2026-07-26. Built as scripts/backup_orchgraph.sh + ~/.config/systemd/user/orchgraph-backup.{service,timer} (user units, matching orchestrata-kg-retention; linger is on, daily 03:00, Persistent=true, the bolt-port readiness guard on coordinator .160:7691). Named orchgraph-backup, not orchgraph-kg-backup, because the Step 0.1 pivot put the operational KG on the EXISTING cluster as the orchkg database — one cluster, one backup unit, all databases.


Phase 2 — Port the access layer (behind the seam, testable before any data moves)

Step 2.1 — Memgraph backend in the repository factory

Step 2.2 — Port the raw-driver holders

Step 2.3 — Test fleet swap (workers)


Phase 3 — Data migration (full fidelity, verified, reversible)

Step 3.1 — scripts/migrate_kg_to_orchgraph.py

Step 3.2 — Query-parity harness

Step 3.3 — AST subgraph: re-derive, don't copy (ratified 2026-07-26)

Exclusion set RATIFIED 2026-07-26 (orchestrator sign-off). The tool excludes 11 derived labels, not just the three this step names literally: Package, Module, Class, Function, Method, Parameter, File, Directory, Predicate, Rule, Test (live: 30,342 nodes). Predicate/Rule are in the set because they are parsed out of the Prolog sources by code_parser.py — the same "derived from source" property that put CodeFunction/CodeClass there; copying them would violate the doctrine for exactly the same reason.

Step 3.3 — EXECUTED 2026-07-26

Both graphs were re-derived from the same input (plans/ast-ultimate.json: 783 modules / 1494 classes / 1655 functions / 5573 methods) and compared with scripts/kg_query_parity.py --include-derived.

Result: the derived subgraph matches exactly — 6/6 labels, 6/6 edge types. Class 1494 · Function 1655 · Method 5561 · Module 783 · Package 783 · Parameter 10672; CALLS 4573 · CONTAINS 1566 · DEFINES 14271 · HAS_PARAMETER 10672 · IMPORTS 1938 · INHERITS 253. The ported sync layer is full-fidelity against real data.

Reaching that equality required deleting garbage from Neo4j, not adding data to orchgraph — orchgraph was right first:

  1. 41 stranded derived nodes (_test_satn_*, kg_*, pytest tmpdirs) sat in Neo4j as hub-unreachable islands, inflating it to 787 Modules / 1498 Classes / 805 Packages. A subtree is only reachable from the hub once its Project->Package edge exists, so a run that died between the node write and the edge write left an island that project-scoped cleanup could not see. Deleted per the root-connectivity invariant (garbage is deleted, never linked); the leak backstop is now in tests/scripts/test_sync_ast_to_neo4j.py::_detach_delete_project.
  2. The Rule premise above is superseded and is NOT actioned. Ontology ruling 3 (plans/architecture-ontology-draft.md §7) retired the Rule label in favour of Constraint{kind:"invariant"}; sync_architecture_to_neo4j.py writes only the canonical shape and explicitly does not migrate the old one. Live Neo4j carried BOTH — 6 Constraints AND 6 Rules, 10 MUST_FOLLOW->Constraint AND 10 MUST_FOLLOW->Rule — the retired shape as duplicate residue, not as data of record. orchgraph already held the canonical shape at identical counts (Constraint 6 · MUST_FOLLOW 10 · CONSTRAINS 6 · Pattern 9 · APPLIES 6). Re-deriving Rule would have imported retired residue into the graph that replaces Neo4j. Same for USES_PATTERN (6 parallel un-MERGEd edges between one node pair, fully covered by APPLIES) — deleted from both.

The harness now asserts the canonical shape for parity (component_must_follow_constraint, constraint_constrains_architecture, component_applies_pattern) and the retired shape as an ABSENCE on the target (RETIRED_SHAPE / retired_shape_violations, which fails the run on any residue). Parity on the retired shape would have meant copying it.

Remaining diffs are record-of-fact live-write growth on Neo4j since the delta (Session 752 vs 748, GatewayAuditEvent 1668 vs 1656 — source-greater in every case, never loss). Phase 4's freeze + final delta closes them.

Step 3.4 — Live code intelligence (session-254 requirement: sub-ms

review queries, seconds-fresh sync)

Step 3.4 — EXECUTED 2026-07-26

All five SLOs met, measured against the live cluster:

SLO Target Measured
Indexed symbol lookup (canonical id) p99 < 1 ms 0.720–0.790 ms
2-hop neighborhood p99 < 1 ms 0.709–0.728 ms
Full code-node predicate scan < 50 ms 8.222 ms
File-save → graph updated < 5 s 1.31–1.68 s
Whole-graph MAGE pass < 1 s 69.8 ms (pagerank) / 122.9 ms (wcc)

Built: scripts/benchmark_code_intel.py (9 SLOs, 24 tests), scripts/ast_live_sync.py (22 tests), ~/.config/systemd/user/ orchestrata-ast-live-sync.service (enabled + active, WantedBy= default.target, bolt-port readiness guard matching orchgraph-backup). Live-proven end to end: saved a real module → the service synced it → the new function was queryable in orchkg within 12 s of the write.

Every one of these was found by RUNNING the thing, not by reading it:

  1. Method.id had no index — Method is out of canonical_schema.py's canon, so it gets no CONSTRAINT and therefore none of the implicit indexes every other code label receives. A lookup by canonical id planned ScanAll over 5,561 nodes. Added method_id + method_name to schema.py. This is why every latency SLO also EXPLAINs its query and fails on a scan: a latency number alone cannot tell "fast because indexed" from "fast because the graph is small today".
  2. A p99 over 200 samples is noise. indexed_symbol_lookup_method "missed" at 3.135 ms p99 with a 13.884 ms single-sample max while its p50 (0.348 ms) matched Class and Function exactly. At n=1000 it measured 0.720 ms. Default raised to 1000 — reporting noise as a regression is the failure mode a benchmark exists to prevent.
  3. Parsing src/ instead of src/orchestrata_ultimate inserts a second codebase. Module ids are dotted paths RELATIVE to the analyzer root, so the wrong root renames every module and incremental_sync adds rather than updates: one pass took orchkg from 783 Modules to 1621. Repaired by a full re-derive. load_or_analyze treats a root mismatch as a cache miss and silently full-reparses — correct for a CLI, catastrophic for a service — so assert_cache_root_matches() now refuses to run instead.
  4. touch() measures nothing. Freshness is decided by CONTENT hash, so the first --measure reported PASS at 0.552 s having written 0 nodes. It now writes a real uuid-named probe module, measures, deletes it, and FAILS if nothing reparsed.
  5. Deletions were reported but never applied. A deleted file has no AST entry left, so incremental_sync cannot express the operation at all (it raises on module paths the AST does not contain) — the sync logged "1 removed" while the module's whole subtree stayed in the graph forever. Extracted Neo4jSyncer.delete_modules() from incremental_sync and wired it in; regression test asserts the Module AND its Functions leave the graph.
  6. --uri "" silently retargeted the graph. --uri "$KG_ROUTING_URI" with that key still blank in .env expands to an empty string, which sync_ast_to_neo4j.py turned into "use the default graph" — aiming orchgraph's DDL at the live Neo4j. Caught only because --no-auth made the wrong target reject the connection. An empty explicit --uri is now an error. Both new tools resolve KG_ROUTING_URI and fall back to MEMGRAPH_URI, so they follow the Phase 4.2 cutover without an edit.

Phase 4 — Cutover (freeze → delta → flip → verify live)

Step 4.1 — Freeze + final delta

Steps 4.1 + 4.2 — EXECUTED 2026-07-26

Final delta. Three delta runs, each a full --apply (MERGE, so a delta is whatever changed since the last):

run nodes relationships dropped edges
delta1 — — —
delta2 42,243 71,892 16 (12 `GatewayAuditEvent
delta3 42,259 71,908 0

delta2's 16 drops were not data loss: a live writer created those Sessions between the node phase and the edge phase of that same run, so the edges had no endpoint yet. delta3 picked up exactly those 16 nodes and 16 edges and dropped nothing — which is the evidence that the delta procedure converges.

Checksum parity: PASS — 118 label/relationship counts, source == target on every one, 0 mismatches (reports/migration-checksum-2026-07-26.txt).

Query parity: PASS — 31/31, no retired-shape violations (reports/migration-query-parity-2026-07-26.txt).

Live smoke (each verified, not asserted):

Defects the smoke exposed — all fixed, none deferred:

  1. Wrong-database class. Both graphs now share one URI, so isolation is the database= argument. Every session omitting it read the cluster default (the semantic graph): graph_browser served Chunk/ADR counts when asked for the operational KG, PromotionStore wrote promoted facts into the wrong database, the scheduler parked on watermarks there, and five sync/promotion test modules verified an EMPTY database and passed anyway. Neo4jSyncRepository and PromotionStore now expose a public session(); test-review T18 gates the shape.
  2. ADR-0019 had become FAIL-OPEN. orchgraph emits no Bolt bookmarks and silently ignores supplied ones, so await_watermark's "open a bookmarked session, run RETURN 1" probe returned immediately for every token — including one no write ever minted. Replaced with the ADR's own second sanctioned mechanism, an explicit visibility watermark stamped inside the same transaction as the facts. See the ADR's "Mechanism change 2026-07-26".
  3. Neo4j DDL against Memgraph. Neo4jSyncer's dialect defaulted to the literal "neo4j", so create_schema() raised on CREATE TEXT INDEX ... IF NOT EXISTS hours after the cutover. resolve_kg_dialect() now resolves it from KG_ENGINE at call time, and introspection (SHOW CONSTRAINTS/SHOW INDEXES) gained dialect-aware equivalents keyed on (label, property) — Memgraph's are unnamed.

Measured effect on the affected suites: 35 failures before, 0 after.

Step 4.2 — Flip + restart

Step 4.3 — Full authoritative suite, production config


Phase 5 — Decommission + knowledge propagation

Step 5.1 — Retire Neo4j surface (rollback window WAIVED — user ruling 2026-07-26)

Step 5.2 — Docs, memories, skills, wiki


Execution order & effort

Phase Depends on Effort Gate to next
0 Decisions + inventory + dialect gate — 1 day decisions ratified; inventory complete
1 Provision target 0 1 day cluster healthy, DDL idempotent, backup drill done
2 Access layer + test fleet 0, 1 2–3 days FULL suite green on orchgraph workers
3 Data migration + parity 1, 2 1 day checksums + parity 100%
4 Cutover 2, 3 0.5 day live smoke + full suite on prod config
5 Decommission + docs 4 + 30 days 0.5 day zero-diff, zero stale references

Rollback is defined at every point: before 4.2 nothing production-facing moved; after 4.2 it is "restart neo4j-db + flip .env back" for 30 days.

Definition of done (whole plan)

  1. Every step's verification shown, not asserted.
  2. Full authoritative suite (kernel started + gRPC-verified) green against the orchgraph production config AND orchgraph test workers.
  3. All live consumers demonstrably writing to the new cluster; Neo4j count frozen; container stopped.
  4. Backups: one successful automatic snapshot pull + one restore drill.
  5. LEVEL0/INFRASTRUCTURE/AGENTS/skills/memories/wiki all updated — no doc still calls Neo4j the operational KG.
  6. session end with artifacts, choices, handoff; CHANGELOG + tag.