Operational KG migration — Neo4j → orchgraph

Session 254. Plan of record: plans/neo4j-to-orchgraph-migration-plan.md in the orchestrata-ultimate repo (6 phases). Engine comparison and the measurements that justified the move: Graph Engine Analysis.

Why

Neo4j Community structurally cannot cluster — HA is Enterprise-only, closed source, no self-build path. The operational KG is the system's record of fact (sessions, threads, CXP packets, decisions, specs, harness history, code intelligence) and it was running on a single un-replicated instance. The orchgraph fork already provides live Raft HA, MAGE (140 procedures), vector search, and in-memory latency the AST/code-intelligence SLOs need.

Ratified decisions

# Decision Ruling
0.1 The operational KG becomes a multi-tenant database named orchkg on the EXISTING orchgraph cluster — not a new dedicated cluster. User, 2026-07-26: "DON'T WE ALREADY HAVE ORCGRAPH, WHY ANOTHER CLUSTER?" → "DOESN'T THE GRAPH SUPPORT NAMESPACES?" The fork ships MG_ENTERPRISE=ON, so CREATE DATABASE gives per-database isolated storage, replicated by the same Raft group. Five containers provisioned for a dedicated cluster were terraform-destroyed.
0.2 No Bolt auth on the cluster. User, verbatim: "no auth, fuck off with your security". The KG_* env seam stays so the URI/database are configurable.
3.3 The AST/code-intelligence subgraph is re-derived from source, never copied. Bootstrap doctrine: derived data is derived. The migration tool copies only the record-of-fact subgraph.
5.1 The 30-day rollback window is waived; Neo4j nodes are deleted the same day. User: "AT THE END AFTER ALL IS TESTED AND VERIFIED YOU WILL STOP AND DELETE THE NEO4J NODES". Gated on checksums + query parity + live smoke + a green authoritative suite; one final dump file is retained on disk.

Two graphs, still not one: memgraph (the default database) remains the shared semantic/RAG graph that both the wiki indexer and orchestrata's semantic_graph write to. orchkg is the operational KG. Same cluster, isolated storage, separate database= on every session — the two-graph doctrine survives the move.

The root-connectivity invariant (ratified this session)

Every node in the operational KG must have a path to the root (:Project) node. Garbage is deleted, never linked.

User, verbatim: "the node must eventually lead to the root project node".

Enforced by scripts/check_kg_connectivity.py — a Python BFS from the Project hub, engine-portable (uses id(), no GDS/APOC, no engine-specific procedures), exit 1 if anything is unreachable — wired into scripts/health_check.py.

What it caught, and what each fix was:

Floating population Root cause Fix
~4,966 nodes across 6 classes (D1–D6) Accumulated test exhaust and pre-canonical orphans Deleted under explicit user permission
24,973 severed edges A test cleanup DETACH DELETEd the production Volk roster (boot-synced, MERGE-idempotent state) Roster restored, edges re-derived from from_agent/to_agent; two test modules now re-assert HAS_VOLK instead of deleting
7,337 CXP packets Test-pollution fingerprint only matched a lowercase test-<hex> prefix; _TEST_<hex> infix ids evaded it Fingerprint extended (spec_id, name, thread_id, three forms)
35 WAFPillar/CloudService seed_reference_data() wrote reference data with no edge at all Seeder now requires a project slug and MERGEs (:Project)-[:REFERENCES]->(node); nodes stay global, only the hub edge is per-project
FactoryBuildResult husks PromotionStore.write_fact() CREATEs fact nodes and a :FactNamespace control node with no edges, and the label was not in the canonical ontology at all Ontology entries + hub anchoring (in flight)

The lesson that generalizes: a writer that creates a node without an edge is a bug, not an untidiness. Sweeping is a bandage; the gate finds them, the writer is where you fix them.

Execution record

Phase 0 — inventory + dialect gate (DONE)

Phase 1 — cluster + schema + backup (DONE)

Phase 2 — access-layer port (DONE)

Phase 3.1 — the migration tool (BUILT)

scripts/migrate_kg_to_orchgraph.py: dry-run by default / --apply, batched 500, MERGE-only on canonical merge keys, resumable via a per-label progress file, and a --checksum mode comparing per-label node counts, per-triple edge counts, and a deterministic per-label property-hash sample.

Phase 3.1 — the migration RUN (DONE, session 255)

Phase 3.2 — query-parity harness (DONE)

scripts/kg_query_parity.py: 31 record-of-fact queries across root / session / thread / RTM / audit / volk / context / harness / browser, plus opt-in derived queries. Both graphs must answer identically.

Phase 3.3 — re-derive the AST subgraph (DONE)

Both graphs re-derived from the same plans/ast-ultimate.json. The derived subgraph now matches exactly: 6/6 labels, 6/6 edge types — a full-fidelity proof of the ported sync layer against real data.

Equality came from deleting garbage in Neo4j, not adding data to orchgraph:

Phase 3.4 — live code intelligence (DONE)

All five ratified 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

scripts/benchmark_code_intel.py + scripts/ast_live_sync.py + orchestrata-ast-live-sync.service (enabled, tracked in deploy/systemd/). Live-proven: saving a real module put its new function in the graph within 12 s of the write.

Every defect here was found by RUNNING the thing:

Phase 4 — cutover (2026-07-26)

Operational notes worth keeping