KG Orphan Detection and Repair (orchkg)
Runbook for the session-254 invariant: every node in the operational KG must be
reachable from a root (:Project) node (undirected reachability; enforced by
scripts/check_kg_connectivity.py). Written after the 2026-10-13 orphan audit that
found 28,889 floating modules and repaired the graph to 0 unreachable of 1,088,711
nodes.
TL;DR
# Authoritative census (native in-graph BFS since 2026-10-13; ~47s on 1M nodes)
ORCHESTRATA_ENV_PROFILE=test python scripts/check_kg_connectivity.py
# Canonical-schema conformance (constraints + keyless-node scan)
ORCHESTRATA_ENV_PROFILE=test python scripts/kg_schema.py check
Both must pass. If the census reports unreachable nodes, work through Diagnosis and Repair recipes below.
What an "orphan" actually is here
An orphan is not a node without edges. The 2026-10-13 incident proved the
dangerous shape: subtrees that are fully edged internally (Package→Module,
DEFINES, IMPORTS, HAS_PARAMETER all present) but have no path to any
(:Project) root, because the single top edge — Project-[:CONTAINS]->Package —
was never written. Reachability is the criterion, not degree.
Root causes found (2026-10-13) — four distinct loader gaps
-
GraphSyncer.incremental_syncnever created the Project root.incremental_synccalls_sync_ast(..., sync_project_node=False)(deliberate — totals must describe the whole project, not a subset), but_sync_contains_edgeshangs depth-0 packages offMATCH (a:Project {id}). A MATCH on a non-existent node silently writes zero edges — no error, no log. Any project whose first load was incremental (everywt-*worktree sync viascripts/ast_live_sync.py) floated. Fixed (commit7a29723dd):_sync_astnow always runsMERGE (p:Project {slug}) ON CREATE SET p.id, p.namebefore the edge phase; existing Project totals are never touched by incremental syncs. Regression:tests/knowledge_graph/test_sync_incremental_project_root.py. -
sync_architecture_to_graph.pyupserted Patterns no Component referenced.Component-[:APPLIES]->Patternbinds via each component'sprotocolslist; "Stale PID cleanup", "Three-tier gRPC logging" and "Exponential backoff" were inPATTERNSbut in no list → zero-degree islands. Fixed (commit872213961): Gateway applies the first two, Kernel applies Exponential backoff. -
Tier-J ingest without the root/link seams (dr-hannah).
Architecture,ArchitectureDecision,Artifactnodes for projectdr-hannahwere written without aProject{dr-hannah}node and withoutHAS_ARCHITECTURE/DECIDES/DOCUMENTSedges.ArchitectureWriter. upsert_architecturedoes MERGE the owning Project — the ingest path that wrote these nodes never called it. Repaired data-side; writer-side guard is follow-up work. -
Test pollution without
test_id.tests/scripts/test_ast_live_sync.pyleft_live_<hex>-prefixed Package/Parameter nodes carrying notest_idproperty, so the session sweep (tests/knowledge_graph/conftest.py::_graph_session_sweep, which deletesn.test_id IN $ids) could never reclaim them. Deleted manually (7 nodes). Lesson: any test that writes graph nodes must stamptest_id— RUL-025's exemption only covers nodes that carry it.
Diagnosis
Fast targeted scans (seconds)
// Orphan code subgraphs: Modules unreachable from any Project
MATCH (m:Module) WHERE NOT (:Project)-[:CONTAINS*0..]->(m)
RETURN split(m.id, ':')[0] AS prefix, count(*) ORDER BY count(*) DESC;
// True islands: zero-degree nodes by label
MATCH (n) WHERE NOT (n)--() RETURN labels(n)[0] AS label, count(*);
// Code nodes detached from their parent tier
MATCH (c:Class) WHERE NOT (:Module)-[:DEFINES]->(c) RETURN count(c);
MATCH (m:Method) WHERE NOT (:Class)-[:DEFINES]->(m) RETURN count(m);
MATCH (p:Parameter) WHERE NOT ()-[:HAS_PARAMETER]->(p) RETURN count(p);
// Runtime hubs (direction matters: Intent-[:TRIGGERED]->PipelineRun,
// GatewayAuditEvent-[:DURING]->Session — outbound, not inbound)
MATCH (s:Session) WHERE NOT (:Project)-[:HAS_SESSION]->(s) RETURN count(s);
Authoritative census
scripts/check_kg_connectivity.py — BFS from all (:Project) seeds over
undirected edges, exit 1 if anything is unreachable. Since 2026-10-13 (commit
196ce76a7) it is dialect-aware: on Memgraph a single native [*BFS..32]
expansion collects the reachable id set engine-side and one anti-join aggregates
unreachable counts per label (~47s total; the 1.09M-row reachable set never crosses
the wire). On Neo4j — no *BFS modifier, and a bounded [*..N] would
path-enumerate exponentially — the original Python-side batched BFS remains as
fallback. Do not reintroduce Python-side frontier BFS on Memgraph: its
MATCH (n)--(m) WHERE id(n) IN $ids batches use no index; two runs exceeded
30 minutes without finishing.
Repair recipes
All repairs are MERGE-based and idempotent. Run against orchkg with
GraphSyncer(no_auth=True) (see .cache/kg_audit*.py in the repo for the
importlib harness pattern).
Re-root a floating code subgraph
MERGE (p:Project {slug: $prefix}) ON CREATE SET p.id = $prefix, p.name = $prefix;
MATCH (p:Project {slug: $prefix})
MATCH (pkg:Package) WHERE pkg.id STARTS WITH $prefix + ':'
AND NOT ()-[:CONTAINS]->(pkg)
MERGE (p)-[:CONTAINS]->(pkg);
Wire an unreferenced Pattern
Add the pattern name to the right component's protocols list in
scripts/sync_architecture_to_graph.py and re-run it (idempotent). Never hand-write
APPLIES edges for data the sync script owns — fix the source so the next run keeps it.
Attach Tier-J strays
Mirror the canonical shape (measured on orchestrata-ultimate):
Project-[:HAS_ARCHITECTURE]->Architecture,
ArchitectureDecision-[:DECIDES]->Architecture,
Artifact-[:DOCUMENTS]->Architecture.
Delete untagged test pollution
MATCH (n) WHERE n.id STARTS WITH '_live_' DETACH DELETE n;
Only safe for prefixes that are provably dead test namespaces — check
n.test_id absence and the producing test first.
Prevention checklist (for any new loader/writer)
- [ ] First write for a project MERGEs the
(:Project)root before any edge phase. - [ ] Edge builders that MATCH their endpoints must be preceded by a MERGE of those endpoints in the same sync path — a silent zero-row MATCH is the orphan factory.
- [ ] Every test writing nodes stamps
test_idand registers it (conftest sweep). - [ ] After any bulk load: run the census +
kg_schema.py check, not just row counts. - [ ]
GraphSyncer.verify()is not sufficient: its pass bar iscount >= 1and its class/function/method counts are global, not project-scoped (known weakness, follow-up owed).
Follow-ups still open
verify()should compare project-scoped counts against the AST JSON, not assert global existence.MAPS_TOcross-project edges: 0 in the live graph — themapsubcommand has never run (or was wiped); theorchestratamonorepo project is not synced at all.- Writer-side guard so
ArchitectureWriteringest paths cannot persist Tier-J nodes without the Project root + link seams (same class of gap as gap 1). - Ontology contradictions documented in the 2026-10-13 audit: codemod labels declared
without merge keys; Message/CXPPacket duplication; Agent↔Volk mapping tension;
kg_design.pynon-canonical writers (CodeClass/CodeFunction/RTMSnapshot) — migration owed under REQ-106-007/008.
References
- KG migration to orchgraph
- Orchestrata Ultimate runbooks
- Ruling RUL-025 —
conformance gate exempts
test_id-carrying ephemeral fixtures. - Session-254 ruling (connectivity invariant) — enforced by
check_kg_connectivity.py. - Audit harness scripts:
.cache/kg_audit.py….cache/kg_orphan_review.py(repo).