Orchestrata Ultimate › Plans › hub-link-plan

Hub-link plan — restoring the root-connectivity invariant in orchkg

Source: plans/hub-link-plan.md at 8a2cb42ad · last changed 2026-07-27 · first committed 2026-07-27 · 823 lines.

Prose pending.

Plan text

Status: DESIGN (analyst). No production code changed by this document. Branch: feat/hub-link. Measured against the LIVE operational KG (neo4j://10.92.53.160:7691, database orchkg, auth none) on 2026-07-27.

1. The invariant and the breach

Session-254 ruling: every node in the operational KG must be reachable from the root (:Project) hub. scripts/check_kg_connectivity.py runs an engine-portable BFS from all :Project seeds over undirected edges and exits 1 when anything is unreachable.

Measured now:

TOTAL 64913   REACHABLE 56466   UNREACHABLE 8447
Label Unreachable
Parameter 5044
CXPPacket 2381
Intent 187
PipelineRun 187
Message 153
GatewayAuditEvent 144
Requirement 139
ToolCall 74
Session 54
UserStory 39
Fact 22
Action 8
Spec 6
Timeout 4
Resolution 3
Artifact 2

The count grew from 758 to 8447 within a session, so writers are actively minting orphans — this is not purely historical residue.

2. Anchoring topology (what "reachable" means per tier)

Diagram source shown as text: Mermaid could not parse it (Error: Parse error on line 4:).

graph LR
  P[":Project (slug)"]
  P -->|HAS_THREAD| T[":Thread"]
  T -->|IN_THREAD (in)| CX[":CXPPacket"]
  T -->|IN_THREAD (in)| MSG[":Message"]
  T -->|IN_THREAD (in)| TC[":ToolCall"]
  T -->|IN_THREAD (in)| ART[":Artifact"]
  T -->|IN_THREAD (in)| INT[":Intent"]
  T -->|IN_THREAD (in)| PR[":PipelineRun"]
  P -->|HAS_SESSION| S[":Session"]
  S -->|DURING (in)| GAE[":GatewayAuditEvent"]
  S -->|IN_SESSION / ABOUT (in)| EV[":Event :PolicyDecision :Timeout :Resolution"]
  P -->|CONTAINS| PKG[":Package"] --> MOD[":Module"]
  MOD -->|DEFINES| FM[":Function / :Method"]
  FM -->|HAS_PARAMETER| PARAM[":Parameter"]
  P -->|HAS_SPEC| SP[":Spec"]
  SP -->|HAS_STORY| US[":UserStory"]
  US -->|HAS_REQUIREMENT| REQ[":Requirement"]

Three anchoring families cover every unreachable label:

  1. Thread family — (:Project)-[:HAS_THREAD]->(:Thread)<-[:IN_THREAD]-(record). Already live and load-bearing: 7185 Threads, 18326 IN_THREAD CXPPacket edges. Prior art: scripts/backfill_thread_links.py.
  2. Session family — (:Project)-[:HAS_SESSION]->(:Session). Already live: 1098 of 1152 Sessions anchored.
  3. Code-intelligence / spec family — CONTAINS/DEFINES/HAS_PARAMETER and HAS_SPEC/HAS_STORY/HAS_REQUIREMENT.

No new anchoring concept is invented by this plan. Every verdict below reuses an edge that already exists in the live graph with a majority of its label already anchored that way.

3. Schema additions required first (RUL-016)

RUL-016 (~/workspace/knowledge-wiki/rulings/RUL-016-ontology-before-graph-import.md): the canonical ontology must define an anchoring path for a label before anything links it.

Already defined — no addition needed (src/orchestrata_ultimate/knowledge_graph/canonical_schema.py):

Constant Line Declares
LABEL_THREAD 176 "Thread" — Tier-H correlation anchor
REL_IN_THREAD 248 (signal)-[:IN_THREAD]->(Thread|Session)
REL_HAS_THREAD 260 (:Project)-[:HAS_THREAD]->(:Thread)
REL_HAS_SESSION 277 (:Project)-[:HAS_SESSION]->(:Session)
thread_entity_id(project, thread_id) 850 canonical Thread id builder

Missing — MUST be added in commit #1. Each of these four edges is already written by production code as a bare string literal with no canonical constant, so linking against them today would violate RUL-016:

New constant Value Bare-literal writers today
REL_HAS_PARAMETER "HAS_PARAMETER" knowledge_graph/sync.py (16 sites: L32, 371, 399, 429, 439, 448, 464, 470, 475, 511, 530, 728, 770, 787, 791, 803, 1416)
REL_HAS_SPEC "HAS_SPEC" session/kg_sync/_stage_specs_traces.py:180,188; knowledge_graph/kg_design.py:283
REL_HAS_STORY "HAS_STORY" knowledge_graph/kg_design.py:340; semantic_graph/spec_trace_enricher.py:175-176
REL_HAS_REQUIREMENT "HAS_REQUIREMENT" knowledge_graph/kg_design.py:391; semantic_graph/spec_trace_enricher.py:206-207

Note HAS_SPEC exists in the other, non-canonical knowledge_graph/schema.py:99 and types_enums.py:96 — that is not canonical_schema.py and does not satisfy RUL-016.

Answer to "schema additions needed?" — YES, four REL_* constants, one commit, before any writer or repair change. No new LABEL is needed: every unreachable label already has a LABEL_* constant and a CANONICAL_MERGE_KEYS entry.

4. Per-label verdicts

Reconciliation: 8409 LINK + 38 DELETE = 8447.

Label n Verdict Hub / edge Keyed on
Parameter 5044 LINK (:Function|:Method)-[:HAS_PARAMETER]->(p) p.parent_id == owner.id
CXPPacket 2381 LINK (p)-[:IN_THREAD]->(:Thread) thread_id
Intent 187 LINK (i)-[:IN_THREAD]->(:Thread) thread_id
PipelineRun 187 LINK (r)-[:IN_THREAD]->(:Thread) thread_id
Message 153 LINK (m)-[:IN_THREAD]->(:Thread) thread_id
GatewayAuditEvent 144 LINK (transitive) via its :Session none — fixed by Session
Requirement 138 LINK (transitive) via :Spec hub mint none — fixed by Spec
Requirement 1 DELETE husk {id:"REQ-008-023"}, degree 0 —
ToolCall 74 LINK (tc)-[:IN_THREAD]->(:Thread) thread_id
Session 54 LINK (:Project)-[:HAS_SESSION]->(s) slug
UserStory 39 LINK (transitive) via :Spec hub mint none — fixed by Spec
Fact 22 DELETE test residue, source='test' —
Action 8 DELETE test residue, agent IN ('pytest','') —
Spec 6 LINK (:Project{slug})-[:HAS_SPEC]->(s) s.project_slug
Timeout 4 DELETE test residue, _TEST_ escalation ids —
Resolution 3 DELETE test residue, conflict_id='c-resolve-neo4j' —

Writer: src/orchestrata_ultimate/knowledge_graph/sync.py. _sync_parameters (L767) MERGEs the node with no edge; the edge is a separate second pass _sync_parameter_edges (L786):

UNWIND $rows AS r
MATCH (owner:Function|Method {id: r.parent_id})
MATCH (param:Parameter {id: r.id})
MERGE (owner)-[:HAS_PARAMETER]->(param)
RETURN count(*) AS cnt

Schema: LABEL_PARAMETER at L100; CANONICAL_MERGE_KEYS[Parameter] = "id" at L484. REL_HAS_PARAMETER does not exist — add it (§3).

Evidence — the anchor key resolves 100%:

Q: MATCH (p:Parameter) WHERE NOT ()-[:HAS_PARAMETER]->(p) RETURN count(p) AS edgeless
   {"edgeless": 5044}
Q: MATCH (p:Parameter) WHERE NOT ()-[:HAS_PARAMETER]->(p) WITH p LIMIT 1500
   OPTIONAL MATCH (o) WHERE o.id = p.parent_id
   RETURN labels(o) AS parent_labels, count(*) AS c ORDER BY c DESC
   {"parent_labels": ["Method"],   "c": 946}
   {"parent_labels": ["Function"], "c": 554}          <- 1500/1500 resolved, zero nulls
Q: MATCH (p:Parameter) WHERE NOT ()-[:HAS_PARAMETER]->(p) WITH p LIMIT 4000
   RETURN p.namespace AS ns, count(*) AS c
   {"ns": null, "c": 4000}                             <- trunk generation, not ns-scoped

Orphan sample (5 nodes, properties):

{"id": "orchestrata-ultimate:agents.communication.CommunicationError.__init__:param:1",
 "name": "sender",  "parent_id": "orchestrata-ultimate:agents.communication.CommunicationError.__init__",
 "type": "", "default": "''"}
{"id": "...CommunicationError.__init__:param:2", "name": "receiver", ...}
{"id": "orchestrata-ultimate:agents.communication.AgentCommunication.__init__:param:1",
 "name": "prolog_bridge", "parent_id": "orchestrata-ultimate:agents.communication.AgentCommunication.__init__", ...}

The parent_id for the first sample resolves to a real :Method:

Q: MATCH (n) WHERE n.id = 'orchestrata-ultimate:agents.communication.CommunicationError.__init__'
   RETURN labels(n) AS labels
   {"labels": ["Method"]}

Verdict: LINK. Repair MATCHes owner.id = param.parent_id and MERGEs HAS_PARAMETER. Zero ambiguity, zero data invented.

Write-time fix: the edge pass already exists, so the code shape is right — but 5044 nodes with resolvable parents and no edge proves the pass silently under-delivered on the last sync (_sync_parameter_edges returns a count that nobody asserts against len(rows)). Fix = make the pass batched and self-verifying: chunk rows, sum the returned counts, and raise if edges_created < len(rows). Do not swallow. Rationale for batching: the live Memgraph aborts long single-transaction writes under storage.access_timeout, which would produce exactly this signature.

Ruled out by measurement, so the builder should not chase it: Memgraph does accept the MATCH (owner:Function|Method {...}) label-disjunction syntax — verified live, returns cnt: 1 for a known parent id.

Writer: src/orchestrata_ultimate/knowledge_graph/cxp_auditor.py, SpikeAuditor.log_packet (def L114), write L130–153:

MERGE (p:CXPPacket {message_id: $message_id})
SET p.thread_id = $thread_id, p.from_agent = $from_agent, p.to_agent = $to_agent,
    p.subject = $subject, p.payload = $payload, p.timestamp = $timestamp,
    p.priority = $priority, p.delivery_status = $delivery_status,
    p.work_status = $work_status

Creates no relationship. The only edge in the file is log_routing (L166–176) which is CXPPacket -> CXPPacket (ROUTED_TO) — packets chain to each other and the whole chain floats. Callers: postman/sync_router.py:82,127; auditor built at postman/postman.py:132-135.

Schema: REL_IN_THREAD (L248) + REL_HAS_THREAD (L260) + LABEL_THREAD (L176) + thread_entity_id() (L850) — all present, no addition needed.

Evidence:

Q: MATCH (c:CXPPacket) RETURN count(c) AS total
   {"total": 21961}                     <- 19580 already reachable via IN_THREAD
Q: MATCH (t:Thread)-[r]-(m) RETURN type(r) AS t, labels(m) AS other, count(*) AS c ORDER BY c DESC
   {"t":"IN_THREAD","other":["CXPPacket"],"c":18326}
   {"t":"HAS_THREAD","other":["Project"],"c":7185}
Q: MATCH (n:CXPPacket) WHERE NOT (n)-[:IN_THREAD]->() WITH n LIMIT 3000
   OPTIONAL MATCH (t:Thread {thread_id: n.thread_id})
   RETURN count(n) AS unlinked, count(n.thread_id) AS has_tid, count(t) AS thread_exists
   {"unlinked": 3000, "has_tid": 3000, "thread_exists": 61}

Orphan degree histogram: {0: 43, 1: 298, 2: 1659} — neighbour rel types are ROUTED_TO -> CXPPacket only. Orphan sample:

{"id":"legacy-000000000413","message_id":"22d51168-0a32-5581-ab3e-02765a7ae3dd",
 "thread_id":"eb041231-d6d4-4a26-99c0-be93915f2893","from_agent":"HUMAN","to_agent":"U:Cx",
 "subject":"intent","payload":"{\"text\": \"test intent\"}","delivery_status":"queued"}
{"id":"legacy-000000000416","thread_id":"c156665c-d44e-411a-b9fd-35b3552275dc","subject":"intent", ...}
{"id":"legacy-000000000417","thread_id":"c156665c-...","from_agent":"U:Cx","to_agent":"HUMAN","subject":"clarification", ...}

Every orphan carries a thread_id (3000/3000) but its :Thread mostly does not exist yet (61/3000). So the repair must MERGE the Thread and its HAS_THREAD hub edge, not merely MATCH it — which is exactly what backfill_thread_links.run_backfill already does (L324–331 then L338–343).

Verdict: LINK via thread_id.

Write-time fix: inline the Thread mint + anchor into log_packet, using cs.thread_entity_id(project, thread_id):

MERGE (p:CXPPacket {message_id: $message_id})
SET   ...
WITH p
MERGE (t:Thread {id: $thread_qid})
  ON CREATE SET t.thread_id = $thread_id, t.project = $project
MERGE (p)-[:IN_THREAD]->(t)
WITH t
MERGE (proj:Project {slug: $project})
MERGE (proj)-[:HAS_THREAD]->(t)

$project comes from a call-time os.environ.get("ORCHESTRATA_PROJECT_ID", "orchestrata-ultimate") helper — the identical idiom already at gateway/audit.py:43-45, memory/cold_store.py:49, factory/promotion/store.py:134 (RUL-003: read env at call time, never at import).

Writer: src/orchestrata_ultimate/knowledge_graph/runtime_writer.py, RuntimeSignalWriter.write_message (def L61), write L97–123:

MERGE (m:Message {message_id: $message_id})
SET m.id = $id, m.thread_id = $thread_id, m.sender = $sender, m.recipient = $recipient,
    m.subject = $subject, m.summary = $summary, m.timestamp = datetime($timestamp),
    m.project_id = $project_id
WITH m
UNWIND $mentioned_files AS path
MATCH (f:File {path: path})
MERGE (m)-[:MENTIONS]->(f)

project_id is written as a scalar property, never an edge; the MENTIONS edge is a no-op when mentioned_files is empty (UNWIND [] kills the row). Callers: kernel/pipeline_kg.py:732, reached from orchestrator/pipeline_runner.py:874,974 and kernel/pipeline_escalation.py:295,322.

Schema: present (Thread family). Evidence: all 153 unlinked carry thread_id; only 8 have an existing :Thread. Degree histogram {0: 153} — every orphan Message is fully isolated. Sample:

{"id":"msg:04562e85-7007-4f78-8a8f-06c5483b6340:in:44136fa355b3678a",
 "sender":"U:Cx","recipient":"U:PM","subject":"chain",
 "thread_id":"04562e85-7007-4f78-8a8f-06c5483b6340"}

Verdict: LINK via thread_id. Write-time fix: same Thread mint+anchor block as §4.2, plus turn the dead project_id scalar into the $project used by the hub MERGE.

Three writers. Only one is correct today:

  1. knowledge_graph/runtime_writer.py::write_tool_call (def L227, write L244–270) — MERGE (tc:ToolCall {id: $id}) SET ... tc.project_id = $project_id then conditional MENTIONS. No hub edge. Callers kernel/pipeline_kg.py:769 and agents/volks/base.py:460 (the latter does not even pass project_id). This is the source of the 74 orphans.
  2. knowledge_graph/conversation_consumer.py::TurnGraphMapper.to_statements (L376–384) — MERGE (tc:ToolCall {id:$tc_id}) ... MERGE (t:ConversationTurn)-[:TRIGGERED]->(tc), and the same method MERGEs (:Project{slug})-[:HAS_CONVERSATION]->(c)-[:HAS_TURN]->(t) at L320–343. Already hub-reachable — this is the in-repo template to copy.
  3. orchestrator/graph_audit.py::_write_event (L328) via _EVENT_LABELS — see §4.11.

Schema: present. Evidence: 74 unlinked, all carry thread_id, 0 have an existing :Thread. Degree histogram {0: 74}. Sample:

{"id":"call_019f9e3746fe71139ce981ed","tool_name":"list_dir","outcome":"error",
 "thread_id":"pipeline_4075156f","arguments_json":"{\"path\": \".\"}"}

These are real operational records of a real pipeline run, not test fixtures. Verdict: LINK via thread_id (repair MERGEs the Thread).

Writer: src/orchestrata_ultimate/gateway/audit.py, _CREATE_AUDIT_QUERY L56–69, executed by AuditWriter._write_event (def L176):

CREATE (e:GatewayAuditEvent {entry_id: $entry_id, event_type: $event_type,
        connection_id: $connection_id, timestamp: $timestamp, data: $data})
WITH e
MERGE (s:Session {session_id: $connection_id})
MERGE (e)-[:DURING]->(s)
MERGE (p:Project {slug: $project})
MERGE (p)-[:HAS_SESSION]->(s)

This writer is already correct — it mints the Session and the Project hub edge. No write-time fix is needed here.

Evidence: all 144 orphans have degree exactly 1, and their only neighbour is DURING -> :Session:

Q: (orphan GatewayAuditEvent) neighbour rel types
   [{"t":"DURING","other":["Session"],"c":144}]

They are orphaned solely because their :Session lacks HAS_SESSION — i.e. they predate the fix quoted above. Fixing Session (§4.6) makes all 144 reachable transitively. The repair script must therefore not add a direct GatewayAuditEvent→Project edge; that would be a redundant, non-canonical edge.

Distinct label, do not conflate: runtime_writer.write_gateway_event (L126, write L143–167) writes :GatewayEvent (canonical, canonical_schema.py:165), not :GatewayAuditEvent. It has the same missing-hub defect and is in scope for the messaging brief.

Ten writers. Five already anchor correctly and are the template:

Anchored writer Site
session/kg_sync/_stage_session.py::sync_session L108–116
knowledge_graph/kg_design.py::store_session L793–800
kernel/session_manager.py::archive_session L473–482
gateway/audit.py _CREATE_AUDIT_QUERY L56–69
memory/cold_store.py::ColdStore.store L172–195

Five mint a :Session with no path to :Project — these are the defect:

Unanchored writer Site Shape
gateway/handlers/session.py::handle_open L332–343 MERGE (s:Session {...}) SET ... — no relationship at all
session/graph_store.py::persist_session L97–100 MERGE (s:Session {session_id:$id}) SET ... — no edge
orchestrator/fact_graph.py::build_create_query L75–76 MERGE (s:Session {session_id:$sid}) MERGE (f)-[:ABOUT]->(s) — inbound only
orchestrator/graph_audit.py::_write_event L355–360 MERGE (s:Session {session_id:$sid}) MERGE (n)-[:IN_SESSION]->(s) — inbound only
knowledge_graph/conversation_consumer.py::to_statements L345–348 reaches Project only via :Conversation; $project is already in scope two statements earlier (L320)

kernel/audit/writers.py L331–355 is the counter-example worth reading: it was deliberately converted from MERGE to OPTIONAL MATCH (s:Session {...}) (L351) specifically to stop minting orphan Sessions. Its L306–325 comment block is the clearest in-repo statement of this rule.

Schema: REL_HAS_SESSION at L277 — present. Its own docstring names this exact defect ("gateway/audit.py, kernel/session_manager.py, and memory/cold_store.py each independently MERGE a :Session node with no Project hub edge at all").

Evidence — orphan Sessions are normally-shaped, not garbage:

Q: MATCH (s:Session) RETURN keys(s) AS k, count(*) AS c ORDER BY c DESC
   {"k":["session_id"], "c":1117}                                  <- the dominant shape
   {"k":["id","session_id","status","started_at","summary","goals","closed_at","portfolio_id"], "c":24}
   {"k":["session_id","project","seq"], "c":6}
Q: MATCH (p:Project)-[r]-(m) RETURN type(r) AS t, labels(m) AS other, count(*) AS c
   {"t":"HAS_SESSION","other":["Session"],"c":1098}                <- 1098 of 1152 anchored

Orphan sample (5 nodes) and their degree/neighbour profile:

{"session_id":"01d29088-abd2-4f8f-95f0-4cc3f9ff3032"}
{"session_id":"0d5bb77b-0c3f-4f3c-9a98-64c4498ee0f4"}
{"session_id":"132fa890-e09d-4b18-801d-a6edd0908c67"}
degree histogram: {3: 52, 5: 2}
neighbour rel types: [{"t":"DURING","other":["GatewayAuditEvent"],"c":144},
                      {"t":"ABOUT","other":["Fact"],"c":22}]

The orphans have the same bare {session_id} shape as the 1098 that ARE anchored — so this is a missing hub edge, not junk. They carry no test_id property (MATCH (n) WHERE n.test_id IS NOT NULL returns zero rows graph-wide).

Verdict: LINK — MERGE (:Project {slug:$project})-[:HAS_SESSION]->(s). This transitively rescues the 144 GatewayAuditEvents (§4.5).

Write-time fix: add the HAS_SESSION hub MERGE to all five unanchored writers, using the call-time _project_slug() idiom.

Writer: src/orchestrata_ultimate/kernel/pipeline_kg.py::sync_intent_to_kg (def L438), write L454–462:

MERGE (i:Intent {id: $intent_id})
SET i.text = $text, i.thread_id = $thread_id,
    i.created_at = coalesce(i.created_at, timestamp())
MERGE (r:PipelineRun {id: $run_id})
SET r.thread_id = $thread_id, r.started_at = coalesce(r.started_at, timestamp())
MERGE (i)-[:TRIGGERED]->(r)

The pair links to each other and to nothing else. Second PipelineRun writer: sync_artifacts_to_kg L400–412, which reaches :Volk only when row.alias <> '' (i.e. only when stage is a registered volk alias).

Schema: LABEL_INTENT L85, LABEL_PIPELINE_RUN L86, REL_IN_THREAD L248 — present. TRIGGERED is deliberately not re-declared (canonical_schema L302–304).

Evidence: 187 Intents and 187 PipelineRuns unlinked, all carry thread_id, only 2 have an existing :Thread. Degree histogram {1: 187} for both; the sole neighbour is each other. Sample:

{"id":"intent:02009b64-df29-4bfc-b058-3beb02daff5a","text":"",
 "thread_id":"02009b64-df29-4bfc-b058-3beb02daff5a","created_at":"1785070347350"}
{"id":"run:02009b64-df29-4bfc-b058-3beb02daff5a","thread_id":"02009b64-...","started_at":"1785070347350"}

Verdict: LINK both via thread_id → :Thread. Write-time fix: append the Thread mint + HAS_THREAD + IN_THREAD block to sync_intent_to_kg. resolve_project_id is already imported at pipeline_kg.py:36 and self._project_id is already computed (used at L358) — and L264–269 in the same file (sync_volks_to_kg) is the working hub-MERGE template.

Writers: kernel/pipeline_kg.py::sync_artifacts_to_kg L400–412 (edge is inside a FOREACH guarded on row.alias <> '', so a non-volk stage mints an edgeless Artifact) and knowledge_graph/architecture_writer.py::upsert_artifact L1055/L1077 (via _merge_node, no edge at all — the one Tier-J writer in that file that skips the hub, cf. seed_reference_data L1146–1170 which anchors).

Evidence: degree {0: 2}; both carry thread_id:

{"id":"pipeline_26aace4d:U:Qa:test_code:8864a166b0cf2785","type":"test_code",
 "stage":"U:Qa","thread_id":"pipeline_26aace4d","format":"text","file_path":""}
{"id":"pipeline_5a714ff1:U:Qa:test_code:c762861c1da0c2eb","thread_id":"pipeline_5a714ff1", ...}

82 IN_THREAD Artifact edges already exist, so the anchoring path is established. Verdict: LINK via thread_id.

These 183 nodes form 6–7 self-consistent islands: each is a complete Spec -[:HAS_STORY]-> UserStory -[:HAS_REQUIREMENT]-> Requirement tree whose :Project hub does not exist.

Writer: src/orchestrata_ultimate/knowledge_graph/kg_design.py::store_spec (def L250), write L275–283:

MERGE (s:Spec {id: $id})
SET s.spec_id = $spec_id, s.project_slug = $slug, s.title = $title,
    s.content = $content, s.updated_at = $ts
WITH s
MATCH (p:Project {slug: $slug})      <-- MATCH, not MERGE: fails OPEN
MERGE (p)-[:HAS_SPEC]->(s)

That MATCH is the bug. When no :Project exists for the slug the match yields no row, the MERGE never runs, and the Spec is minted unanchored — while store_user_stories (L330–340) and store_requirements (L383–391) happily hang a whole tree off it. Every other hub writer in the repo MERGEs the Project (cold_store.py:193, session_manager.py:481, gateway/audit.py:68).

Evidence — the hub is missing for every generated slug:

Q: MATCH (p:Project) RETURN p.slug AS slug   -> exactly ONE row: {"slug":"orchestrata-ultimate"}
Q: MATCH (s:Spec) OPTIONAL MATCH (p:Project {slug: s.project_slug}) RETURN s.project_slug AS slug, count(p) AS proj
   {"slug":"reusable--thread-safe--generic-lru-cache-with-ttl","proj":0}
   {"slug":null,"proj":0}
   {"slug":"implement-as-specified","proj":0}
   {"slug":"build-stdlib-only-python-3-14-free-threaded-in-mem","proj":0}
   {"slug":"implement-a-thread-safe-generic-lru-cache-with-fix","proj":0}
   {"slug":"build-a-thread-safe-generic-lru-cache-module-for-p","proj":0}
   {"slug":"build-a-thread-safe--free-threaded-safe-generic-lr","proj":0}
Q: MATCH (r:Requirement) RETURN split(r.id,":")[0] AS root, count(*) AS c ORDER BY c DESC
   {"root":"orchestrata-ultimate","c":559}                          <- reachable
   {"root":"build-stdlib-only-python-3-14-free-threaded-in-mem","c":38}
   {"root":"build-a-thread-safe--free-threaded-safe-generic-lr","c":24}
   {"root":"reusable--thread-safe--generic-lru-cache-with-ttl","c":23}
   {"root":"implement-a-thread-safe-generic-lru-cache-with-fix","c":23}
   {"root":"implement-as-specified","c":16}
   {"root":"build-a-thread-safe-generic-lru-cache-module-for-p","c":14}
                                                     sum = 138  <- exactly the orphan count

Spec orphan sample:

{"id":"build-a-thread-safe--free-threaded-safe-generic-lr:001","spec_id":"001",
 "project_slug":"build-a-thread-safe--free-threaded-safe-generic-lr",
 "title":"Thread-safe, free-threaded-safe generic LRUCache[T] module","content":""}

These are genuine dogfood pipeline output (real user stories with acceptance criteria), not test fixtures. The multi-project invariant (plans/kg-project-scoped-identity.md, "the KG must hold 100+ projects") says a generated project is entitled to its own hub.

Verdict: LINK — MERGE one :Project {slug: s.project_slug} per distinct slug and MERGE (p)-[:HAS_SPEC]->(s). UserStory and Requirement then become reachable transitively; no direct edge is added for them. See DECISION D1 for the slug: null / "implement-as-specified" edge cases.

Write-time fix: MATCH (p:Project {slug: $slug}) → MERGE (p:Project {slug: $slug}) in store_spec. Keep the existing _require_project_slug guard (L272) so an empty slug still fails closed.

Also fix, same brief: knowledge_graph/sync.py::_sync_requirements (L809, write L839–843) MERGEs :Requirement with no relationship at all. Its 559 orchestrata-ultimate:-rooted nodes are reachable today only by luck (incoming TRACES_TO edges from other writers). It should MERGE (:Project {slug:$project})-[:HAS_SPEC]-> … or, more precisely, anchor via the spec it belongs to. Not counted in the 8447; a latent orphan source.

4.11 Requirement husk (1) — DELETE

Q: MATCH (r:Requirement) WHERE size(keys(r)) <= 1 RETURN r.id AS id, keys(r) AS k
   {"id":"REQ-008-023","k":["id"]}
Q: MATCH (r:Requirement) WHERE r.id IN ["REQ-008-023","orchestrata-ultimate:REQ-008-023"]
   RETURN r.id AS id, size(keys(r)) AS nkeys, size([(r)--() | 1]) AS deg
   {"id":"REQ-008-023","nkeys":1,"deg":0}

A single-property, zero-degree husk. Writer: knowledge_graph/runtime_writer.py::write_decision (def L170), write L219–223:

MATCH (d:Decision {id: $id})
MERGE (r:Requirement {id: $req_id})
MERGE (d)-[:JUSTIFIED_BY]->(r)

$req_id is the raw trace tag, not project-qualified — so it mints a duplicate identity of the canonical {project}:{req} scheme (sync.py::_sync_requirements L826). The leading MATCH (d:Decision …) failed, leaving the bare husk behind. Same defect in kernel/pipeline_kg.py::sync_decisions_to_kg L544–554.

Verdict: DELETE. It carries no information (one property, no edges) and the real requirement lives on disk in specs/ and in the graph as one of 5388 :RequirementItem nodes. Linking a contentless husk would be linking garbage, which the standing ruling forbids. Write-time fix: project-qualify $req_id in both writers via canonical_schema.project_qualified_id(project, req) (L792).

4.12 Fact — 22 — DELETE

Writers: orchestrator/fact_graph.py::build_create_query (L41) — the no-session branch L86–94 is a bare CREATE (f:Fact {...}) with zero edges; and session/graph_store.py::persist_fact L131–136 / persist_facts_batch L171–178 which MATCH (not MERGE) the Session, so a missing Session silently drops the edge.

Evidence — the entire label is test residue:

Q: MATCH (f:Fact) RETURN f.source AS src, count(*) AS c
   {"src":"test","c":88}                    <- 88 of 88, no other source value exists

Orphan sample:

{"id":"fact-15d474ef1982","fact_type":"decision","data":"{\"i\": 4}","source":"test",
 "created_at":"2026-07-26T10:44:00.055447"}
{"id":"fact-1dfbdfd549df","fact_type":"decision","data":"{\"i\": 2}","source":"test", ...}

data values of {"i": 2} / {"i": 3} / {"i": 4} are loop-counter fixtures. Verdict: DELETE the 22 orphans (source = 'test', keyed to the orphan set). See DECISION D2 for the 66 reachable siblings.

4.13 Action — 8 — DELETE

Writer: session/kg_sync/_stage_session.py::log_action (def L295), write L323–331 — CREATE (a:Action {...}) WITH a MATCH (s:Session {...}) MERGE (s)-[:LOGGED_ACTION]->(a). The CREATE runs before the MATCH, so a missing Session leaves an orphan.

Critically: log_action has no production caller. rg 'log_action' src/ scripts/ returns only the definition and its docstring mention; every invocation is in tests.

Evidence — all 8 nodes, complete:

Q: MATCH (a:Action) RETURN a.agent AS agent, a.description AS d, a.type AS t
   {"agent":"pytest","d":"edited module.py","t":"file_edit"}   x4
   {"agent":"","d":"picked option A","t":"decision"}           x4

Verdict: DELETE all 8 — integration-test residue with no production origin.

4.14 Timeout — 4 — DELETE (writer fix still required)

Writer: orchestrator/graph_audit.py::_write_event (def L328) via the dynamic _EVENT_LABELS table (L316 "timeout": "Timeout"). The emitter log_timeout (L407) enqueues {"escalation_id": ...} with no session_id, so session_id = data.get("session_id","") (L346) is always empty and the no-edge branch fires unconditionally:

CREATE (n:Timeout {id: $id, data: $data, timestamp: $ts})

Every :Timeout node ever written by this path is a guaranteed zero-degree orphan.

Evidence — all 4 nodes:

Q: MATCH (t:Timeout) RETURN t.data AS data
   {"data":"{\"escalation_id\": \"_TEST_7d71145ea674_esc\"}"}
   {"data":"{\"escalation_id\": \"_TEST_930d835ba1e1_esc\"}"}
   {"data":"{\"escalation_id\": \"_TEST_1b5fdde4bf12_esc\"}"}
   {"data":"{\"escalation_id\": \"_TEST_58a4c15399d1_esc\"}"}

All four carry the _TEST_ escalation-id marker. Verdict: DELETE the 4 as test residue — but the production path is real (kernel/pam/escalation.py::timeout_stale L294–296), so the writer MUST still be fixed or it will re-mint orphans.

4.15 Resolution — 3 — DELETE (writer fix still required)

Two writers, both orphan-minting:

  1. governance/escalation_handler/resolver.py::apply_resolution_strategy (def L224), write L266–276 — a bare CREATE (r:Resolution {...}) with no WITH, no edge. workflow_id/conflict_id are string properties.
  2. orchestrator/graph_audit.py::_write_event — same no-session branch as Timeout; log_resolution (L396) passes no session_id.

Note an identity conflict worth recording: canonical_schema.py:516 declares LABEL_RESOLUTION's merge key as resolution_id, but writer (2) keys on a fresh uuid4() id. Flagged, not fixed here.

Evidence — all 3 nodes:

Q: MATCH (r:Resolution) RETURN r.conflict_id AS cid, r.resolved_by AS by, count(*) AS c
   {"cid":"c-resolve-neo4j","by":"system","c":3}

All three share the fixture conflict id c-resolve-neo4j and the identical resolution_text "Vote resolution: v1 granted authority over comp_a". Verdict: DELETE the 3. Writer fix still required (real production path via kernel/pam/escalation.py::resolve L263–264).

One-site leverage: orchestrator/graph_audit.py L352–366 is the single write site for 12 labels (Timeout, Resolution, Escalation, Violation, ToolCall, Event, PolicyDecision, SessionStart, SessionEnd, Error, ComplianceCheck, VolkLifecycle). Fixing that one else branch closes the largest single orphan tap in the codebase. It is the highest-value edit in either brief.

5. Sequencing (mandatory order)

  1. Commit 1 — schema only. Add REL_HAS_PARAMETER, REL_HAS_SPEC, REL_HAS_STORY, REL_HAS_REQUIREMENT to canonical_schema.py with the session-254 rationale comment, plus schema tests. RUL-016: nothing may link against these before this lands. Owner: BUILDER. Nothing else in this commit.
  2. Commit 2..n — writers. BUILDER and KIMI work in parallel on disjoint file sets (§6). Each writer fix ships with a behavioural test that asserts the new node is reachable from (:Project) — not merely that the edge exists.
  3. Commit N+1 — repair script. scripts/repair_orphan_hub_links.py (BUILDER). Dry-run default, --apply to write.
  4. Repair run — orchestrator only. The agents never run --apply against the live graph. The orchestrator runs it once, then re-runs python scripts/check_kg_connectivity.py and expects UNREACHABLE 0.

Rationale for ordering: writers before repair. If the repair runs first, the still-broken writers immediately re-mint orphans and the verification is meaningless.

Repair-internal ordering: the script computes the orphan set once, up front, then applies DELETE verdicts, then LINK verdicts. This removes an order-dependence trap — e.g. the 22 test :Fact nodes stop being orphans the moment their :Session gets its hub edge, so a naive "recompute per label" implementation would silently skip them.

6. File ownership (collision-free split)

Both agents work in the same worktree, so ownership is exclusive. Touching a file owned by the other agent is a stop-and-report condition.

KIMI — KIMI-BRIEF.md — cxp / gateway / messaging:

Kimi needs no schema addition — every constant its fixes require (REL_IN_THREAD, REL_HAS_THREAD, REL_HAS_SESSION, LABEL_THREAD, thread_entity_id) already exists. Kimi must not edit canonical_schema.py.

BUILDER — BUILDER-BRIEF.md — code-intelligence / orchestration / repair:

All repair scripts are BUILDER-owned, including the thread backfill that covers Kimi's labels. Kimi ships write-time fixes only.

7. DECISIONS for Mohamed

D1 — degenerate generated-project slugs (§4.10). Minting :Project hubs for the six real dogfood slugs is straightforward. Two are degenerate:

Options: (a) mint hubs for all seven, junk slugs included; (b) mint for the six real ones and DELETE the two degenerate trees; (c) fold both into orchestrata-ultimate. Recommendation: (b) — a null slug cannot be a project identity, and "implement-as-specified" is the same class of writer bug that repair_project_hubs.py was written to clean up (it already deletes a slug='' hub and a truncated intent-slug hub). Needs your call before the repair runs; the plan currently assumes (b).

D2 — :Fact is 100% test residue (§4.12). All 88 :Fact nodes have source='test'; only 22 are orphaned. This plan deletes those 22. The other 66 are reachable (hanging off anchored Sessions) and therefore invisible to the connectivity gate — but they are the same garbage. Options: delete all 88 here, or leave 66 for a separate sweep_test_pollution.py pass. Recommendation: delete the 22 in this wave (scope discipline — this is a connectivity repair) and log the 66 as a sweeper backlog item. Say the word and I will widen it to all 88.

D3 — :Resolution merge-key conflict (§4.15). canonical_schema.py:516 declares the key resolution_id; graph_audit.py keys the same label on a fresh uuid4() id, and escalation_handler/resolver.py on resolution_id. Two writers, two identities, one label. Out of scope for hub-linking, but it is active ontology drift. Recommendation: file it as a follow-up, do not widen this wave.

8. Verification

cd /home/mohamed/workspace/wt-HUB && export PYTHONPATH=$(pwd)/src

# gate — must print UNREACHABLE 0 after the repair run
python scripts/check_kg_connectivity.py
python scripts/check_kg_connectivity.py --json

# ontology conformance
python scripts/kg_schema.py check

Success criteria: check_kg_connectivity.py exits 0, and a second run 24h later still exits 0 (proving the writer fixes, not just the repair, are holding).

9. Evidence provenance

Every figure in this document was read from the live orchkg database on 2026-07-27 with read-only Cypher (MATCH/RETURN only; no write was issued at any point). The BFS reachability set was recomputed with the same algorithm scripts/check_kg_connectivity.py uses (undirected traversal from all :Project seeds, batched id(n) IN $ids adjacency queries) so the orphan set here is identical to the one the gate reports.