diff --git a/docs/graph-explorer-contract.md b/docs/graph-explorer-contract.md
index 960bee5..d6057a6 100644
--- a/docs/graph-explorer-contract.md
+++ b/docs/graph-explorer-contract.md
@@ -182,6 +182,12 @@ Useful warnings for the graph explorer include:
- local-only surfaces that appear in shared or production scenarios;
- conflicting port or host claims within the same deployment scenario.
+Zone resolvers should also expose scoped diagnostics in zone detail panels. The
+initial diagnostic set includes empty zone seed sets, visible nodes matched by
+multiple zone definitions, and edges crossing zone boundaries. Attraction
+diagnostics such as multiple attraction candidates or depth-limit stops belong
+to the same resolver diagnostic channel when attraction rules are enabled.
+
## Repo-Scoping Compatibility
Repo-scoping can adapt without a rewrite because its current graph payload
diff --git a/railiance_fabric/graph_explorer_ui.py b/railiance_fabric/graph_explorer_ui.py
index e032c22..af651f4 100644
--- a/railiance_fabric/graph_explorer_ui.py
+++ b/railiance_fabric/graph_explorer_ui.py
@@ -1111,6 +1111,14 @@ def graph_explorer_page() -> str:
};
};
+ const zoneDiagnosticCodes = {
+ emptySeedSet: "ZONE_EMPTY_SEED_SET",
+ nodeSeededByMultipleZones: "ZONE_NODE_SEEDED_BY_MULTIPLE_ZONES",
+ nodeAttractedByMultipleZones: "ZONE_NODE_ATTRACTED_BY_MULTIPLE_ZONES",
+ attractionDepthLimitReached: "ZONE_ATTRACTION_DEPTH_LIMIT_REACHED",
+ edgeCrossesZoneBoundary: "ZONE_EDGE_CROSSES_ZONE_BOUNDARY",
+ };
+
const zoneDefinitionsForData = (data, grouping = activeZoneGrouping) => {
if (grouping === "deploymentEnvironment") return defaultZoneDefinitions.deploymentEnvironment;
if (grouping === "accessZone") {
@@ -1176,7 +1184,7 @@ def graph_explorer_page() -> str:
if (candidates.length > 1) {
const diagnostic = {
severity: "warning",
- code: "ZONE_NODE_SEEDED_BY_MULTIPLE_ZONES",
+ code: zoneDiagnosticCodes.nodeSeededByMultipleZones,
message: `${elementLabel(node)} matched multiple zone definitions.`,
};
candidates.forEach((candidate) => zones.get(candidate.definition.id)?.diagnostics.push(diagnostic));
@@ -1189,6 +1197,15 @@ def graph_explorer_page() -> str:
zone.nodes.push(node);
addElementToZone(chosen.id, node);
});
+ zones.forEach((zone) => {
+ if (!zone.nodes.length) {
+ zone.diagnostics.push({
+ severity: "warning",
+ code: zoneDiagnosticCodes.emptySeedSet,
+ message: `${zone.label} has no visible seed nodes.`,
+ });
+ }
+ });
elements.filter((element) => element.isEdge && element.isEdge()).forEach((edge) => {
const data = zoneElementData(edge);
const zoneIds = new Set(
@@ -1198,6 +1215,16 @@ def graph_explorer_page() -> str:
);
const sourceZoneId = assignments.get(edge.data("source"));
const targetZoneId = assignments.get(edge.data("target"));
+ if ((sourceZoneId || targetZoneId) && sourceZoneId !== targetZoneId) {
+ const diagnostic = {
+ severity: "info",
+ code: zoneDiagnosticCodes.edgeCrossesZoneBoundary,
+ message: `${elementLabel(edge)} crosses a zone boundary.`,
+ };
+ [sourceZoneId, targetZoneId]
+ .filter(Boolean)
+ .forEach((zoneId) => zones.get(zoneId)?.diagnostics.push(diagnostic));
+ }
if (sourceZoneId) zoneIds.add(sourceZoneId);
if (targetZoneId) zoneIds.add(targetZoneId);
zoneIds.forEach((zoneId) => addElementToZone(zoneId, edge));
@@ -1321,6 +1348,8 @@ def graph_explorer_page() -> str:
.flatMap((element) => zoneWarningsForData(element.data()).map((warning) =>
`${elementLabel(element)}: ${warning}`
));
+ const diagnostics = (zone.diagnostics || [])
+ .map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`);
const rows = [
["visible nodes", String(visibleNodes.length)],
["deployment environments", valuesFor("deploymentEnvironment")],
@@ -1328,12 +1357,14 @@ def graph_explorer_page() -> str:
["access zones", valuesFor("accessZone")],
["routing authorities", valuesFor("routingAuthority")],
["policy authorities", valuesFor("policyAuthority")],
+ ...diagnostics.slice(0, 8).map((diagnostic) => ["diagnostic", diagnostic]),
...warnings.slice(0, 8).map((warning) => ["warning", warning]),
];
+ if (diagnostics.length > 8) rows.push(["diagnostic", `${diagnostics.length - 8} additional zone diagnostics`]);
if (warnings.length > 8) rows.push(["warning", `${warnings.length - 8} additional route warnings`]);
detailList.innerHTML = rows
.filter(([, value]) => value)
- .map(([key, value]) => `
${escapeHtml(key)} ${escapeHtml(value)}`)
+ .map(([key, value]) => `${escapeHtml(key)} ${escapeHtml(value)}`)
.join("");
const contextIds = new Set(visibleNodes.map((node) => node.id()));
@@ -1346,6 +1377,7 @@ def graph_explorer_page() -> str:
orientationList.innerHTML = [
{label: "grouping", value: ruleAttributeLabels[zone.field] || humanize(zone.field), state: "good"},
{label: "zone", value: zone.label},
+ {label: "diagnostics", value: diagnostics.length ? String(diagnostics.length) : "none", state: diagnostics.length ? "warning" : "good"},
{label: "warnings", value: warnings.length ? String(warnings.length) : "none", state: warnings.length ? "warning" : "good"},
].map((row) => `
diff --git a/railiance_fabric/zone_view.py b/railiance_fabric/zone_view.py
index ff71781..a1fec95 100644
--- a/railiance_fabric/zone_view.py
+++ b/railiance_fabric/zone_view.py
@@ -332,6 +332,7 @@ def resolve_zones(
edge_records,
enabled_definitions,
seed_node_ids_by_zone_id,
+ diagnostics,
)
for candidate in attraction_candidates:
candidates_by_node_id[candidate.node_id].append(candidate)
@@ -406,6 +407,16 @@ def resolve_zones(
internal_edge_ids_by_zone_id[source_zone_id].append(edge.id)
continue
if source_zone_id or target_zone_id:
+ zone_ids = tuple(sorted(str(zone_id) for zone_id in {source_zone_id, target_zone_id} - {None}))
+ diagnostics.append(
+ ZoneDiagnostic(
+ severity="INFO",
+ code="ZONE_EDGE_CROSSES_ZONE_BOUNDARY",
+ message=f"Edge {edge.id} crosses a zone boundary.",
+ edge_id=edge.id,
+ zone_ids=zone_ids,
+ )
+ )
boundary_edges.append(
ZoneBoundaryEdge(
edge_id=edge.id,
@@ -468,6 +479,7 @@ def _attraction_candidates(
edge_records: tuple[_EdgeRecord, ...],
enabled_definitions: tuple[tuple[int, ZoneDefinition], ...],
seed_node_ids_by_zone_id: Mapping[str, set[str]],
+ diagnostics: list[ZoneDiagnostic],
) -> list[_Candidate]:
nodes_by_id = {node.id: node for node in node_records}
adjacency: dict[str, list[_EdgeRecord]] = defaultdict(list)
@@ -476,6 +488,7 @@ def _attraction_candidates(
adjacency[edge.target].append(edge)
candidates: dict[tuple[str, str], _Candidate] = {}
+ depth_limit_diagnostics: set[tuple[str, str]] = set()
for definition_order, definition in enabled_definitions:
seed_node_ids = seed_node_ids_by_zone_id.get(definition.id, set())
if not seed_node_ids:
@@ -486,6 +499,27 @@ def _attraction_candidates(
while queue:
node_id, depth = queue.popleft()
if depth >= rule.depth:
+ if _has_matching_attraction_neighbor(
+ node_id,
+ adjacency.get(node_id, []),
+ nodes_by_id,
+ rule,
+ ):
+ key = (definition.id, node_id)
+ if key not in depth_limit_diagnostics:
+ depth_limit_diagnostics.add(key)
+ diagnostics.append(
+ ZoneDiagnostic(
+ severity="INFO",
+ code="ZONE_ATTRACTION_DEPTH_LIMIT_REACHED",
+ message=(
+ f"Zone {definition.id} reached attraction depth "
+ f"{rule.depth} at node {node_id}."
+ ),
+ node_id=node_id,
+ zone_ids=(definition.id,),
+ )
+ )
continue
for edge in adjacency.get(node_id, []):
if not _edge_matches_attraction_rule(edge, rule):
@@ -521,6 +555,24 @@ def _attraction_candidates(
return sorted(candidates.values(), key=lambda candidate: (candidate.node_id, candidate.zone_id))
+def _has_matching_attraction_neighbor(
+ node_id: str,
+ edges: Iterable[_EdgeRecord],
+ nodes_by_id: Mapping[str, _NodeRecord],
+ rule: ZoneAttractionRule,
+) -> bool:
+ for edge in edges:
+ if not _edge_matches_attraction_rule(edge, rule):
+ continue
+ neighbor_id = _neighbor_for_direction(node_id, edge, rule.direction)
+ if not neighbor_id:
+ continue
+ neighbor = nodes_by_id.get(neighbor_id)
+ if neighbor and _rule_matches(neighbor.data, rule.node_filter, empty_matches=True):
+ return True
+ return False
+
+
def _node_records(nodes: Iterable[Mapping[str, Any]]) -> tuple[_NodeRecord, ...]:
records: list[_NodeRecord] = []
for order, element in enumerate(nodes):
diff --git a/tests/test_graph_explorer.py b/tests/test_graph_explorer.py
index 71a68a1..fcd521d 100644
--- a/tests/test_graph_explorer.py
+++ b/tests/test_graph_explorer.py
@@ -417,6 +417,11 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
assert "zoneForData" in page
assert "defaultZoneDefinitions" in page
assert "resolveZoneInstances" in page
+ assert "zoneDiagnosticCodes" in page
+ assert "ZONE_EMPTY_SEED_SET" in page
+ assert "ZONE_NODE_SEEDED_BY_MULTIPLE_ZONES" in page
+ assert "ZONE_EDGE_CROSSES_ZONE_BOUNDARY" in page
+ assert "zone.diagnostics" in page
assert 'normalize: "deploymentEnvironment"' in page
assert "zoneBoundsForNodes" in page
assert "renderZoneOverlay" in page
diff --git a/tests/test_zone_view.py b/tests/test_zone_view.py
index e659147..bdda5eb 100644
--- a/tests/test_zone_view.py
+++ b/tests/test_zone_view.py
@@ -91,6 +91,9 @@ def test_resolver_assigns_seed_nodes_and_boundary_edges() -> None:
assert resolution.boundary_edges[0].edge_id == "edge.prod-test"
assert resolution.boundary_edges[0].source_zone_id == "prod"
assert resolution.boundary_edges[0].target_zone_id == "test"
+ assert "ZONE_EDGE_CROSSES_ZONE_BOUNDARY" in {
+ diagnostic.code for diagnostic in resolution.diagnostics
+ }
def test_resolver_attracts_nodes_by_edge_type_direction_and_depth() -> None:
@@ -128,6 +131,9 @@ def test_resolver_attracts_nodes_by_edge_type_direction_and_depth() -> None:
assert "far" not in resolution.node_assignments
assert resolution.zone_by_id("prod").internal_edge_ids == ("edge.seed-near",)
assert resolution.zone_by_id("prod").boundary_edge_ids == ("edge.near-far",)
+ assert "ZONE_ATTRACTION_DEPTH_LIMIT_REACHED" in {
+ diagnostic.code for diagnostic in resolution.diagnostics
+ }
def test_resolver_keeps_seed_membership_over_attraction() -> None:
@@ -229,3 +235,19 @@ def test_resolver_serializes_resolution() -> None:
assert serialized["zones"][0]["id"] == "prod"
assert serialized["node_assignments"]["svc"]["zone_id"] == "prod"
assert serialized["node_assignments"]["svc"]["reason"] == "seed"
+
+
+def test_resolver_reports_empty_zone_seed_set() -> None:
+ resolution = resolve_zones(
+ nodes=[_node("svc", deploymentEnvironment="dev")],
+ edges=[],
+ zone_definitions=[
+ {
+ "id": "prod",
+ "membership": {"field": "deploymentEnvironment", "op": "equals", "value": "prod"},
+ }
+ ],
+ )
+
+ assert resolution.zone_by_id("prod").node_ids == ()
+ assert "ZONE_EMPTY_SEED_SET" in {diagnostic.code for diagnostic in resolution.diagnostics}
diff --git a/workplans/RAIL-FAB-WP-0022-zone-entity-visualization-engine.md b/workplans/RAIL-FAB-WP-0022-zone-entity-visualization-engine.md
index 9c7f2b7..c131404 100644
--- a/workplans/RAIL-FAB-WP-0022-zone-entity-visualization-engine.md
+++ b/workplans/RAIL-FAB-WP-0022-zone-entity-visualization-engine.md
@@ -127,7 +127,7 @@ preserving edge evidence in zone details.
```task
id: RAIL-FAB-WP-0022-T04
-status: todo
+status: done
priority: medium
state_hub_task_id: "d140cb5b-6a35-4cb0-ab68-e39e708c08e9"
```
@@ -146,6 +146,12 @@ Diagnostics should include at least:
Expected result: zone detail panels show scoped diagnostics, and tests verify
that diagnostics are generated by the resolver rather than ad hoc UI checks.
+Result: Added resolver diagnostics for empty seed sets, overlapping zone
+membership, attraction depth-limit stops, and boundary-crossing edges. The graph
+explorer now surfaces scoped zone diagnostics in the selected zone detail panel
+and orientation context, with assertions proving diagnostics come from the zone
+resolver path.
+
## Task 5: Persist Zone View Settings In Profiles
```task