feat(graph): namespace topic-tunnel rooms with "topic:" prefix + kind field

Previously a cross-wing topic tunnel for "Angular" stored the room as
"Angular" — colliding with a wing's literal folder-derived "Angular" room
at follow_tunnels/list_tunnels read time, and exposing raw topic strings
(which may contain characters rejected by sanitize_name) to the MCP
surface.

Topic tunnels now store their room as "topic:<original-casing>" and carry
kind="topic" on the stored dict. Explicit tunnels get kind="explicit"
(default). follow_tunnels("wing", "Angular") on a literal Angular room
no longer surfaces topic connections for the same name, and any LLM
scanning list_tunnels has a visible discriminator.
This commit is contained in:
Igor Lins e Silva
2026-04-24 23:05:56 -03:00
parent fe051adc73
commit 865a36bc5c
4 changed files with 79 additions and 10 deletions
+4 -1
View File
@@ -536,7 +536,10 @@ def test_mine_creates_topic_tunnels_for_shared_topics(tmp_path, monkeypatch):
listed = palace_graph.list_tunnels()
assert len(listed) == 1
rooms = {listed[0]["source"]["room"], listed[0]["target"]["room"]}
assert rooms == {"foo"}
# Topic tunnels use a ``topic:<name>`` synthetic room so they can't
# collide with literal folder-derived rooms of the same name.
assert rooms == {"topic:foo"}
assert listed[0]["kind"] == "topic"
wings = {listed[0]["source"]["wing"], listed[0]["target"]["wing"]}
assert wings == {"wing_one", "wing_two"}
+44 -3
View File
@@ -156,9 +156,15 @@ class TestTopicTunnels:
assert len(created) == 1
assert created[0]["source"]["wing"] in {"wing_alpha", "wing_beta"}
assert created[0]["target"]["wing"] in {"wing_alpha", "wing_beta"}
# Room is the topic itself (case preserved from the first wing).
assert created[0]["source"]["room"] == "OpenAPI"
# Room is namespaced with the ``topic:`` prefix so it can't collide
# with a literal folder-derived room of the same name. Casing of the
# topic is preserved for display.
assert created[0]["source"]["room"] == "topic:OpenAPI"
assert created[0]["target"]["room"] == "topic:OpenAPI"
assert created[0]["kind"] == "topic"
# Label carries the human-readable topic without the prefix.
assert "OpenAPI" in created[0]["label"]
assert "topic:OpenAPI" not in created[0]["label"]
# Tunnel is retrievable via the standard list_tunnels API.
listed = palace_graph.list_tunnels()
@@ -187,7 +193,7 @@ class TestTopicTunnels:
created = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=2)
# Two shared topics × one wing pair = two tunnels.
rooms = sorted(t["source"]["room"] for t in created)
assert rooms == ["Angular", "OpenAPI"]
assert rooms == ["topic:Angular", "topic:OpenAPI"]
def test_compute_topic_tunnels_case_insensitive_overlap(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
@@ -258,3 +264,38 @@ class TestTopicTunnels:
# not multiply the stored tunnels.
assert first[0]["id"] == second[0]["id"]
assert len(palace_graph.list_tunnels()) == 1
def test_topic_tunnel_room_does_not_collide_with_literal_room(self, tmp_path, monkeypatch):
"""Regression: a literal "Angular" folder-room and a topic tunnel
for "Angular" must resolve to distinct endpoints so ``follow_tunnels``
from the real room doesn't accidentally surface topic connections
(issue raised in review of #1184)."""
_use_tmp_tunnel_file(monkeypatch, tmp_path)
# Explicit tunnel anchored at a literal "Angular" room in wing_alpha.
palace_graph.create_tunnel(
"wing_alpha", "Angular", "wing_gamma", "frontend", label="explicit"
)
# Topic tunnel between the same wings that share the "Angular" topic.
palace_graph.compute_topic_tunnels(
{"wing_alpha": ["Angular"], "wing_beta": ["Angular"]}, min_count=1
)
# follow_tunnels on the literal Angular room only sees the explicit link.
literal = palace_graph.follow_tunnels("wing_alpha", "Angular")
assert len(literal) == 1
assert literal[0]["connected_wing"] == "wing_gamma"
# The topic tunnel is stored under the namespaced room.
topical = palace_graph.follow_tunnels("wing_alpha", "topic:Angular")
assert len(topical) == 1
assert topical[0]["connected_wing"] == "wing_beta"
def test_topic_tunnels_carry_kind_field(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
palace_graph.create_tunnel("wing_a", "auth", "wing_b", "users", label="x")
palace_graph.compute_topic_tunnels({"wing_a": ["Redis"], "wing_b": ["Redis"]}, min_count=1)
tunnels = palace_graph.list_tunnels()
kinds = sorted(t["kind"] for t in tunnels)
assert kinds == ["explicit", "topic"]