You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/09/29 04:37:23 UTC

(skywalking-horizon-ui) branch main updated: fix: check every service a relation's destination can be, and alarm, topology and layer-page follow-ups to #179 (#180)

This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git


The following commit(s) were added to refs/heads/main by this push:
     new e0585aed fix: check every service a relation's destination can be, and alarm, topology and layer-page follow-ups to #179 (#180)
e0585aed is described below

commit e0585aedc11ef0c21884a0126964924056bd7475
Author: 吴晟 Wu Sheng <wu...@foxmail.com>
AuthorDate: Tue Sep 29 12:37:17 2026 +0800

    fix: check every service a relation's destination can be, and alarm, topology and layer-page follow-ups to #179 (#180)
---
 apps/bff/src/ai/lib/tools/telemetry/tools.ts       |   2 +-
 apps/bff/src/ai/lib/tools/visualization/tools.ts   |  15 +-
 apps/bff/src/http/query/alarms-filters.test.ts     |  60 ++++++-
 apps/bff/src/http/query/alarms.ts                  |  36 ++--
 apps/bff/src/http/query/endpoint-dependency.ts     |   1 +
 apps/bff/src/http/query/instance-topology.ts       |   1 +
 apps/bff/src/http/query/services-access.test.ts    | 106 +++++++++++
 apps/bff/src/http/query/services.ts                |   9 +
 apps/bff/src/http/query/topology.ts                |   5 +-
 apps/bff/src/logic/alarms/owners.ts                |  18 +-
 apps/bff/src/logic/alarms/readable.test.ts         |  94 ++++++++--
 apps/bff/src/logic/alarms/readable.ts              |  59 ++++--
 apps/bff/src/logic/oap/endpoint-dependency.ts      |  23 ++-
 .../src/logic/oap/graph-metrics-blocked.test.ts    | 197 +++++++++++++++++++++
 apps/bff/src/logic/oap/instance-topology.ts        |  10 +-
 apps/bff/src/logic/oap/service-topology.ts         |  19 +-
 apps/bff/src/logic/services/service-identity.ts    | 114 +++++++++---
 apps/bff/src/rbac/request-access.test.ts           |  39 +++-
 apps/bff/src/rbac/request-access.ts                |  35 ++++
 apps/ui/src/api/permissionDenied.test.ts           |   2 +
 apps/ui/src/api/permissionDenied.ts                |  16 +-
 apps/ui/src/api/scopes/layer.test.ts               |   6 +-
 apps/ui/src/api/scopes/layer.ts                    |   4 +-
 .../admin/alert-page/AlertPageSetupView.test.ts    |  56 +++++-
 .../admin/alert-page/AlertPageSetupView.vue        |   4 +-
 .../admin/alert-page/useAlertPageEditor.ts         |  59 +++---
 apps/ui/src/features/alarms/AlarmsView.vue         |   9 +-
 .../ui/src/features/alarms/useAlarmFilters.test.ts |  16 +-
 apps/ui/src/features/alarms/useAlarmFilters.ts     |  12 +-
 apps/ui/src/features/infra-3d/Infra3DScene.vue     |   4 +-
 .../infra-3d/composables/useInfra3dAlarms.test.ts  |  27 +++
 .../infra-3d/composables/useInfra3dAlarms.ts       |   7 +
 apps/ui/src/i18n/locales/de.json                   |  12 +-
 apps/ui/src/i18n/locales/en.json                   |  12 +-
 apps/ui/src/i18n/locales/es.json                   |  12 +-
 apps/ui/src/i18n/locales/fr.json                   |  12 +-
 apps/ui/src/i18n/locales/ja.json                   |  12 +-
 apps/ui/src/i18n/locales/ko.json                   |  12 +-
 apps/ui/src/i18n/locales/pt.json                   |  12 +-
 apps/ui/src/i18n/locales/zh-CN.json                |  12 +-
 apps/ui/src/layer/LayerShell.vue                   |  16 ++
 .../LayerEndpointDependencyView.vue                |  28 ++-
 .../useLayerEndpointDependency.ts                  |   3 +-
 apps/ui/src/layer/graphQuery.test.ts               |  17 ++
 apps/ui/src/layer/graphQuery.ts                    |   4 +
 .../src/layer/service-map/LayerDeploymentView.vue  |   3 +-
 .../service-map/LayerInstanceTopologyView.vue      |  23 +--
 .../src/layer/service-map/LayerServiceMapView.vue  |  37 +++-
 .../layer/service-map/ServiceHierarchyOverlay.vue  |  63 +++----
 .../src/layer/service-map/TopologyDetailPanels.vue |   6 +-
 apps/ui/src/layer/service-map/useDeployment.ts     |   3 +-
 .../src/layer/service-map/useInstanceTopology.ts   |   3 +-
 apps/ui/src/layer/service-map/useLayerTopology.ts  |   5 +-
 .../render/layer-dashboard/LayerDashboardsView.vue |   7 +-
 apps/ui/src/render/widgets/KpiTileWidget.vue       |   5 +-
 .../src/render/widgets/MetricCompositeWidget.vue   |   5 +-
 apps/ui/src/render/widgets/RankingWidget.vue       |   3 +-
 apps/ui/src/shell/useLayers.test.ts                |  21 ++-
 apps/ui/src/shell/useLayers.ts                     |  18 ++
 apps/ui/src/utils/layerRoute.test.ts               |  27 +++
 apps/ui/src/utils/layerRoute.ts                    |  24 +++
 docs/access-control/rbac.md                        |  30 +++-
 docs/changelog/1.1.0.md                            |  20 ++-
 packages/api-client/src/topology.ts                |  14 ++
 64 files changed, 1312 insertions(+), 234 deletions(-)

diff --git a/apps/bff/src/ai/lib/tools/telemetry/tools.ts b/apps/bff/src/ai/lib/tools/telemetry/tools.ts
index 59fdc664..633c9635 100644
--- a/apps/bff/src/ai/lib/tools/telemetry/tools.ts
+++ b/apps/bff/src/ai/lib/tools/telemetry/tools.ts
@@ -100,7 +100,7 @@ export function telemetryTools(ctx: ToolContext): StructuredToolInterface[] {
             return 'The service catalog could not be read, so alarms cannot be told apart by the services they concern right now. This is not a permission problem; try again shortly.';
           }
           const index = catalogIndex(cat);
-          const rowAccess = ctx.access && !everything ? new AlarmRowAccess(ctx.access, index) : null;
+          const rowAccess = ctx.access && !everything ? new AlarmRowAccess(ctx.access) : null;
           const page = await readFilteredPage(
             async (rows) => {
               const fetched = await fetchFirst(rows);
diff --git a/apps/bff/src/ai/lib/tools/visualization/tools.ts b/apps/bff/src/ai/lib/tools/visualization/tools.ts
index e91e7180..21ff81fc 100644
--- a/apps/bff/src/ai/lib/tools/visualization/tools.ts
+++ b/apps/bff/src/ai/lib/tools/visualization/tools.ts
@@ -168,8 +168,12 @@ function topoMetricLine(metrics: Record<string, number | null>, defs: MetricLege
 
 // Compact "N nodes: name (metrics); …" line for the map builders' model text —
 // the WS2 rich read for deployment / instance / endpoint graphs.
+/** What the model is told of a service or call the user may not read: it is
+ *  drawn, its values are not read. */
+const METRICS_BLOCKED = 'metrics blocked for the current user';
+
 function summarizeMapNodes(
-  nodes: Array<{ name: string; metrics: Record<string, number | null> }>,
+  nodes: Array<{ name: string; metrics: Record<string, number | null>; metricsBlocked?: boolean }>,
   nodeDefs: MetricLegend[],
   cap: number,
 ): string {
@@ -177,6 +181,7 @@ function summarizeMapNodes(
   const head = nodes
     .slice(0, cap)
     .map((n) => {
+      if (n.metricsBlocked) return `${n.name} (${METRICS_BLOCKED})`;
       const line = topoMetricLine(n.metrics, nodeDefs);
       return line ? `${n.name} (${line})` : n.name;
     })
@@ -249,8 +254,8 @@ function summarizeTopology(snap: TopologyResponse, focusId: string, service: str
     const node = nodeById.get(peerId);
     const name = node?.name ?? peerId;
     const hasNodeVal = !!node && Object.values(node.metrics ?? {}).some((v) => v != null);
-    const health = hasNodeVal ? topoMetricLine(node!.metrics, nodeDefs) : '';
-    const edge = call ? topoMetricLine(call.serverMetrics, srvDefs) : '';
+    const health = node?.metricsBlocked ? METRICS_BLOCKED : hasNodeVal ? topoMetricLine(node!.metrics, nodeDefs) : '';
+    const edge = call?.metricsBlocked ? METRICS_BLOCKED : call ? topoMetricLine(call.serverMetrics, srvDefs) : '';
     const parts = [health && `node ${health}`, edge && `edge ${edge}`].filter(Boolean);
     return parts.length ? `${name} (${parts.join(' · ')})` : name;
   };
@@ -574,6 +579,7 @@ export function visualizationTools(ctx: ToolContext): StructuredToolInterface[]
         // layer's own map page passes a roster selection.
         serviceArg: rows.map((r) => r!.id).join(','),
         depth: hops,
+        ...ctx.access?.graphReadable(['topology:read']),
       });
       const nodeById = new Map(snapshot.nodes.map((n) => [n.id, n]));
       const toPeer = (id: string): TopoPeer | null => {
@@ -651,6 +657,7 @@ export function visualizationTools(ctx: ToolContext): StructuredToolInterface[]
         serviceArg: '',
         depth: 1,
         seedGroups: reach && !reach.whole ? reach.groups : undefined,
+        ...ctx.access?.graphReadable(['topology:read']),
       });
       if (!snapshot.reachable) {
         return `The ${layer.toUpperCase()} layer map is unreachable (${snapshot.error ?? 'no data'}).`;
@@ -797,6 +804,7 @@ export function visualizationTools(ctx: ToolContext): StructuredToolInterface[]
         layerKey: layer.toUpperCase(),
         clientServiceId: client.id,
         serverServiceId: server.id,
+        ...ctx.access?.graphReadable(['topology:read']),
       });
       ctx.emitInstanceTopology({
         title: title || `Instance map — ${sourceService} → ${destService}`,
@@ -862,6 +870,7 @@ export function visualizationTools(ctx: ToolContext): StructuredToolInterface[]
         layerKey: layer.toUpperCase(),
         service: { id: row.id, name: row.name, normal: row.normal !== false },
         endpointArg: endpoint ?? '',
+        ...ctx.access?.graphReadable(['topology:read']),
       });
       ctx.emitEndpointDependency({
         title: title || `API dependency — ${service}`,
diff --git a/apps/bff/src/http/query/alarms-filters.test.ts b/apps/bff/src/http/query/alarms-filters.test.ts
index b146fa36..289edc9a 100644
--- a/apps/bff/src/http/query/alarms-filters.test.ts
+++ b/apps/bff/src/http/query/alarms-filters.test.ts
@@ -127,7 +127,11 @@ interface AlarmRead {
 interface Oap {
   fetch: FetchLike;
   alarmReads: AlarmRead[];
+  /** The ids `getService` was asked about. */
+  serviceAsks: string[];
 }
+/** A service OAP holds beyond the catalog's read. */
+type Beyond = { name: string; normal: boolean; group: string; layers: string[] };
 
 interface EntityWire {
   scope: string;
@@ -144,16 +148,20 @@ function entityId(e: EntityWire): string {
   return service;
 }
 
-function fakeOap(rows: Stored[], opts: { legacy?: boolean; down?: boolean } = {}): Oap {
+function fakeOap(rows: Stored[], opts: { legacy?: boolean; down?: boolean; services?: Record<string, Beyond> } = {}): Oap {
   const alarmReads: AlarmRead[] = [];
+  const serviceAsks: string[] = [];
   const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
   const fetch: FetchLike = async (_url, init) => {
     const { query = '', variables = {} } = JSON.parse(String(init?.body ?? '{}')) as { query?: string; variables?: Record<string, unknown> };
     if (query.includes('__type')) return json({ data: { __type: { fields: opts.legacy ? [] : [{ name: 'queryAlarms' }] } } });
     if (query.includes('getTimeInfo')) return json({ data: { time: { timezone: '+0000' } } });
     if (query.includes('HorizonAccessService')) {
+      const id = String(variables.id);
+      serviceAsks.push(id);
       if (opts.down) throw new Error('connect ECONNREFUSED');
-      return json({ data: { service: null } });
+      const hit = opts.services?.[id];
+      return json({ data: { service: hit ? { id, ...hit } : null } });
     }
     if (query.includes('HorizonAlarmServices')) {
       const layer = String(variables.layer);
@@ -172,7 +180,7 @@ function fakeOap(rows: Stored[], opts: { legacy?: boolean; down?: boolean } = {}
     }
     return json({ data: {} });
   };
-  return { fetch, alarmReads };
+  return { fetch, alarmReads, serviceAsks };
 }
 
 let app: FastifyInstance | null = null;
@@ -200,7 +208,7 @@ async function call(role: keyof typeof ROLES, query: string, oap: Oap, setup: Se
   const config: ConfigSource = { current: cfg, current_: () => cfg, path: '', onChange: () => () => {}, close: async () => {} };
   const snapshot: ServiceCatalog = setup.catalogUnreachable
     ? { layers: [], byLayer: new Map(), byName: new Map(), unreachable: true }
-    : CATALOG;
+    : { ...CATALOG };
   const catalog = { get: async () => snapshot } as unknown as ServiceLayerCatalog;
   const store = setup.store ?? STORE;
   let storeReads = 0;
@@ -347,12 +355,31 @@ describe('a service picked alone', () => {
     expect(names(byService.body)).toEqual(names(byGroup.body));
   });
 
-  it('serves a group grant its service without asking whose the other services are', async () => {
-    // With OAP unable to say whose `User` is, deciding every row of the window
-    // would refuse the read; each row kept concerns the checked service.
-    const res = await call('payments', SERVICE, fakeOap(ROWS, { down: true }));
+  it('serves a group grant its service, asking only about the rows it lists', async () => {
+    const elsewhere = svc('elsewhere::ghost');
+    const oap = fakeOap([...ROWS, row('Service', elsewhere.id, elsewhere.name)]);
+    const res = await call('payments', SERVICE, oap);
     expect(res.status).toBe(200);
     expect(names(res.body)).toEqual(namesOf(CHECKOUTS));
+    expect(oap.serviceAsks).not.toContain(elsewhere.id);
+  });
+
+  // The catalog was read before a conjectured risk `payments::checkout`
+  // registered: a relation that names it only as its destination may mean
+  // either, so it is the payments reader's only when both are.
+  it('drops a relation into the picked service that may mean an unreadable namesake', async () => {
+    const namesake = { name: checkout.name, normal: false, group: 'risk', layers: ['GENERAL'] };
+    const oap = fakeOap(ROWS, { services: { [serviceIdOf(checkout.name, false)]: namesake } });
+    const res = await call('payments', SERVICE, oap);
+    expect(res.status).toBe(200);
+    expect(names(res.body)).toEqual(namesOf([CHECKOUT, CHECKOUT_1, CHECKOUT_EP, TO_SCORER]));
+    expect(names((await call('ops', SERVICE, fakeOap(ROWS, { services: { [serviceIdOf(checkout.name, false)]: namesake } }))).body)).toEqual(
+      namesOf(CHECKOUTS),
+    );
+  });
+
+  it('says OAP could not be asked, rather than listing a relation it could not decide', async () => {
+    expect((await call('payments', SERVICE, fakeOap(ROWS, { down: true }))).status).toBe(503);
   });
 
   it('is still refused a service the grant does not read', async () => {
@@ -395,4 +422,21 @@ describe('a picked instance or endpoint', () => {
     expect(endpoint.alarmReads[0]!.entities).toEqual([{ scope: 'Endpoint', serviceName: checkout.name, normal: true, endpointName: 'POST:/pay' }]);
     expect(names(byEndpoint.body)).toEqual([CHECKOUT_EP.name]);
   });
+
+  // OAP matched the relation on the picked instance's exact id, so a
+  // conjectured namesake of its service cannot be what the relation meant.
+  it('keeps a relation into it for a group grant, whatever namesake its service has', async () => {
+    const namesake = { name: checkout.name, normal: false, group: 'risk', layers: ['GENERAL'] };
+    const oap = fakeOap(ROWS, { services: { [serviceIdOf(checkout.name, false)]: namesake } });
+    const res = await call('payments', `${SERVICE}&instance=checkout-1`, oap);
+    expect(res.status).toBe(200);
+    expect(names(res.body)).toEqual(namesOf([CHECKOUT_1, INSTANCE_FROM_SCORER]));
+  });
+
+  it('asks OAP nothing about the rows it returns', async () => {
+    const oap = fakeOap(ROWS, { down: true });
+    const res = await call('payments', `${SERVICE}&instance=checkout-1`, oap);
+    expect(res.status).toBe(200);
+    expect(names(res.body)).toEqual(namesOf([CHECKOUT_1, INSTANCE_FROM_SCORER]));
+  });
 });
diff --git a/apps/bff/src/http/query/alarms.ts b/apps/bff/src/http/query/alarms.ts
index 10560a41..c53d62bb 100644
--- a/apps/bff/src/http/query/alarms.ts
+++ b/apps/bff/src/http/query/alarms.ts
@@ -65,9 +65,12 @@
  *     overview widget is bound to: it keeps the rows of that group's
  *     services, by `ownerKeys`. It narrows what the caller may read; it
  *     grants nothing.
- *   - A caller who does not hold `alarms:read` on every layer gets, from
- *     a read that names no service, only the rows of services they may
- *     read (logic/alarms/readable.ts), filtered here because OAP cannot.
+ *   - A caller who does not hold `alarms:read` on every layer gets only
+ *     the rows of services they may read (logic/alarms/readable.ts),
+ *     filtered here because OAP cannot — a picked service included, since
+ *     a relation naming it only as its destination may mean a namesake. A
+ *     picked instance or endpoint is OAP's exact entity, whose service the
+ *     gate has checked, so its rows are not filtered again.
  *     The count counts those rows alone.
  *   - `page` names an alarm page (logic/alarms/pages.ts): only the rows
  *     one of its pins covers are kept, the pins narrowed to the caller as
@@ -103,7 +106,7 @@ import { readsByNameOnly } from '../../rbac/request-access.js';
 import { ServiceLookupUnavailable, catalogIndex, entityServiceName, serviceIdOf, type Index } from '../../logic/services/service-identity.js';
 import { alarmConcernsService, alarmIncidentKey, alarmLayers, alarmOwnerKeys } from '../../logic/alarms/owners.js';
 import { namedPagePins, pinReachOf, readStoredAlarmPages } from '../../logic/alarms/pages.js';
-import { AlarmRowAccess, keepAll, readsEveryAlarm, type DecidedRow } from '../../logic/alarms/readable.js';
+import { AlarmRowAccess, readsEveryAlarm, type DecidedRow } from '../../logic/alarms/readable.js';
 import { canonicalLayerKey } from '../../logic/templates/identity.js';
 
 export interface AlarmsQueryRouteDeps extends AuthDeps {
@@ -467,27 +470,30 @@ export function registerAlarmsQueryRoutes(app: FastifyInstance, deps: AlarmsQuer
 
     const layerPin = q.layer && !named ? layerFilterPin(q.layer, canonicalLayerKey) : null;
     const index = catalogIndex(catalog);
-    // Every row a picked service keeps concerns that service, which the gate
-    // has checked; the legacy query cannot narrow, so its rows are decided.
-    const perRow = access && !readsEveryAlarm(access) && (!named || !caps.queryAlarms) ? new AlarmRowAccess(access, index) : null;
+    // A picked service narrows which rows are listed, not who may read them:
+    // a relation that names it only as its destination may mean a namesake.
+    // A picked instance or endpoint is matched by OAP on its exact id at
+    // either end, and the gate has checked its service, so its rows are not
+    // decided again. The legacy query ignores the entity, so its rows are.
+    const exactEntity = entity !== null && caps.queryAlarms;
+    const perRow = access && !readsEveryAlarm(access) && !exactEntity ? new AlarmRowAccess(access) : null;
     if ((layerPin || perRow || pagePins || serviceId) && catalogMissing(catalog)) return catalogUnavailable(reply);
-    const keep = (d: DecidedRow<AlarmMessage>): boolean =>
-      d.kept &&
-      (!layerPin || alarmPinMatches(layerPin, d.row)) &&
-      (!pagePins || pagePins.some((pin) => alarmPinMatches(pin, d.row))) &&
-      (!serviceId || alarmConcernsService(d.row, serviceId, index));
+    const listed = (m: AlarmMessage): boolean =>
+      (!layerPin || alarmPinMatches(layerPin, m)) &&
+      (!pagePins || pagePins.some((pin) => alarmPinMatches(pin, m))) &&
+      (!serviceId || alarmConcernsService(m, serviceId, index));
     // Every read starts at row 0 — see readPrefixPage for the OAP offset
     // this avoids.
     const fetchFirst = async (rows: number): Promise<Array<DecidedRow<AlarmMessage>>> => {
       const tagged = tagWithOwners(await fetchAlarms({ pageNum: 1, pageSize: rows }), index);
-      return perRow ? perRow.decide(tagged) : keepAll(tagged);
+      return perRow ? perRow.decide(tagged, listed) : tagged.map((row) => ({ row, kept: listed(row) }));
     };
     const paging = { pageNum: q.pageNum, pageSize: q.pageSize };
     let page: PageResult<DecidedRow<AlarmMessage>>;
     let tagged: AlarmMessage[];
     try {
       page = layerPin || perRow || pagePins || serviceId
-        ? await readFilteredPage(fetchFirst, keep, paging, ALARM_READ)
+        ? await readFilteredPage(fetchFirst, (d) => d.kept, paging, ALARM_READ)
         : await readPrefixPage(fetchFirst, paging);
       tagged = page.rows.map((d) => d.row);
     } catch (err) {
@@ -567,7 +573,7 @@ export function registerAlarmsQueryRoutes(app: FastifyInstance, deps: AlarmsQuer
         if (!access || !catalog) {
           capped = await readPageWith(fetchCountRows, { pageNum: 1, pageSize: COUNT_FETCH_CAP });
         } else {
-          const rowAccess = new AlarmRowAccess(access, catalogIndex(catalog));
+          const rowAccess = new AlarmRowAccess(access);
           const page = await readFilteredPage(
             async (rows) => rowAccess.decide(await fetchCountRows({ pageNum: 1, pageSize: rows })),
             (d) => d.kept,
diff --git a/apps/bff/src/http/query/endpoint-dependency.ts b/apps/bff/src/http/query/endpoint-dependency.ts
index 3667a5c7..5f8e1899 100644
--- a/apps/bff/src/http/query/endpoint-dependency.ts
+++ b/apps/bff/src/http/query/endpoint-dependency.ts
@@ -147,6 +147,7 @@ export function registerEndpointDependencyRoute(
         layerKey,
         service,
         endpointArg,
+        ...req.access?.graphReadable(['topology:read']),
       });
       return reply.send(response);
     },
diff --git a/apps/bff/src/http/query/instance-topology.ts b/apps/bff/src/http/query/instance-topology.ts
index 1dc7005a..d028dbd1 100644
--- a/apps/bff/src/http/query/instance-topology.ts
+++ b/apps/bff/src/http/query/instance-topology.ts
@@ -141,6 +141,7 @@ export function registerInstanceTopologyRoute(
         layerKey,
         clientServiceId,
         serverServiceId,
+        ...req.access?.graphReadable(['topology:read']),
       });
       return reply.send(response);
     },
diff --git a/apps/bff/src/http/query/services-access.test.ts b/apps/bff/src/http/query/services-access.test.ts
new file mode 100644
index 00000000..6e87d35e
--- /dev/null
+++ b/apps/bff/src/http/query/services-access.test.ts
@@ -0,0 +1,106 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * `GET /api/layer/:key/services` through the real gate: a group entry of a
+ * layer split by service group is refused, as a layer is, when the grant does
+ * not reach it — the layer page tells "no access" from "not found" by that.
+ */
+
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import Fastify, { type FastifyInstance } from 'fastify';
+import cookie from '@fastify/cookie';
+import type { FetchLike } from '@skywalking-horizon-ui/api-client';
+import { configSchema } from '../../config/schema.js';
+import type { ConfigSource } from '../../config/loader.js';
+import { SessionStore } from '../../user/sessions.js';
+import { makeRouteAuthHook } from '../../rbac/route-policy.js';
+import { ServiceIdentityResolver } from '../../logic/services/service-identity.js';
+import { resetServiceLayerCatalog, serviceLayerCatalog } from '../../logic/services/service-layer-catalog.js';
+import { registerLayerServicesRoute } from './services.js';
+
+const ROLES = {
+  viewer: ['metrics:read'],
+  payments: ['metrics:read@GENERAL[payments]'],
+};
+
+const SERVICES = [
+  { id: 'a.1', name: 'payments::a', normal: true, group: 'payments' },
+  { id: 'c.1', name: 'risk::c', normal: true, group: 'risk' },
+];
+
+const oap: FetchLike = async (_url, init) => {
+  const { query = '' } = JSON.parse(String(init?.body ?? '{}')) as { query?: string };
+  const data = query.includes('HorizonServiceCatalogLayers') ? { layers: ['GENERAL'] } : { _0: SERVICES };
+  return new Response(JSON.stringify({ data }), { status: 200, headers: { 'content-type': 'application/json' } });
+};
+
+let app: FastifyInstance | null = null;
+beforeEach(() => resetServiceLayerCatalog());
+afterEach(async () => {
+  await app?.close();
+  app = null;
+  resetServiceLayerCatalog();
+});
+
+async function roster(role: keyof typeof ROLES, query: string) {
+  const cfg = configSchema.parse({ rbac: { roles: ROLES } });
+  const config: ConfigSource = { current: cfg, current_: () => cfg, path: '', onChange: () => () => {}, close: async () => {} };
+  const sessions = new SessionStore({ ttlMinutes: 60 });
+  const catalog = serviceLayerCatalog({ config, fetch: oap });
+  app = Fastify();
+  await app.register(cookie);
+  app.addHook(
+    'onRoute',
+    makeRouteAuthHook({
+      config,
+      sessions,
+      access: { services: new ServiceIdentityResolver({ config, catalog, fetch: oap }), classify: async () => ({ isOperate: () => false }) },
+    }),
+  );
+  registerLayerServicesRoute(app, { config, sessions, fetch: oap });
+  await app.ready();
+  const sid = sessions.create(role, [role]).sid;
+  const res = await app.inject({ method: 'GET', url: `/api/layer/general/services${query}`, headers: { cookie: `horizon_sid=${sid}` } });
+  return { status: res.statusCode, body: res.json() as { services?: Array<{ name: string }>; reason?: string; group?: string } };
+}
+
+describe('a group entry of a split layer', () => {
+  it('lists the group\'s services to a role whose grant reaches it', async () => {
+    const res = await roster('payments', '?group=payments');
+    expect(res.status).toBe(200);
+    expect(res.body.services?.map((s) => s.name)).toEqual(['payments::a']);
+  });
+
+  it('refuses a group the grant does not reach, rather than listing nothing', async () => {
+    const res = await roster('payments', '?group=risk');
+    expect(res.status).toBe(403);
+    expect(res.body).toMatchObject({ error: 'permission_denied', reason: 'group_not_granted', group: 'risk' });
+  });
+
+  it('lists any group to a role that reads the whole layer', async () => {
+    const res = await roster('viewer', '?group=risk');
+    expect(res.status).toBe(200);
+    expect(res.body.services?.map((s) => s.name)).toEqual(['risk::c']);
+  });
+
+  it('keeps the whole-layer read narrowed to the grant\'s groups', async () => {
+    const res = await roster('payments', '');
+    expect(res.status).toBe(200);
+    expect(res.body.services?.map((s) => s.name)).toEqual(['payments::a']);
+  });
+});
diff --git a/apps/bff/src/http/query/services.ts b/apps/bff/src/http/query/services.ts
index 9e8d5373..a2abcb4a 100644
--- a/apps/bff/src/http/query/services.ts
+++ b/apps/bff/src/http/query/services.ts
@@ -63,6 +63,15 @@ export function registerLayerServicesRoute(
       // Optional `?group=` (from a split-by-service-group menu entry) —
       // narrow the roster to that OAP Service.group. Absent ⇒ all groups.
       const group = (req.query as { group?: string }).group;
+      // A group the caller's grant does not reach is refused, as a layer it
+      // does not reach is, so its menu entry opened by URL says "no access"
+      // rather than listing nothing.
+      const reach = group !== undefined ? req.access?.onLayer(LAYER_PAGE_VERBS, layerKey) : undefined;
+      if (reach && !reach.whole && !reach.groups.has(group!)) {
+        return reply
+          .code(403)
+          .send({ error: 'permission_denied', verb: LAYER_PAGE_VERBS.join(' or '), reason: 'group_not_granted', layer: layerKey, group });
+      }
       try {
         const snap = await catalog.get();
         const layerRows = snap.byLayer.get(layerUpper) ?? [];
diff --git a/apps/bff/src/http/query/topology.ts b/apps/bff/src/http/query/topology.ts
index a52048d4..672b1759 100644
--- a/apps/bff/src/http/query/topology.ts
+++ b/apps/bff/src/http/query/topology.ts
@@ -94,7 +94,9 @@ export function registerTopologyRoute(app: FastifyInstance, deps: TopologyRouteD
         return reply.code(400).send({ error: 'incomplete_service', message: scope.message });
       }
       const serviceArg = scope.kind === 'service' ? scope.service.id : '';
-      const depth = Math.max(1, Math.min(3, Number(q.depth) || 1));
+      // The layer-wide map is seeded with every service of the layer already,
+      // so hops add nothing but its neighbours' neighbours.
+      const depth = serviceArg ? Math.max(1, Math.min(3, Number(q.depth) || 1)) : 1;
 
       // Admin "Preview" mode: the page forwards the operator's unpublished
       // draft `topology` block so we render it against live OAP without
@@ -162,6 +164,7 @@ export function registerTopologyRoute(app: FastifyInstance, deps: TopologyRouteD
         depth,
         group: q.group,
         seedGroups: seedGroupsFor(req, layerKey),
+        ...req.access?.graphReadable(['topology:read']),
       });
       return reply.send(response);
     },
diff --git a/apps/bff/src/logic/alarms/owners.ts b/apps/bff/src/logic/alarms/owners.ts
index 10bef2b7..14cd4b8a 100644
--- a/apps/bff/src/logic/alarms/owners.ts
+++ b/apps/bff/src/logic/alarms/owners.ts
@@ -69,8 +69,18 @@ export function alarmSourceId(m: AlarmEntity): string | null {
  * pin it down: an instance or endpoint name may itself contain ` of ` / ` in `,
  * so every suffix the catalog knows is a candidate, and a normal and a
  * conjectured service can share a name.
+ *
+ * For placing a row only: whether the reader may see it is decided by
+ * `readable.ts`, which also asks OAP about a namesake the catalog lacks.
  */
 export function alarmDestinations(m: AlarmEntity, index: Index): ResolvedService[] {
+  return alarmDestinationNames(m).flatMap((name) => index.byName.get(name) ?? []);
+}
+
+/** Every service NAME the destination of a relation alarm could be — each
+ *  suffix after ` of ` / ` in ` for an instance or endpoint relation. What
+ *  each name means is for the caller to look up. */
+export function alarmDestinationNames(m: AlarmEntity): string[] {
   const source = alarmSourceId(m);
   const sourceName = source ? serviceNameOfId(source) : null;
   if (!source || sourceName === null) return [];
@@ -86,11 +96,9 @@ export function alarmDestinations(m: AlarmEntity, index: Index): ResolvedService
   }
   if (!m.name.startsWith(prefix)) return [];
   const rest = m.name.slice(prefix.length);
-  if (!joiner) return index.byName.get(rest) ?? [];
-  const out: ResolvedService[] = [];
-  for (let i = rest.indexOf(joiner); i >= 0; i = rest.indexOf(joiner, i + 1)) {
-    out.push(...(index.byName.get(rest.slice(i + joiner.length)) ?? []));
-  }
+  if (!joiner) return [rest];
+  const out: string[] = [];
+  for (let i = rest.indexOf(joiner); i >= 0; i = rest.indexOf(joiner, i + 1)) out.push(rest.slice(i + joiner.length));
   return out;
 }
 
diff --git a/apps/bff/src/logic/alarms/readable.test.ts b/apps/bff/src/logic/alarms/readable.test.ts
index d2cdacb3..8398f445 100644
--- a/apps/bff/src/logic/alarms/readable.test.ts
+++ b/apps/bff/src/logic/alarms/readable.test.ts
@@ -21,7 +21,7 @@ import { configSchema } from '../../config/schema.js';
 import type { ConfigSource } from '../../config/loader.js';
 import { SessionAccess } from '../../rbac/layer-access.js';
 import { RequestAccess } from '../../rbac/request-access.js';
-import { ServiceIdentityResolver, ServiceLookupUnavailable, catalogIndex, serviceIdOf } from '../services/service-identity.js';
+import { ServiceIdentityResolver, ServiceLookupUnavailable, serviceIdOf } from '../services/service-identity.js';
 import type { ServiceCatalog, ServiceLayerCatalog } from '../services/service-layer-catalog.js';
 import { AlarmRowAccess, readsEveryAlarm } from './readable.js';
 
@@ -59,25 +59,40 @@ const ROLES = {
   paymentsAndMeshRisk: ['alarms:read@GENERAL[payments]', 'alarms:read@MESH[risk]'],
 };
 
-/** OAP's `getService`, by id; anything else is a service OAP does not know. */
+/** OAP's `getService`, by id — one, or several aliased in one request;
+ *  anything else is a service OAP does not know. */
 function oap(known: Record<string, { group: string; layers: string[] }> = {}, down = false) {
   const asked: string[] = [];
+  const requests = { count: 0 };
   const fetch: FetchLike = async (_url, init) => {
-    const { variables } = JSON.parse(String(init?.body ?? '{}')) as { variables: { id: string } };
-    asked.push(variables.id);
+    const { variables } = JSON.parse(String(init?.body ?? '{}')) as { variables: Record<string, string> };
+    requests.count += 1;
+    const one = (id: string) => {
+      asked.push(id);
+      const hit = known[id];
+      return hit ? { id, name: 'x', normal: true, ...hit } : null;
+    };
+    const data =
+      'id' in variables
+        ? { service: one(variables.id!) }
+        : Object.fromEntries(Object.entries(variables).map(([k, id]) => [`s${k.slice(1)}`, one(id)]));
     if (down) throw new Error('connect ECONNREFUSED');
-    const hit = known[variables.id];
-    const service = hit ? { id: variables.id, name: 'x', normal: true, ...hit } : null;
-    return new Response(JSON.stringify({ data: { service } }), { status: 200, headers: { 'content-type': 'application/json' } });
+    return new Response(JSON.stringify({ data }), { status: 200, headers: { 'content-type': 'application/json' } });
   };
-  return { fetch, asked };
+  return { fetch, asked, requests };
 }
 
-function rowsFor(role: keyof typeof ROLES, fetch: FetchLike = oap().fetch): { access: RequestAccess; rows: AlarmRowAccess } {
-  const catalog = { get: async () => snapshot } as unknown as ServiceLayerCatalog;
+/** A fresh snapshot per call unless one is shared: what OAP answered beyond
+ *  a snapshot is held for that snapshot's life. */
+function rowsFor(
+  role: keyof typeof ROLES,
+  fetch: FetchLike = oap().fetch,
+  snap: ServiceCatalog = { ...snapshot },
+): { access: RequestAccess; rows: AlarmRowAccess } {
+  const catalog = { get: async () => snap } as unknown as ServiceLayerCatalog;
   const services = new ServiceIdentityResolver({ config, fetch, catalog });
   const access = new RequestAccess(new SessionAccess(ROLES[role], undefined, facts), services);
-  return { access, rows: new AlarmRowAccess(access, catalogIndex(snapshot)) };
+  return { access, rows: new AlarmRowAccess(access) };
 }
 
 const service = (s: { id: string; name: string }) => ({ scope: 'Service', id: s.id, name: s.name });
@@ -155,6 +170,63 @@ describe('alarms:read on a layer\'s group', () => {
     expect(await rowsFor('paymentsAndMeshRisk').rows.keeps(relation(scorer, 'reports'))).toBe(true);
   });
 
+  // The catalog was read before a conjectured risk `ledger` registered beside
+  // the payments one: the name still means both.
+  it('drops a relation whose destination may be a namesake the catalog has not seen yet', async () => {
+    const ledger = svc('ledger', 'payments');
+    const snap: ServiceCatalog = { ...snapshot, byLayer: new Map([...snapshot.byLayer, ['GENERAL', [checkout, scorer, reports, ledger]]]) };
+    const o = oap({ [serviceIdOf('ledger', false)]: { group: 'risk', layers: ['MESH'] } });
+    expect(await rowsFor('payments', o.fetch, snap).rows.keeps(relation(scorer, 'ledger'))).toBe(false);
+    expect(await rowsFor('paymentsAndMeshRisk', o.fetch, snap).rows.keeps(relation(scorer, 'ledger'))).toBe(true);
+    expect(await rowsFor('payments', o.fetch, snap).rows.keeps(relation(checkout, 'ledger'))).toBe(true);
+  });
+
+  it('asks OAP about a namesake the catalog lacks once per catalog read', async () => {
+    const o = oap();
+    const snap: ServiceCatalog = { ...snapshot };
+    for (let i = 0; i < 3; i++) expect(await rowsFor('payments', o.fetch, snap).rows.keeps(relation(scorer, checkout.name))).toBe(true);
+    expect(o.asked).toEqual([serviceIdOf(checkout.name, false)]);
+    await rowsFor('payments', o.fetch).rows.keeps(relation(scorer, checkout.name));
+    expect(o.asked).toHaveLength(2);
+  });
+
+  it('asks OAP about every service a batch of rows needs in one request', async () => {
+    const o = oap({ [serviceIdOf('d3', false)]: { group: 'payments', layers: ['GENERAL'] } });
+    const { rows } = rowsFor('payments', o.fetch);
+    const batch = Array.from({ length: 20 }, (_, i) => relation(scorer, `d${i}`));
+    const decided = await rows.decide([...batch, relation(scorer, checkout.name)]);
+    expect(o.requests.count).toBe(1);
+    expect(new Set(o.asked).size).toBe(41);
+    expect(decided.filter((d) => d.kept).map((d) => d.row.name)).toEqual(['risk::scorer to d3', `risk::scorer to ${checkout.name}`]);
+  });
+
+  it('asks nothing about the destinations of relations its source already decides', async () => {
+    const o = oap();
+    const { rows } = rowsFor('payments', o.fetch);
+    const batch = Array.from({ length: 20 }, (_, i) => relation(checkout, `d${i}`));
+    const decided = await rows.decide(batch);
+    expect(decided.every((d) => d.kept)).toBe(true);
+    expect(o.requests.count).toBe(0);
+  });
+
+  it('asks about many services a few requests at a time', async () => {
+    const o = oap();
+    const { rows } = rowsFor('payments', o.fetch);
+    await rows.decide(Array.from({ length: 120 }, (_, i) => relation(scorer, `d${i}`)));
+    expect(new Set(o.asked).size).toBe(240);
+    expect(o.requests.count).toBe(5);
+  });
+
+  it('still says OAP could not be asked when the batched request fails', async () => {
+    const { rows } = rowsFor('payments', oap({}, true).fetch);
+    await expect(rows.decide([relation(scorer, checkout.name)])).rejects.toBeInstanceOf(ServiceLookupUnavailable);
+  });
+
+  it('throws when OAP could not say whether the destination has a namesake', async () => {
+    const { rows } = rowsFor('payments', oap({}, true).fetch);
+    await expect(rows.keeps(relation(scorer, checkout.name))).rejects.toBeInstanceOf(ServiceLookupUnavailable);
+  });
+
   it('drops a relation whose destination the catalog does not know', async () => {
     expect(await rowsFor('payments').rows.keeps(relation(scorer, 'nobody-known'))).toBe(false);
   });
diff --git a/apps/bff/src/logic/alarms/readable.ts b/apps/bff/src/logic/alarms/readable.ts
index 27406178..584d2b74 100644
--- a/apps/bff/src/logic/alarms/readable.ts
+++ b/apps/bff/src/logic/alarms/readable.ts
@@ -16,7 +16,7 @@
  */
 
 /**
- * Which alarm rows a reader may see, for an alarm read that names no service.
+ * Which alarm rows a reader may see.
  *
  * An alarm is the service's that owns its entity. A relation concerns both of
  * its ends, so a reader of either sees the whole of it: of its source, or of
@@ -24,15 +24,17 @@
  * a process alarm) belongs to no layer a grant can name, so only a reader
  * holding `alarms:read` without a layer sees it.
  *
- * Every service is decided through {@link RequestAccess.decide}, once per id
+ * Every service is decided through {@link RequestAccess}, once per id or name
  * per request: it answers from the catalog while the catalog is fresh and
  * asks OAP for a service the catalog misses, or holds only from before a
- * failed refresh.
+ * failed refresh. A destination is known by name alone, and a name means
+ * both its normal and its conjectural id, so each is looked up — a namesake
+ * registered since the catalog was read still counts.
  */
 
-import type { RequestAccess } from '../../rbac/request-access.js';
-import { ServiceLookupUnavailable, type Index } from '../services/service-identity.js';
-import { alarmDestinations, alarmSourceId, isRelationAlarm, type AlarmEntity } from './owners.js';
+import type { RequestAccess, ServiceRef } from '../../rbac/request-access.js';
+import { ServiceLookupUnavailable, type ResolvedService } from '../services/service-identity.js';
+import { alarmDestinationNames, alarmSourceId, isRelationAlarm, type AlarmEntity } from './owners.js';
 
 const VERBS = ['alarms:read'] as const;
 
@@ -55,10 +57,7 @@ export function readsEveryAlarm(access: RequestAccess | undefined): boolean {
 export class AlarmRowAccess {
   private readonly decided = new Map<string, Promise<'allow' | 'deny' | 'unavailable'>>();
 
-  constructor(
-    private readonly access: RequestAccess,
-    private readonly index: Index,
-  ) {}
+  constructor(private readonly access: RequestAccess) {}
 
   /** Throws {@link ServiceLookupUnavailable} when OAP could not say whose a
    *  service is: dropping the row would pass a partial answer off as whole. */
@@ -73,24 +72,48 @@ export class AlarmRowAccess {
     return answer === 'allow';
   }
 
-  /** Whether the caller sees a row from a read that named no service. */
+  /** Whether the caller sees a row. */
   async keeps(m: AlarmEntity): Promise<boolean> {
     if (readsEveryAlarm(this.access)) return true;
     const source = alarmSourceId(m);
     if (!source) return !this.access.layerLimited(VERBS);
     if (await this.readable(source)) return true;
     if (!isRelationAlarm(m)) return false;
-    const dests = alarmDestinations(m, this.index);
-    if (dests.length === 0) return false;
-    for (const d of dests) if (!(await this.readable(d.id))) return false;
-    return true;
+    const dests: ResolvedService[] = [];
+    for (const name of alarmDestinationNames(m)) {
+      const r = await this.access.resolve({ name });
+      if (r.kind === 'unavailable') throw new ServiceLookupUnavailable('OAP did not answer which service an alarm concerns');
+      if (r.kind === 'found') dests.push(...r.services);
+    }
+    return dests.length > 0 && dests.every((d) => this.access.allowsResolved(VERBS, d));
   }
 
   /** Every row with whether it is kept — decided before a page filter, which
-   *  cannot wait, runs over them. */
-  async decide<T extends AlarmEntity>(rows: readonly T[]): Promise<Array<DecidedRow<T>>> {
+   *  cannot wait, runs over them. A row the read does not list is not kept,
+   *  and whose it is is not asked. */
+  async decide<T extends AlarmEntity>(rows: readonly T[], listed: (row: T) => boolean = () => true): Promise<Array<DecidedRow<T>>> {
+    if (!readsEveryAlarm(this.access)) await this.prefetch(rows.filter(listed));
     const out: Array<DecidedRow<T>> = [];
-    for (const row of rows) out.push({ row, kept: await this.keeps(row) });
+    for (const row of rows) out.push({ row, kept: listed(row) && (await this.keeps(row)) });
     return out;
   }
+
+  /** Asks OAP at once for what deciding `rows` needs: the sources first, then
+   *  the destinations of only the relations whose source does not already
+   *  decide them. */
+  private async prefetch(rows: readonly AlarmEntity[]): Promise<void> {
+    const sources = new Set<string>();
+    for (const m of rows) {
+      const source = alarmSourceId(m);
+      if (source) sources.add(source);
+    }
+    await this.access.prefetch([...sources].map((id): ServiceRef => ({ id })));
+    const names = new Set<string>();
+    for (const m of rows) {
+      const source = alarmSourceId(m);
+      if (!source || !isRelationAlarm(m) || (await this.readable(source))) continue;
+      for (const name of alarmDestinationNames(m)) names.add(name);
+    }
+    await this.access.prefetch([...names].map((name): ServiceRef => ({ name })));
+  }
 }
diff --git a/apps/bff/src/logic/oap/endpoint-dependency.ts b/apps/bff/src/logic/oap/endpoint-dependency.ts
index f9315dcc..87bf6e75 100644
--- a/apps/bff/src/logic/oap/endpoint-dependency.ts
+++ b/apps/bff/src/logic/oap/endpoint-dependency.ts
@@ -188,10 +188,15 @@ export interface BuildEndpointDependencyInput {
   service: { id: string; name: string; normal: boolean };
   /** Endpoint NAME or id — resolved to an endpointId that PINS the chain. */
   endpointArg: string;
+  /** Of the services the graph's endpoints belong to, the ones whose values
+   *  the caller may see; absent when it may see every one. The others'
+   *  endpoints are drawn without metrics, and a call keeps its values when
+   *  the caller reads either end. */
+  readableOf?: (ids: readonly string[]) => Promise<ReadonlySet<string>>;
 }
 
 export async function buildEndpointDependency(input: BuildEndpointDependencyInput): Promise<EndpointDependencyResponse> {
-  const { opts, perf, window, coldStage, cfg: epCfg, layerKey, service, endpointArg } = input;
+  const { opts, perf, window, coldStage, cfg: epCfg, layerKey, service, endpointArg, readableOf } = input;
   const serviceId = service.id;
   const serviceArg = service.name;
   // The id carries the flag (`.1` real, `.0` conjectured) and is what was
@@ -237,8 +242,12 @@ export async function buildEndpointDependency(input: BuildEndpointDependencyInpu
     return emptyEndpointDependencyResponse(layerKey, serviceArg, endpointArg, endpointId, epCfg, false, err instanceof Error ? err.message : String(err));
   }
 
+  const readable = readableOf ? await readableOf([...new Set(graph.nodes.map((n) => n.serviceId))]) : null;
+  const withheld = (n: { serviceId: string } | undefined): boolean => readable !== null && (!n || !readable.has(n.serviceId));
+  const blocked = (n: { isReal: boolean; name: string; serviceId: string }): boolean =>
+    n.isReal && n.name !== 'User' && withheld(n);
   const realNodes = graph.nodes.filter(
-    (n) => n.isReal && n.serviceName && n.name && n.name !== 'User',
+    (n) => n.isReal && n.serviceName && n.name && n.name !== 'User' && !withheld(n),
   );
   // Per-node + per-edge MQE. Build both fragment families, then fan them
   // out concurrently (disjoint OAP entities + result maps); each chunks
@@ -275,9 +284,11 @@ export async function buildEndpointDependency(input: BuildEndpointDependencyInpu
   const linkMetrics = epCfg.linkMetrics ?? [];
   const realEndpointMap = new Map(realNodes.map((n) => [n.id, n]));
   const nodeById = new Map(graph.nodes.map((n) => [n.id, n]));
+  const callOpen = (c: { source: string; target: string }): boolean =>
+    !withheld(nodeById.get(c.source)) || !withheld(nodeById.get(c.target));
   const candidateEdges = graph.calls.filter((c) => {
     const dst = nodeById.get(c.target);
-    return !!dst && dst.isReal && !!dst.name && !!dst.serviceName;
+    return !!dst && dst.isReal && !!dst.name && !!dst.serviceName && callOpen(c);
   });
   const edgeAliasMap = new Map<string, { callId: string; metric: TopologyMetricDef }>();
   const edgeFragments: string[] = [];
@@ -352,7 +363,9 @@ export async function buildEndpointDependency(input: BuildEndpointDependencyInpu
     const m = nodeMetricVals.get(n.id) ?? {};
     const filled: Record<string, number | null> = {};
     for (const def of epCfg.nodeMetrics) filled[def.id] = m[def.id] ?? null;
-    if (n.isReal && n.name !== 'User' && !hasAnyValue(filled)) continue;
+    // A blocked endpoint has no values because none were read, not because it
+    // carried no traffic: it stays, and says so.
+    if (n.isReal && n.name !== 'User' && !hasAnyValue(filled) && !blocked(n)) continue;
     liveNodes.push({
       id: n.id,
       name: n.name,
@@ -362,6 +375,7 @@ export async function buildEndpointDependency(input: BuildEndpointDependencyInpu
       isReal: n.isReal,
       metrics: filled,
       ...legacyNodeView(filled),
+      ...(blocked(n) ? { metricsBlocked: true } : {}),
     });
   }
   const liveIds = new Set(liveNodes.map((n) => n.id));
@@ -384,6 +398,7 @@ export async function buildEndpointDependency(input: BuildEndpointDependencyInpu
       metrics: filled,
       metricSeries: filledSeries,
       ...legacyEdgeView(filled),
+      ...(callOpen(c) ? {} : { metricsBlocked: true }),
     });
   }
 
diff --git a/apps/bff/src/logic/oap/graph-metrics-blocked.test.ts b/apps/bff/src/logic/oap/graph-metrics-blocked.test.ts
new file mode 100644
index 00000000..3c350c75
--- /dev/null
+++ b/apps/bff/src/logic/oap/graph-metrics-blocked.test.ts
@@ -0,0 +1,197 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * A graph draws every service OAP connects to the ones asked about; the
+ * metrics of a service the caller may not read are not read. A node keeps its
+ * name and says its metrics are blocked, and a call keeps its values when the
+ * caller reads either end.
+ */
+
+import { describe, expect, it } from 'vitest';
+import type { FetchLike } from '@skywalking-horizon-ui/api-client';
+import { configSchema } from '../../config/schema.js';
+import { buildServiceTopology } from './service-topology.js';
+import { buildInstanceTopology } from './instance-topology.js';
+import { buildEndpointDependency } from './endpoint-dependency.js';
+
+const id = (name: string, normal = true) => `${Buffer.from(name).toString('base64')}.${normal ? 1 : 0}`;
+const child = (serviceId: string, name: string) => `${serviceId}_${Buffer.from(name).toString('base64')}`;
+
+// payments::a (readable) → risk::b → risk::c, and the virtual User → payments::a.
+const A = id('payments::a');
+const B = id('risk::b');
+const C = id('risk::c');
+const USER = id('User', false);
+const readableOf = async () => new Set([A]);
+
+const node = (nodeId: string, name: string, isReal = true) => ({ id: nodeId, name, type: null, isReal, layers: isReal ? ['GENERAL'] : [] });
+const call = (source: string, target: string) => ({ id: `${source}-${target}`, source, target, detectPoints: ['SERVER', 'CLIENT'] });
+
+/** Answers the graph queries with `graphs` and every metric with 5; records
+ *  the metric queries it was sent. */
+function oap(graphs: Record<string, unknown>) {
+  const metricQueries: string[] = [];
+  const fetch: FetchLike = async (_url, init) => {
+    const { query = '' } = JSON.parse(String(init?.body ?? '{}')) as { query?: string };
+    let data: Record<string, unknown> = {};
+    if (query.includes('execExpression')) {
+      metricQueries.push(query);
+      for (const m of query.matchAll(/(\w+): execExpression/g)) data[m[1]!] = { type: 'TIME_SERIES_VALUES', results: [{ values: [{ value: '5' }] }] };
+    } else {
+      const key = Object.keys(graphs).find((k) => query.includes(k));
+      data = key ? (graphs[key] as Record<string, unknown>) : {};
+    }
+    return new Response(JSON.stringify({ data }), { status: 200, headers: { 'content-type': 'application/json' } });
+  };
+  return { fetch, metricQueries: () => metricQueries.join('\n') };
+}
+
+const base = {
+  perf: configSchema.parse({}).performance,
+  window: { start: '2026-09-29 1000', end: '2026-09-29 1015', step: 'MINUTE' as const },
+  coldStage: false,
+};
+const opts = (fetch: FetchLike) => ({ queryUrl: 'http://oap:12800', timeoutMs: 5000, fetch });
+const metric = { id: 'cpm', label: 'Load', mqe: 'service_cpm' };
+const link = { id: 'cpm', label: 'Load', mqe: 'service_relation_server_cpm' };
+const linkCli = { id: 'cpm', label: 'Load', mqe: 'service_relation_client_cpm' };
+
+describe('the service map', () => {
+  const graph = {
+    getServicesTopology: {
+      topology: {
+        nodes: [node(A, 'payments::a'), node(B, 'risk::b'), node(C, 'risk::c'), node(USER, 'User', false)],
+        calls: [call(A, B), call(B, C), call(USER, A)],
+      },
+    },
+    listServices: { services: [] },
+  };
+
+  async function build(withCheck: boolean) {
+    const o = oap(graph);
+    const res = await buildServiceTopology({
+      ...base,
+      opts: opts(o.fetch),
+      cfg: { nodeMetrics: [metric], linkServerMetrics: [link], linkClientMetrics: [linkCli] },
+      layerKey: 'GENERAL',
+      serviceArg: A,
+      depth: 2,
+      ...(withCheck ? { readableOf } : {}),
+    });
+    return { res, queries: o.metricQueries() };
+  }
+
+  it('draws every connected service, and reads no metric of one the caller may not read', async () => {
+    const { res, queries } = await build(true);
+    expect(res.nodes.map((n) => n.name).sort()).toEqual(['User', 'payments::a', 'risk::b', 'risk::c']);
+    const b = res.nodes.find((n) => n.id === B)!;
+    expect(b.metricsBlocked).toBe(true);
+    expect(b.metrics).toEqual({ cpm: null });
+    expect(res.nodes.find((n) => n.id === A)).toMatchObject({ metrics: { cpm: 5 } });
+    expect(res.nodes.find((n) => n.id === A)!.metricsBlocked).toBeUndefined();
+    // A virtual service has no metrics of its own to block.
+    expect(res.nodes.find((n) => n.id === USER)!.metricsBlocked).toBeUndefined();
+    expect(queries).not.toMatch(/scope: Service, serviceName: "risk::/);
+  });
+
+  it('keeps both sides of a call the caller reads one end of, and none of one it reads neither end of', async () => {
+    const { res } = await build(true);
+    const ab = res.calls.find((c) => c.id === `${A}-${B}`)!;
+    expect(ab.metricsBlocked).toBeUndefined();
+    expect(ab).toMatchObject({ serverMetrics: { cpm: 5 }, clientMetrics: { cpm: 5 } });
+    const bc = res.calls.find((c) => c.id === `${B}-${C}`)!;
+    expect(bc.metricsBlocked).toBe(true);
+    expect(bc).toMatchObject({ serverMetrics: { cpm: null }, clientMetrics: { cpm: null } });
+    expect(res.calls.find((c) => c.id === `${USER}-${A}`)).toMatchObject({ serverMetrics: { cpm: 5 } });
+  });
+
+  it('reads every value for a caller who may read every service', async () => {
+    const { res } = await build(false);
+    expect(res.nodes.find((n) => n.id === B)).toMatchObject({ metrics: { cpm: 5 } });
+    expect(res.calls.find((c) => c.id === `${B}-${C}`)).toMatchObject({ serverMetrics: { cpm: 5 } });
+    expect(res.nodes.some((n) => n.metricsBlocked) || res.calls.some((c) => c.metricsBlocked)).toBe(false);
+  });
+});
+
+describe('the instance map', () => {
+  const a1 = child(A, 'a-1');
+  const b1 = child(B, 'b-1');
+  const graph = {
+    getServiceInstanceTopology: {
+      topology: {
+        nodes: [
+          { id: a1, name: 'a-1', serviceId: A, serviceName: 'payments::a', isReal: true },
+          { id: b1, name: 'b-1', serviceId: B, serviceName: 'risk::b', isReal: true },
+        ],
+        calls: [call(b1, a1)],
+      },
+    },
+    listServices: { services: [] },
+  };
+
+  it('draws the other service\'s instances without their metrics, and keeps the calls between them', async () => {
+    const o = oap(graph);
+    const res = await buildInstanceTopology({
+      ...base,
+      opts: opts(o.fetch),
+      cfg: { nodeMetrics: [{ id: 'cpm', label: 'Load', mqe: 'service_instance_cpm' }], linkServerMetrics: [link], linkClientMetrics: [] },
+      layerKey: 'GENERAL',
+      clientServiceId: B,
+      serverServiceId: A,
+      readableOf,
+    });
+    expect(res.nodes.find((n) => n.id === b1)).toMatchObject({ metricsBlocked: true, metrics: { cpm: null } });
+    expect(res.nodes.find((n) => n.id === a1)).toMatchObject({ metrics: { cpm: 5 } });
+    expect(res.calls[0]).toMatchObject({ serverMetrics: { cpm: 5 } });
+    expect(o.metricQueries()).not.toMatch(/scope: ServiceInstance, serviceName: "risk::b"/);
+  });
+});
+
+describe('the API dependency graph', () => {
+  const pay = child(A, 'POST:/pay');
+  const score = child(B, 'POST:/score');
+  const graph = {
+    findEndpoint: { endpoints: [{ id: pay, name: 'POST:/pay' }] },
+    getEndpointDependencies: {
+      topology: {
+        nodes: [
+          { id: pay, name: 'POST:/pay', serviceId: A, serviceName: 'payments::a', type: null, isReal: true },
+          { id: score, name: 'POST:/score', serviceId: B, serviceName: 'risk::b', type: null, isReal: true },
+        ],
+        calls: [call(score, pay)],
+      },
+    },
+  };
+
+  it('keeps an endpoint of a service the caller may not read, marked, instead of dropping it for having no values', async () => {
+    const o = oap(graph);
+    const res = await buildEndpointDependency({
+      ...base,
+      opts: opts(o.fetch),
+      cfg: { nodeMetrics: [{ id: 'cpm', label: 'Load', mqe: 'endpoint_cpm' }], linkMetrics: [{ id: 'cpm', label: 'Load', mqe: 'endpoint_relation_cpm' }] },
+      layerKey: 'GENERAL',
+      service: { id: A, name: 'payments::a', normal: true },
+      endpointArg: 'POST:/pay',
+      readableOf,
+    });
+    expect(res.nodes.find((n) => n.id === score)).toMatchObject({ metricsBlocked: true, metrics: { cpm: null } });
+    expect(res.nodes.find((n) => n.id === pay)).toMatchObject({ metrics: { cpm: 5 } });
+    expect(res.calls[0]).toMatchObject({ metrics: { cpm: 5 } });
+    expect(o.metricQueries()).not.toMatch(/scope: Endpoint,\s*serviceName: "risk::b"/);
+  });
+});
diff --git a/apps/bff/src/logic/oap/instance-topology.ts b/apps/bff/src/logic/oap/instance-topology.ts
index a1f726bb..d500c0b0 100644
--- a/apps/bff/src/logic/oap/instance-topology.ts
+++ b/apps/bff/src/logic/oap/instance-topology.ts
@@ -147,10 +147,13 @@ export interface BuildInstanceTopologyInput {
   layerKey: string;
   clientServiceId: string;
   serverServiceId: string;
+  /** Of the two services, the ones whose values the caller may see; absent
+   *  when it may see both. The other's instances are drawn without metrics. */
+  readableOf?: (ids: readonly string[]) => Promise<ReadonlySet<string>>;
 }
 
 export async function buildInstanceTopology(input: BuildInstanceTopologyInput): Promise<InstanceTopologyResponse> {
-  const { opts, perf, window, coldStage, cfg: instCfg, layerKey, clientServiceId, serverServiceId } = input;
+  const { opts, perf, window, coldStage, cfg: instCfg, layerKey, clientServiceId, serverServiceId, readableOf } = input;
   const oapLayer = layerKey.toUpperCase();
   const durationVar = coldStage
     ? { start: window.start, end: window.end, step: window.step, coldStage: true }
@@ -206,7 +209,9 @@ export async function buildInstanceTopology(input: BuildInstanceTopologyInput):
   const serverMetricSeries = new Map<string, Record<string, Array<number | null> | null>>();
   const clientMetricSeries = new Map<string, Record<string, Array<number | null> | null>>();
 
-  const realNodes = nodes.filter((n) => n.isReal);
+  const readable = readableOf ? await readableOf([clientServiceId, serverServiceId]) : null;
+  const withheld = (n: OapInstNode): boolean => readable !== null && !readable.has(n.serviceId);
+  const realNodes = nodes.filter((n) => n.isReal && !withheld(n));
   const nodeAliasMap = new Map<string, { nodeId: string; metric: TopologyMetricDef }>();
   const nodeFragments: string[] = [];
   if (realNodes.length > 0 && instCfg.nodeMetrics.length > 0) {
@@ -309,6 +314,7 @@ export async function buildInstanceTopology(input: BuildInstanceTopologyInput):
       serviceName: n.serviceName,
       isReal: n.isReal,
       metrics: filled,
+      ...(n.isReal && withheld(n) ? { metricsBlocked: true } : {}),
     });
   }
   const liveNodeIds = new Set(liveNodes.map((n) => n.id));
diff --git a/apps/bff/src/logic/oap/service-topology.ts b/apps/bff/src/logic/oap/service-topology.ts
index a7076767..a0bd0336 100644
--- a/apps/bff/src/logic/oap/service-topology.ts
+++ b/apps/bff/src/logic/oap/service-topology.ts
@@ -145,10 +145,15 @@ export interface BuildServiceTopologyInput {
   /** Layer-overview only: the Service.group values the caller may read, when
    *  that is not every group. Neighbours still render as OAP returns them. */
   seedGroups?: ReadonlySet<string>;
+  /** Of the services drawn, the ones whose values the caller may see; absent
+   *  when it may see every one. The rest are drawn, but their metrics are not
+   *  read: a node keeps only its name, and a call keeps its values when the
+   *  caller reads either end. */
+  readableOf?: (ids: readonly string[]) => Promise<ReadonlySet<string>>;
 }
 
 export async function buildServiceTopology(input: BuildServiceTopologyInput): Promise<TopologyResponse> {
-  const { opts, perf, window, coldStage, cfg: topoCfg, layerKey, serviceArg, depth, group, seedGroups } = input;
+  const { opts, perf, window, coldStage, cfg: topoCfg, layerKey, serviceArg, depth, group, seedGroups, readableOf } = input;
   const oapLayer = layerKey.toUpperCase();
   const durationVar = coldStage
     ? { start: window.start, end: window.end, step: window.step, coldStage: true }
@@ -226,10 +231,16 @@ export async function buildServiceTopology(input: BuildServiceTopologyInput): Pr
     } satisfies TopologyResponse;
   }
 
+  // Every service drawn is asked about at once, virtual ones included: a call
+  // is open when the caller reads either end, whatever that end is.
+  const readable = readableOf ? await readableOf([...nodes.keys()]) : null;
+  const withheld = (id: string): boolean => readable !== null && !readable.has(id);
+  const callOpen = (c: OapTopoCall): boolean => !withheld(c.source) || !withheld(c.target);
+
   // ── Per-node MQE. Builds fragments off the layer's
   // `topology.nodeMetrics`. Synthetic nodes (User / external) are
   // skipped since OAP has no metrics for them.
-  const realNodes = [...nodes.values()].filter((n) => n.isReal);
+  const realNodes = [...nodes.values()].filter((n) => n.isReal && !withheld(n.id));
   const nodeMetricVals = new Map<string, Record<string, number | null>>();
   const serverMetricVals = new Map<string, Record<string, number | null>>();
   const clientMetricVals = new Map<string, Record<string, number | null>>();
@@ -269,7 +280,7 @@ export async function buildServiceTopology(input: BuildServiceTopologyInput): Pr
   const candidateEdges = [...calls.values()].filter((c) => {
     const a = nodes.get(c.source);
     const b = nodes.get(c.target);
-    return !!a && !!b && !!a.name && !!b.name;
+    return !!a && !!b && !!a.name && !!b.name && callOpen(c);
   });
   const linkSrv = topoCfg.linkServerMetrics ?? [];
   const linkCli = topoCfg.linkClientMetrics ?? [];
@@ -370,6 +381,7 @@ export async function buildServiceTopology(input: BuildServiceTopologyInput): Pr
       layers: n.layers ?? [],
       metrics: filled,
       ...legacyNodeView(filled),
+      ...(n.isReal && withheld(n.id) ? { metricsBlocked: true } : {}),
     });
   }
   // Re-prune edges whose endpoint(s) were dropped.
@@ -403,6 +415,7 @@ export async function buildServiceTopology(input: BuildServiceTopologyInput): Pr
       serverMetricSeries: filledSrvSeries,
       clientMetricSeries: filledCliSeries,
       ...legacyEdgeView(filledSrv, filledCli),
+      ...(callOpen(c) ? {} : { metricsBlocked: true }),
     });
   }
 
diff --git a/apps/bff/src/logic/services/service-identity.ts b/apps/bff/src/logic/services/service-identity.ts
index b92289e8..7e0b08be 100644
--- a/apps/bff/src/logic/services/service-identity.ts
+++ b/apps/bff/src/logic/services/service-identity.ts
@@ -22,8 +22,9 @@
  * Built from the shared service catalog, indexed by exact id — never by the
  * catalog's lower-cased, last-wins name map, which is a display aid. A miss
  * (a service registered since the last refresh, or a catalog that could not
- * be read) asks OAP for that one service; a service OAP does not know is
- * `null`, and the caller refuses.
+ * be read) asks OAP for that one service, and OAP's answer is held until the
+ * catalog is next read; a service OAP does not know is `null`, and the caller
+ * refuses.
  */
 
 import type { FetchLike } from '@skywalking-horizon-ui/api-client';
@@ -65,6 +66,23 @@ export interface Index {
 
 const indexes = new WeakMap<ServiceCatalog, Index>();
 
+/** What OAP answered for an id a trusted snapshot lacks, held for that
+ *  snapshot's life: the other id of a name, which almost never exists, costs
+ *  one OAP read per catalog refresh rather than one per request. Only answers
+ *  are held — a lookup that failed or was cancelled is not. */
+const beyondSnapshot = new WeakMap<ServiceCatalog, Map<string, ResolvedService | null>>();
+
+function heldFor(snapshot: ServiceCatalog): Map<string, ResolvedService | null> {
+  let held = beyondSnapshot.get(snapshot);
+  if (!held) beyondSnapshot.set(snapshot, (held = new Map()));
+  return held;
+}
+
+/** Ids asked of OAP per request by {@link ServiceIdentityResolver.prefetch},
+ *  and how many of those requests are in flight at once. */
+const PREFETCH_CHUNK = 50;
+const PREFETCH_PARALLEL = 4;
+
 /** How long rows kept through failed refreshes still decide access. Past it
  *  they are re-checked with OAP, so a service that has since moved into a layer
  *  the caller cannot read is not granted on old membership. */
@@ -153,6 +171,23 @@ interface RawService {
   layers?: string[] | null;
 }
 
+function servicesQuery(n: number): string {
+  const vars = Array.from({ length: n }, (_, i) => `$i${i}: String!`).join(', ');
+  const fields = Array.from({ length: n }, (_, i) => `s${i}: getService(serviceId: $i${i}) { id name normal group layers }`);
+  return `query HorizonAccessServices(${vars}) {\n  ${fields.join('\n  ')}\n}`;
+}
+
+function resolvedOf(s: RawService | null | undefined, id: string): ResolvedService | null {
+  if (!s || s.id !== id) return null;
+  return {
+    id: s.id,
+    name: s.name,
+    normal: s.normal === true ? true : s.normal === false ? false : null,
+    group: s.group ?? '',
+    layers: [...new Set((s.layers ?? []).map(canonicalLayerKey))],
+  };
+}
+
 export class ServiceIdentityResolver {
   constructor(private readonly deps: ServiceIdentityDeps) {}
 
@@ -161,9 +196,11 @@ export class ServiceIdentityResolver {
    *  through failed refreshes decide access only while recent. */
   async byId(id: string, signal?: AbortSignal): Promise<ResolvedService | null> {
     const snapshot = await this.deps.catalog.get();
-    if (trusted(snapshot)) {
+    const held = trusted(snapshot) ? heldFor(snapshot) : null;
+    if (held) {
       const hit = indexOf(snapshot).byId.get(id);
       if (hit) return hit;
+      if (held.has(id)) return held.get(id)!;
     }
     const opts = buildOapOpts(this.deps.config.current, this.deps.fetch, signal);
     let got: { service: RawService | null };
@@ -172,15 +209,43 @@ export class ServiceIdentityResolver {
     } catch (err) {
       throw new ServiceLookupUnavailable(err);
     }
-    const s = got.service;
-    if (!s || s.id !== id) return null;
-    return {
-      id: s.id,
-      name: s.name,
-      normal: s.normal === true ? true : s.normal === false ? false : null,
-      group: s.group ?? '',
-      layers: [...new Set((s.layers ?? []).map(canonicalLayerKey))],
+    const s = resolvedOf(got.service, id);
+    held?.set(id, s);
+    return s;
+  }
+
+  /**
+   * Asks OAP, a chunk of ids per request, about every id a trusted snapshot
+   * neither lists nor holds an answer for, and holds the answers — so a caller
+   * about to decide many rows asks once instead of once per row. Best effort:
+   * an id it could not settle is asked again, and reported, by the lookup
+   * that needs it.
+   */
+  async prefetch(ids: readonly string[], signal?: AbortSignal): Promise<void> {
+    const snapshot = await this.deps.catalog.get();
+    if (!trusted(snapshot)) return;
+    const idx = indexOf(snapshot);
+    const held = heldFor(snapshot);
+    const missing = [...new Set(ids)].filter((id) => !idx.byId.has(id) && !held.has(id));
+    const opts = buildOapOpts(this.deps.config.current, this.deps.fetch, signal);
+    const ask = async (chunk: string[]): Promise<void> => {
+      let got: Record<string, RawService | null>;
+      try {
+        got = await graphqlPost<Record<string, RawService | null>>(
+          opts,
+          servicesQuery(chunk.length),
+          Object.fromEntries(chunk.map((id, j) => [`i${j}`, id])),
+        );
+      } catch {
+        return;
+      }
+      chunk.forEach((id, j) => {
+        if (got && `s${j}` in got) held.set(id, resolvedOf(got[`s${j}`], id));
+      });
     };
+    const chunks: string[][] = [];
+    for (let i = 0; i < missing.length; i += PREFETCH_CHUNK) chunks.push(missing.slice(i, i + PREFETCH_CHUNK));
+    for (let i = 0; i < chunks.length; i += PREFETCH_PARALLEL) await Promise.all(chunks.slice(i, i + PREFETCH_PARALLEL).map(ask));
   }
 
   /**
@@ -194,21 +259,16 @@ export class ServiceIdentityResolver {
       return one ? [one] : null;
     }
     const snapshot = await this.deps.catalog.get();
-    if (trusted(snapshot)) {
-      // Both ids the name addresses, as well as the rows carrying it: OAP lists
-      // its blank-named service with an empty name, and files it under `_blank`
-      // — the same ids a service literally named `_blank` has.
-      const idx = indexOf(snapshot);
-      const known = new Map((idx.byName.get(name) ?? []).map((s) => [s.id, s]));
-      for (const id of [serviceIdOf(name, true), serviceIdOf(name, false)]) {
-        const s = idx.byId.get(id);
-        if (s) known.set(id, s);
-      }
-      if (known.size > 0) return [...known.values()];
-    }
-    const found = (
-      await Promise.all([this.byId(serviceIdOf(name, true), signal), this.byId(serviceIdOf(name, false), signal)])
-    ).filter((s): s is ResolvedService => s !== null);
-    return found.length > 0 ? found : null;
+    // The rows carrying the name: OAP lists its blank-named service with an
+    // empty name, and files it under `_blank` — the same ids a service
+    // literally named `_blank` has.
+    const known = new Map<string, ResolvedService>();
+    if (trusted(snapshot)) for (const s of indexOf(snapshot).byName.get(name) ?? []) known.set(s.id, s);
+    // Both ids the name addresses, each from the catalog or, where it holds
+    // none, from OAP: a namesake registered since the catalog's last read is
+    // still one of the services the name can mean.
+    const ids = [serviceIdOf(name, true), serviceIdOf(name, false)].filter((id) => !known.has(id));
+    for (const s of await Promise.all(ids.map((id) => this.byId(id, signal)))) if (s) known.set(s.id, s);
+    return known.size > 0 ? [...known.values()] : null;
   }
 }
diff --git a/apps/bff/src/rbac/request-access.test.ts b/apps/bff/src/rbac/request-access.test.ts
index d984a2df..30fa57b4 100644
--- a/apps/bff/src/rbac/request-access.test.ts
+++ b/apps/bff/src/rbac/request-access.test.ts
@@ -41,15 +41,38 @@ function viewer(snapshot: ServiceCatalog, fetch: FetchLike): RequestAccess {
   return new RequestAccess(new SessionAccess(['metrics:read'], undefined, facts), services);
 }
 
-const empty: ServiceCatalog = { layers: [], byLayer: new Map(), byName: new Map() };
+/** A fresh catalog per call: what OAP answered beyond a catalog is held for
+ *  that catalog's life. */
+const empty = (): ServiceCatalog => ({ layers: [], byLayer: new Map(), byName: new Map() });
 
 describe('a service lookup that could not be answered', () => {
   it('refuses rather than taking "unavailable" as "unknown"', async () => {
-    expect(await viewer(empty, oap('down')).allows(['metrics:read'], { id: BDB.id })).toBe(false);
+    expect(await viewer(empty(), oap('down')).allows(['metrics:read'], { id: BDB.id })).toBe(false);
   });
 
   it('still lets a plain verb name a service OAP answers it does not know', async () => {
-    expect(await viewer(empty, oap('none')).allows(['metrics:read'], { id: BDB.id })).toBe(true);
+    expect(await viewer(empty(), oap('none')).allows(['metrics:read'], { id: BDB.id })).toBe(true);
+  });
+});
+
+describe('a name the catalog knows only one service by', () => {
+  // A fresh catalog lists the normal `showcase-banyandb` in GENERAL; the
+  // conjectured namesake OAP registered since is in BANYANDB.
+  const fresh = (): ServiceCatalog => ({ layers: ['GENERAL'], byLayer: new Map([['GENERAL', [BDB]]]), byName: new Map() });
+  const namesake: FetchLike = async (_url, init) => {
+    const { variables } = JSON.parse(String(init?.body ?? '{}')) as { variables: { id: string } };
+    const service = variables.id === serviceIdOf(BDB.name, false) ? { ...BDB, id: variables.id, normal: false, layers: ['BANYANDB'] } : null;
+    return new Response(JSON.stringify({ data: { service } }), { status: 200, headers: { 'content-type': 'application/json' } });
+  };
+
+  it('asks OAP for the other id, and refuses the name when the namesake is out of reach', async () => {
+    expect(await viewer(fresh(), namesake).allows(['metrics:read'], { name: BDB.name })).toBe(false);
+    expect(await viewer(fresh(), oap('none')).allows(['metrics:read'], { name: BDB.name })).toBe(true);
+    expect(await viewer(fresh(), oap('down')).decide(['metrics:read'], { name: BDB.name })).toBe('unavailable');
+  });
+
+  it('does not ask when the name comes with its normal flag', async () => {
+    expect(await viewer(fresh(), oap('down')).allows(['metrics:read'], { name: BDB.name, normal: true })).toBe(true);
   });
 });
 
@@ -83,8 +106,8 @@ describe('a catalog kept through a failed refresh', () => {
 
 describe('an empty identity', () => {
   it('reads every service for a plain verb, as before, and nothing for a layer-limited one', async () => {
-    expect(await viewer(empty, oap('down')).allows(['metrics:read'], { id: '' })).toBe(true);
-    const catalog = { get: async () => empty } as unknown as ServiceLayerCatalog;
+    expect(await viewer(empty(), oap('down')).allows(['metrics:read'], { id: '' })).toBe(true);
+    const catalog = { get: async () => empty() } as unknown as ServiceLayerCatalog;
     const services = new ServiceIdentityResolver({ config, fetch: oap('down'), catalog });
     const limited = new RequestAccess(new SessionAccess(['metrics:read@GENERAL'], undefined, facts), services);
     expect(await limited.allows(['metrics:read'], { id: '' })).toBe(false);
@@ -103,14 +126,14 @@ describe('an identity OAP reads exactly as written', () => {
 describe('filtering the rows of a read that named no service', () => {
   it('reports an ownership lookup OAP could not answer instead of dropping the row', async () => {
     const rows = [{ serviceId: BDB.id }];
-    await expect(viewer(empty, oap('down')).keepReadable(['metrics:read'], rows, (r) => ({ id: r.serviceId }))).rejects.toBeInstanceOf(
+    await expect(viewer(empty(), oap('down')).keepReadable(['metrics:read'], rows, (r) => ({ id: r.serviceId }))).rejects.toBeInstanceOf(
       ServiceLookupUnavailable,
     );
-    expect(await viewer(empty, oap('none')).keepReadable(['metrics:read'], rows, (r) => ({ id: r.serviceId }))).toEqual(rows);
+    expect(await viewer(empty(), oap('none')).keepReadable(['metrics:read'], rows, (r) => ({ id: r.serviceId }))).toEqual(rows);
   });
 
   it('asks nothing for a caller who reads every layer', async () => {
-    const catalog = { get: async () => empty } as unknown as ServiceLayerCatalog;
+    const catalog = { get: async () => empty() } as unknown as ServiceLayerCatalog;
     const services = new ServiceIdentityResolver({ config, fetch: oap('down'), catalog });
     const everyLayer = new RequestAccess(new SessionAccess(['metrics:read', 'cluster:read'], undefined, facts), services);
     const rows = [{ serviceId: BDB.id }];
diff --git a/apps/bff/src/rbac/request-access.ts b/apps/bff/src/rbac/request-access.ts
index e8014e72..b8954380 100644
--- a/apps/bff/src/rbac/request-access.ts
+++ b/apps/bff/src/rbac/request-access.ts
@@ -194,6 +194,41 @@ export class RequestAccess {
     return found && found.length > 0 ? { kind: 'found', services: found } : { kind: 'unknown' };
   }
 
+  /** Asks OAP at once about every service `refs` name that the catalog does
+   *  not list, before many rows are decided one by one. Best effort: what it
+   *  could not settle is asked, and reported, by the decision that needs it. */
+  async prefetch(refs: readonly ServiceRef[]): Promise<void> {
+    const ids: string[] = [];
+    for (const ref of refs) {
+      if ('id' in ref) {
+        if (!isBlankToOap(ref.id)) ids.push(ref.id);
+      } else if ('name' in ref) {
+        if (ref.normal !== undefined) ids.push(serviceIdOf(ref.name, ref.normal));
+        else ids.push(serviceIdOf(ref.name, true), serviceIdOf(ref.name, false));
+      } else if ('childId' in ref) {
+        const owner = isBlankToOap(ref.childId) ? null : serviceIdOfChild(ref.childId);
+        if (owner) ids.push(owner);
+      }
+    }
+    await this.services.prefetch(ids, this.signal);
+  }
+
+  /** Of `ids`, the services the caller may read with any of `verbs`, OAP asked
+   *  at once about those the catalog lacks. One OAP could not answer about is
+   *  left out: a graph then draws it without its values. */
+  async readableIds(verbs: readonly Verb[], ids: readonly string[]): Promise<Set<string>> {
+    await this.prefetch(ids.map((id): ServiceRef => ({ id })));
+    const out = new Set<string>();
+    for (const id of ids) if ((await this.decide(verbs, { id })) === 'allow') out.add(id);
+    return out;
+  }
+
+  /** For a graph builder: the check that tells which drawn services the
+   *  caller may see the values of — none to pass when it may see every one. */
+  graphReadable(verbs: readonly Verb[]): { readableOf?: (ids: readonly string[]) => Promise<ReadonlySet<string>> } {
+    return this.readsEveryLayer(verbs) ? {} : { readableOf: (ids) => this.readableIds(verbs, ids) };
+  }
+
   /** May the caller read this service with any of `verbs`? A name that can
    *  mean several services must be allowed for every one of them. */
   async allows(verbs: readonly Verb[], ref: ServiceRef): Promise<boolean> {
diff --git a/apps/ui/src/api/permissionDenied.test.ts b/apps/ui/src/api/permissionDenied.test.ts
index 371f5eab..a6ad6f12 100644
--- a/apps/ui/src/api/permissionDenied.test.ts
+++ b/apps/ui/src/api/permissionDenied.test.ts
@@ -31,6 +31,8 @@ describe('a refused data read', () => {
     expect(errorText(err)).toMatch(/^Pick a service/);
     expect(permissionDeniedText(refused({ error: 'permission_denied', verb: 'metrics:read', reason: 'layer_not_granted', layer: 'banyandb' })))
       .toBe('You do not have access to the BANYANDB layer.');
+    expect(permissionDeniedText(refused({ error: 'permission_denied', reason: 'group_not_granted', layer: 'general', group: 'risk' })))
+      .toBe('You do not have access to the risk services of the GENERAL layer.');
   });
 
   it('keeps today\'s wording for a refusal without a reason, and for any other failure', () => {
diff --git a/apps/ui/src/api/permissionDenied.ts b/apps/ui/src/api/permissionDenied.ts
index 8a16776d..5008cfbc 100644
--- a/apps/ui/src/api/permissionDenied.ts
+++ b/apps/ui/src/api/permissionDenied.ts
@@ -28,6 +28,7 @@ export interface PermissionDenied {
   verb?: string;
   reason?: string;
   layer?: string;
+  group?: string;
 }
 
 function str(v: unknown): string | undefined {
@@ -38,7 +39,7 @@ export function permissionDeniedOf(status: number, body: unknown): PermissionDen
   if (status !== 403 || !body || typeof body !== 'object') return null;
   const o = body as Record<string, unknown>;
   if (o.error !== 'permission_denied') return null;
-  return { verb: str(o.verb), reason: str(o.reason), layer: str(o.layer) };
+  return { verb: str(o.verb), reason: str(o.reason), layer: str(o.layer), group: str(o.group) };
 }
 
 /** Null for a refusal without a reason — the plain missing-verb 403, whose
@@ -55,6 +56,11 @@ export function describeRefusal(d: PermissionDenied): string | null {
       return d.layer
         ? t('You do not have access to the {layer} layer.', { layer: d.layer.toUpperCase() })
         : t('You do not have access to this layer.');
+    case 'group_not_granted':
+      return t('You do not have access to the {group} services of the {layer} layer.', {
+        group: d.group ?? '',
+        layer: (d.layer ?? '').toUpperCase(),
+      });
     case 'id_names_no_service':
     case 'segment_ids_name_no_service':
     case 'schedule_ids_name_no_service':
@@ -88,6 +94,14 @@ export function permissionDeniedText(err: unknown): string | null {
   return d ? describeRefusal(d) : null;
 }
 
+/** The sentence for any refused BFF read — with a reason, its own; without
+ *  one, a general one — or null when `err` is anything else. A refusal is an
+ *  answer, so a screen showing it offers no Retry. */
+export function refusalText(err: unknown): string | null {
+  if (!isPermissionDenied(err)) return null;
+  return permissionDeniedText(err) ?? i18n.global.t('Your role does not allow this read.');
+}
+
 /** `String(err)` for everything but a refusal with a reason, which reads as
  *  its sentence instead of `BffApiError: …`. */
 export function errorText(err: unknown): string {
diff --git a/apps/ui/src/api/scopes/layer.test.ts b/apps/ui/src/api/scopes/layer.test.ts
index 20ecb6c3..6669a157 100644
--- a/apps/ui/src/api/scopes/layer.test.ts
+++ b/apps/ui/src/api/scopes/layer.test.ts
@@ -239,10 +239,10 @@ describe('LayerApi.endpoints / instances — entity query params', () => {
 });
 
 describe('LayerApi.topology / endpointDependency', () => {
-  it('topology defaults depth=1, omits service param when undefined', async () => {
+  it('asks for the layer-wide map with no service and no depth: it holds every service already', async () => {
     const { bff, calls } = makeStub();
-    await new LayerApi(bff).topology('mesh');
-    expect(calls[0][1]).toBe('/api/layer/mesh/topology?depth=1');
+    await new LayerApi(bff).topology('mesh', [], 3);
+    expect(calls[0][1]).toBe('/api/layer/mesh/topology?');
   });
 
   // Multi-seed: the map sends every picked row's id AND name, so the BFS seeds
diff --git a/apps/ui/src/api/scopes/layer.ts b/apps/ui/src/api/scopes/layer.ts
index c04467f6..136b35bc 100644
--- a/apps/ui/src/api/scopes/layer.ts
+++ b/apps/ui/src/api/scopes/layer.ts
@@ -234,11 +234,13 @@ export class LayerApi {
     signal?: AbortSignal,
   ): Promise<TopologyResponse> {
     const qs = new URLSearchParams();
+    // Hops widen a focused map only: the layer-wide one holds every service
+    // of the layer already, and the BFF reads it one hop out.
     if (services && services.length > 0) {
       qs.set('serviceId', services.map((s) => s.id).join(','));
       qs.set('service', services.map((s) => s.name).join(','));
+      qs.set('depth', String(depth));
     }
-    qs.set('depth', String(depth));
     if (range) {
       qs.set('step', range.step);
       qs.set('startMs', String(range.startMs));
diff --git a/apps/ui/src/features/admin/alert-page/AlertPageSetupView.test.ts b/apps/ui/src/features/admin/alert-page/AlertPageSetupView.test.ts
index 301f7a79..3c1a3e64 100644
--- a/apps/ui/src/features/admin/alert-page/AlertPageSetupView.test.ts
+++ b/apps/ui/src/features/admin/alert-page/AlertPageSetupView.test.ts
@@ -71,6 +71,8 @@ let resyncs = 0;
  *  the write (`landed`) or before it showed it (`unseen`). */
 let saveReply: 'ok' | 'timeout-landed' | 'timeout-unseen' = 'ok';
 let disableStatus = 200;
+/** Every read of the stored pages fails — set once a test has mounted. */
+let readsFail = false;
 /** Requests to hold until the test lets them go. */
 let hold: { save?: Promise<void>; resync?: Promise<void> } = {};
 let roster: Array<{ id: string; name: string; normal: boolean; group: string | null }> = [];
@@ -92,6 +94,7 @@ beforeEach(() => {
   resyncs = 0;
   saveReply = 'ok';
   disableStatus = 200;
+  readsFail = false;
   hold = {};
   roster = [
     { id: 'a', name: 'payments::api', normal: true, group: 'payments' },
@@ -121,7 +124,12 @@ beforeEach(() => {
       if (path === '/api/menu') {
         return json({ layers: menuLayers, oap: { reachable: true } });
       }
-      if (path === '/api/configs/settings') return json({ alert: DEFAULT_CONTENT });
+      const readBack = ['/api/configs/settings', '/api/admin/templates/sync-status', '/api/admin/templates/resync'];
+      if (readsFail && readBack.includes(path)) return json({ error: 'oap_unreachable' }, 503);
+      if (path === '/api/configs/settings') {
+        const stored = rows.find((r) => r.key === 'default' && !r.remote.disabled);
+        return json({ alert: stored ? contentOf(stored) : DEFAULT_CONTENT });
+      }
       if (path === '/api/admin/templates/sync-status') return json(status());
       if (path === '/api/admin/templates/resync') {
         resyncs++;
@@ -403,6 +411,52 @@ describe('Alarm pages admin — save', () => {
   });
 });
 
+describe('Alarm pages admin — a save that cannot be read back', () => {
+  it('keeps the default page\'s edits, and says so, when its stored settings cannot be read again', async () => {
+    const w = await mountView();
+    await chooseLayer(w, 'MESH');
+    await clickButton(w, '.apin__btn', 'Add pin');
+    readsFail = true;
+    await saveButton(w).trigger('click');
+    await flushPromises();
+    expect(saves).toHaveLength(1);
+    expect(pinnedCodes(w)).toEqual(['GENERAL', 'MESH']);
+    expect(statusLine(w)).toMatch(/^Saved, but the stored page could not be read back/);
+  });
+
+  it('reads a named page back from the save\'s own answer', async () => {
+    const w = await mountView();
+    await clickRow(w, 'On-call');
+    await w.find('.ape__field--grow input').setValue('Payments on-call');
+    readsFail = true;
+    await saveButton(w).trigger('click');
+    await flushPromises();
+    expect((w.find('.ape__field--grow input').element as HTMLInputElement).value).toBe('Payments on-call');
+    expect(listLabels(w)).toContain('Payments on-call');
+    expect(statusLine(w)).toBe('saved · Payments on-call · 1 pinned');
+  });
+
+  it('drops a deleted page from the list by the delete\'s own answer', async () => {
+    const w = await mountView();
+    await clickRow(w, 'On-call');
+    await w.find('.aps__btn--danger').trigger('click');
+    await flushPromises();
+    readsFail = true;
+    await w.find('.dap__in').setValue('oncall');
+    await w.find('.dap__danger').trigger('click');
+    await flushPromises();
+    expect(listLabels(w)).not.toContain('On-call');
+  });
+
+  it('says a reset could not be read back, rather than that it took', async () => {
+    const w = await mountView();
+    readsFail = true;
+    w.findComponent(TemplateDiffModal).vm.$emit('reset');
+    await flushPromises();
+    expect(statusLine(w)).toMatch(/^Reset, but the stored page could not be read back/);
+  });
+});
+
 describe('Alarm pages admin — pins a group name cannot spell', () => {
   it('shows a group whose name a pin cannot write, but does not let it be picked', async () => {
     roster = [
diff --git a/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue b/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue
index e3a9df23..de55194e 100644
--- a/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue
+++ b/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue
@@ -138,8 +138,8 @@ const defaultDiverged = computed<boolean>(
 );
 const diffModalOpen = ref(false);
 async function onDiffReset(): Promise<void> {
-  await ed.afterReset();
-  ed.setFlash(t('OAP reset to bundled · reload to see header changes'));
+  if (await ed.afterReset()) ed.setFlash(t('OAP reset to bundled · reload to see header changes'));
+  else ed.setFlash(t('Reset, but the stored page could not be read back, so this page may still show the old values. Reload before editing again.'), 'err');
 }
 
 const statusText = computed<{ text: string; cls: string }>(() => {
diff --git a/apps/ui/src/features/admin/alert-page/useAlertPageEditor.ts b/apps/ui/src/features/admin/alert-page/useAlertPageEditor.ts
index 179eb14a..0545b03d 100644
--- a/apps/ui/src/features/admin/alert-page/useAlertPageEditor.ts
+++ b/apps/ui/src/features/admin/alert-page/useAlertPageEditor.ts
@@ -192,18 +192,37 @@ export function useAlertPageEditor(readOnly: Ref<boolean>) {
 
   /** After a write: every reader of the pages follows it — the sidebar and the
    *  Alarms page (`alarms/pages`), the badge and overview widget
-   *  (`alarms/config`), and this page's own sync badges (the bundle). */
-  async function afterWrite(): Promise<void> {
+   *  (`alarms/config`), and this page's own sync badges (the bundle).
+   *  `written` is the confirmed write's own answer, which already holds the
+   *  stored rows. False when what this editor shows could not be read back,
+   *  so it may still be from before the write. */
+  async function afterWrite(written?: TemplateSyncStatus): Promise<boolean> {
+    if (written) queryClient.setQueryData(ALERT_ROWS_QUERY_KEY, written);
+    let rowsRead = true;
     try {
       queryClient.setQueryData(ALERT_ROWS_QUERY_KEY, await bff.templateSync.resync());
     } catch {
-      await rowsQ.refetch();
+      rowsRead = written !== undefined || !(await rowsQ.refetch()).isError;
     }
     await refreshConfigBundle({ force: true });
-    await Promise.all([
-      queryClient.invalidateQueries({ queryKey: ALARMS_CONFIG_QUERY_KEY }),
+    const [config] = await Promise.allSettled([
+      queryClient.invalidateQueries({ queryKey: ALARMS_CONFIG_QUERY_KEY }, { throwOnError: true }),
       queryClient.invalidateQueries({ queryKey: ALARM_PAGES_QUERY_KEY }),
     ]);
+    return rowsRead && config.status === 'fulfilled';
+  }
+
+  /** Settles the editor on what OAP holds after a save of `d`: a new page
+   *  that shows is no longer pending, and the edits go only once the stored
+   *  page reads back as them — so a readback that failed never passes the
+   *  values from before the save off as saved. */
+  function settle(id: string, d: PageDraft, wasPending: boolean): boolean {
+    if (wasPending && named.value.some((p) => p.id === id)) pending.value = null;
+    else if (wasPending && pending.value?.id === id) pending.value = { ...pending.value, sent: namedContent(id, d) };
+    const saved = pending.value?.id === id ? null : savedDraft(id);
+    const landed = saved !== null && sameContent(contentOf(id, saved), contentOf(id, d));
+    if (landed) dropEdits(id);
+    return landed;
   }
 
   const WINDOW_LABELS = computed<Record<number, string>>(() => ({
@@ -239,13 +258,11 @@ export function useAlertPageEditor(readOnly: Ref<boolean>) {
           return;
         }
       }
-      await bff.templateSync.save(alertRowName(id), contentOf(id, d));
-      // Edits go only once the fresh rows are in, so the editor never shows
-      // the pre-save values in between.
-      await afterWrite();
-      if (wasPending) pending.value = null;
-      dropEdits(id);
-      if (!defaultPage && !named.value.some((p) => p.id === id)) selectedId.value = ALERT_DEFAULT_PAGE_ID;
+      await afterWrite(await bff.templateSync.save(alertRowName(id), contentOf(id, d)));
+      if (!settle(id, d, wasPending)) {
+        setFlash(t('Saved, but the stored page could not be read back, so your edits are kept. Reload to check it before editing again.'), 'err');
+        return;
+      }
       setFlash(
         defaultPage
           ? t('saved · {pinned} pinned · {window} · limit {limit}', {
@@ -262,11 +279,7 @@ export function useAlertPageEditor(readOnly: Ref<boolean>) {
         // that is stored now is no longer pending, and edits the stored page
         // already holds go.
         await afterWrite();
-        if (wasPending && named.value.some((p) => p.id === id)) pending.value = null;
-        else if (wasPending && pending.value?.id === id) pending.value = { ...pending.value, sent: namedContent(id, d) };
-        const saved = pending.value?.id === id ? null : savedDraft(id);
-        const landed = saved !== null && sameContent(contentOf(id, saved), contentOf(id, d));
-        if (landed) dropEdits(id);
+        const landed = settle(id, d, wasPending);
         setFlash(t('Refetched after timeout — the push may have completed; please verify.'), landed ? 'ok' : 'err');
       } else {
         setFlash(t('error: {msg}', { msg: pushErrorLines(err).join('; ') }), 'err');
@@ -281,10 +294,10 @@ export function useAlertPageEditor(readOnly: Ref<boolean>) {
     const title = named.value.find((p) => p.id === id)?.title || id;
     deleting.value = true;
     try {
-      await bff.templateSync.disable(alertRowName(id));
+      const written = await bff.templateSync.disable(alertRowName(id));
       dropEdits(id);
       if (selectedId.value === id) selectedId.value = ALERT_DEFAULT_PAGE_ID;
-      await afterWrite();
+      await afterWrite(written);
       setFlash(t('deleted · {title}', { title }));
       return true;
     } catch (err) {
@@ -296,12 +309,14 @@ export function useAlertPageEditor(readOnly: Ref<boolean>) {
   }
 
   /** The default page was reset to the bundled one (the diff modal wrote it):
-   *  every reader follows, as after a save, and its edits here go. */
-  async function afterReset(): Promise<void> {
+   *  every reader follows, as after a save, and its edits here go. False when
+   *  the reset page could not be read back. */
+  async function afterReset(): Promise<boolean> {
     resetting.value = true;
     try {
-      await afterWrite();
+      const readBack = await afterWrite();
       dropEdits(ALERT_DEFAULT_PAGE_ID);
+      return readBack;
     } finally {
       resetting.value = false;
     }
diff --git a/apps/ui/src/features/alarms/AlarmsView.vue b/apps/ui/src/features/alarms/AlarmsView.vue
index 712a36fe..8db00925 100644
--- a/apps/ui/src/features/alarms/AlarmsView.vue
+++ b/apps/ui/src/features/alarms/AlarmsView.vue
@@ -94,6 +94,9 @@ const listPins = ref<string[] | null>(null);
 const drawnPins = computed<readonly string[]>(() =>
   props.pageId && listPins.value ? listPins.value : alarmPage.pinnedLayers.value,
 );
+/** A named page's pins as the pages route serves them; empty on the default
+ *  page, whose rows and choices do not depend on its pins. */
+const servedPinsKey = computed<string>(() => (props.pageId ? alarmPage.pinnedLayers.value.join('|') : ''));
 
 /* Each page's starting window is applied once, when it is first known; after
  * that the operator's picker choice wins and a refetch of the pages must not
@@ -114,7 +117,7 @@ watch(
   { immediate: true },
 );
 
-const filters = useAlarmFilters(hasQueryAlarms, toRef(props, 'pageId'));
+const filters = useAlarmFilters(hasQueryAlarms, toRef(props, 'pageId'), servedPinsKey);
 /** A named page offers only its own layers to filter by. */
 const pageLayers = computed<string[] | null>(() =>
   props.pageId ? [...new Set(drawnPins.value.map((p) => canonicalPin(p)?.layer).filter((l): l is string => !!l))] : null,
@@ -176,7 +179,7 @@ const alarmsQuery = useQuery({
   queryKey: computed(() => [
     'alarms',
     props.pageId ?? '',
-    props.pageId ? alarmPage.pinnedLayers.value.join('|') : '',
+    servedPinsKey.value,
     startTime.value,
     endTime.value,
     applied.value.layer,
@@ -363,7 +366,7 @@ watch(
 // a layer or service picked under the old pins may be one it no longer offers,
 // and would narrow the new read to nothing.
 watch(
-  [() => props.pageId, () => (props.pageId ? alarmPage.pinnedLayers.value.join('|') : '')],
+  [() => props.pageId, servedPinsKey],
   ([id, pins], [prevId, prevPins]) => {
     if (id !== prevId || pins === prevPins) return;
     filters.clearFilters();
diff --git a/apps/ui/src/features/alarms/useAlarmFilters.test.ts b/apps/ui/src/features/alarms/useAlarmFilters.test.ts
index ef30a150..f04186d6 100644
--- a/apps/ui/src/features/alarms/useAlarmFilters.test.ts
+++ b/apps/ui/src/features/alarms/useAlarmFilters.test.ts
@@ -50,11 +50,11 @@ type Filters = ReturnType<typeof useAlarmFilters>;
 
 /** Mount the composable inside a throwaway component so vue-query has a
  *  provider + an owning scope, and hand the state machine back. */
-function mountFilters(hasQueryAlarms: Ref<boolean> = ref(true)): Filters {
+function mountFilters(hasQueryAlarms: Ref<boolean> = ref(true), pageId?: Ref<string | undefined>, pagePins?: Ref<string>): Filters {
   let filters!: Filters;
   const Host = defineComponent({
     setup() {
-      filters = useAlarmFilters(hasQueryAlarms);
+      filters = useAlarmFilters(hasQueryAlarms, pageId, pagePins);
       return () => h('div');
     },
   });
@@ -94,6 +94,18 @@ describe('normalFor — roster lookup', () => {
   });
 });
 
+describe('useAlarmFilters — a named page', () => {
+  it('reads the service choices again when the page is re-pinned', async () => {
+    const pins = ref('GENERAL[payments]');
+    const f = mountFilters(ref(true), ref('payments'), pins);
+    const before = services.mock.calls.length;
+    await pickLayer(f, 'GENERAL');
+    expect(services.mock.calls.length).toBe(before + 1);
+    pins.value = 'GENERAL[payments, risk]';
+    await vi.waitFor(() => expect(services.mock.calls.length).toBe(before + 2));
+  });
+});
+
 describe('useAlarmFilters — the picked service carries its normal flag', () => {
   it('starts out with no service and the normal default', () => {
     expect(emptyFilters()).toEqual({
diff --git a/apps/ui/src/features/alarms/useAlarmFilters.ts b/apps/ui/src/features/alarms/useAlarmFilters.ts
index 3f1d9e17..f37b9411 100644
--- a/apps/ui/src/features/alarms/useAlarmFilters.ts
+++ b/apps/ui/src/features/alarms/useAlarmFilters.ts
@@ -84,13 +84,19 @@ export interface AlarmFilters {
   clearFilters: () => void;
 }
 
-/** `pageId`: the named page whose pins bound the choices; absent on the default page. */
-export function useAlarmFilters(hasQueryAlarms: Ref<boolean>, pageId?: Ref<string | undefined>): AlarmFilters {
+/** `pageId`: the named page whose pins bound the choices; absent on the default
+ *  page. `pagePins`: those pins as served, so a re-pinned page reads its
+ *  choices again. */
+export function useAlarmFilters(
+  hasQueryAlarms: Ref<boolean>,
+  pageId?: Ref<string | undefined>,
+  pagePins?: Ref<string>,
+): AlarmFilters {
   const draft = ref<FilterValues>(emptyFilters());
   const applied = ref<FilterValues>(emptyFilters());
 
   const servicesQuery = useQuery({
-    queryKey: computed(() => ['alarms/services', draft.value.layer, pageId?.value ?? '']),
+    queryKey: computed(() => ['alarms/services', draft.value.layer, pageId?.value ?? '', pagePins?.value ?? '']),
     queryFn: () => bff.alarms.services(draft.value.layer, pageId?.value),
     enabled: computed(() => hasQueryAlarms.value && draft.value.layer.length > 0),
     staleTime: 30_000,
diff --git a/apps/ui/src/features/infra-3d/Infra3DScene.vue b/apps/ui/src/features/infra-3d/Infra3DScene.vue
index 53a7d12f..2ac6707f 100644
--- a/apps/ui/src/features/infra-3d/Infra3DScene.vue
+++ b/apps/ui/src/features/infra-3d/Infra3DScene.vue
@@ -60,7 +60,7 @@ import {
 import type { ServiceNamingRule } from '@skywalking-horizon-ui/api-client';
 import { levelForLayer, isLayerExcluded } from './composables/useInfra3dConfig';
 import { resolveServiceIdentity } from '@/utils/serviceName';
-import { useInfra3dAlarms, alarmKey } from './composables/useInfra3dAlarms';
+import { useInfra3dAlarms, cubeAlarmKey } from './composables/useInfra3dAlarms';
 import { useInfra3dMetrics, formatMetricValue } from './composables/useInfra3dMetrics';
 import { useSceneCamera } from './composables/useSceneCamera';
 import { useSceneEdges } from './composables/useSceneEdges';
@@ -171,7 +171,7 @@ const { alarmedKeys, alarmedNamesNoLayer } = useInfra3dAlarms();
 const alarmedNodes = computed(() =>
   visibleNodes.value.filter(
     (n) =>
-      alarmedKeys.value.has(alarmKey(n.node.layerKey, n.node.name)) ||
+      alarmedKeys.value.has(cubeAlarmKey(n.node.layerKey, n.node.name)) ||
       alarmedNamesNoLayer.value.has(n.node.name),
   ),
 );
diff --git a/apps/ui/src/features/infra-3d/composables/useInfra3dAlarms.test.ts b/apps/ui/src/features/infra-3d/composables/useInfra3dAlarms.test.ts
new file mode 100644
index 00000000..84e4fb47
--- /dev/null
+++ b/apps/ui/src/features/infra-3d/composables/useInfra3dAlarms.test.ts
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { describe, expect, it } from 'vitest';
+import { alarmKey, cubeAlarmKey } from './useInfra3dAlarms';
+
+describe('the key an alarm reddens a cube by', () => {
+  it('finds a cube of a layer split by service group under the layer alone', () => {
+    // An alarm is keyed by the plain layer; the cube by its sidebar entry.
+    expect(cubeAlarmKey('general~payments', 'payments::checkout')).toBe(alarmKey('GENERAL', 'payments::checkout'));
+    expect(cubeAlarmKey('general', 'payments::checkout')).toBe(alarmKey('GENERAL', 'payments::checkout'));
+    expect(cubeAlarmKey('general~payments', 'payments::checkout')).not.toBe(alarmKey('MESH', 'payments::checkout'));
+  });
+});
diff --git a/apps/ui/src/features/infra-3d/composables/useInfra3dAlarms.ts b/apps/ui/src/features/infra-3d/composables/useInfra3dAlarms.ts
index 66e193ac..bac58265 100644
--- a/apps/ui/src/features/infra-3d/composables/useInfra3dAlarms.ts
+++ b/apps/ui/src/features/infra-3d/composables/useInfra3dAlarms.ts
@@ -76,6 +76,13 @@ const lastUpdatedAt = ref<number | null>(null);
 export function alarmKey(layerKey: string, serviceName: string): string {
   return `${layerKey.toUpperCase()}\u0000${serviceName}`;
 }
+
+/** The key a cube is looked up by. A cube in a layer split by service group
+ *  sits under its entry's key (`general~payments`); an alarm is keyed by the
+ *  layer alone. */
+export function cubeAlarmKey(layerKey: string, serviceName: string): string {
+  return alarmKey(layerKey.split('~', 1)[0]!, serviceName);
+}
 const error = ref<string | null>(null);
 let timer: ReturnType<typeof setInterval> | null = null;
 let inflight: Promise<void> | null = null;
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index e085e8f6..3d33913a 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -292,7 +292,7 @@
   "Bundled-only metrics will be installed.": "Nur in der Bündelung enthaltene Metriken werden installiert.",
   "CONFIGURED": "KONFIGURIERT",
   "CURRENT": "AKTUELL",
-  "Callee outside this layer — no instance dashboard here.": "Aufgerufener außerhalb dieser Ebene — hier gibt es kein Instance-Dashboard.",
+  "Not in this page's service list (another layer or group, or outside your access) — no instance dashboard here.": "Nicht in der Dienstliste dieser Seite (anderer Layer oder andere Gruppe, oder außerhalb Ihres Zugriffs) — hier gibt es kein Instanz-Dashboard.",
   "Callees ({n})": "Aufgerufene ({n})",
   "Callers ({n})": "Aufrufer ({n})",
   "Calls": "Aufrufe",
@@ -925,7 +925,7 @@
   "Newest": "Neueste",
   "Next": "Weiter",
   "No API-dependency data was captured for {serviceName} in this window.": "In diesem Zeitfenster wurden keine API-Abhängigkeitsdaten für {serviceName} erfasst.",
-  "No Horizon layer template configured for {layer}": "Für {layer} ist keine Horizon-Ebenen-Vorlage konfiguriert",
+  "{layer} has no entry in your menu for this service: Horizon has no page for it, or your role cannot open it": "{layer} hat in Ihrem Menü keinen Eintrag für diesen Dienst: Horizon hat dafür keine Seite, oder Ihre Rolle darf sie nicht öffnen",
   "No KPI rows. Add one to surface a metric on the tile.": "Keine KPI-Zeilen. Fügen Sie eine hinzu, um eine Metrik auf der Kachel zu zeigen.",
   "No MQE snapshot was recorded with this alarm. Upgrade OAP or enable the snapshot capture in the alarm rule to see the trigger values here.": "Mit diesem Alarm wurde kein MQE-Snapshot aufgezeichnet. Aktualisieren Sie OAP oder aktivieren Sie die Snapshot-Erfassung in der Alarmregel, um die Auslösewerte hier zu sehen.",
   "No OAP layer matches": "Keine OAP-Ebene passt zu",
@@ -1309,6 +1309,8 @@
   "Records": "Datensätze",
   "Ref type": "Referenztyp",
   "Refetched after timeout — the push may have completed; please verify.": "Nach dem Timeout neu geladen — die Veröffentlichung könnte dennoch durchgelaufen sein; bitte prüfen.",
+  "Saved, but the stored page could not be read back, so your edits are kept. Reload to check it before editing again.": "Gespeichert, aber die gespeicherte Seite konnte nicht erneut gelesen werden. Ihre Änderungen bleiben daher erhalten. Laden Sie neu und prüfen Sie die Seite, bevor Sie weiter bearbeiten.",
+  "Reset, but the stored page could not be read back, so this page may still show the old values. Reload before editing again.": "Zurückgesetzt, aber die gespeicherte Seite konnte nicht erneut gelesen werden. Diese Seite zeigt daher möglicherweise noch die alten Werte. Laden Sie neu, bevor Sie weiter bearbeiten.",
   "Refetched after timeout — verify on OAP.": "Nach Zeitüberschreitung neu geladen — bitte auf OAP verifizieren.",
   "Refresh": "Aktualisieren",
   "Refresh service list": "Service-Liste aktualisieren",
@@ -3430,6 +3432,12 @@
   "You do not have access to this service.": "Sie haben keinen Zugriff auf diesen Service.",
   "You do not have access to the {layer} layer.": "Sie haben keinen Zugriff auf die Ebene {layer}.",
   "You do not have access to this layer.": "Sie haben keinen Zugriff auf diese Ebene.",
+  "blocked": "gesperrt",
+  "Metrics blocked: your role cannot read this service.": "Metriken gesperrt: Ihre Rolle darf diesen Dienst nicht lesen.",
+  "Metrics blocked: your role reads neither end of this call.": "Metriken gesperrt: Ihre Rolle darf keines der beiden Enden dieses Aufrufs lesen.",
+  "Your role does not allow this read.": "Ihre Rolle erlaubt diesen Abruf nicht.",
+  "The requested service is not in this page's list for your role: it is outside your access, or no longer reports here. Showing {name} instead.": "Der angeforderte Dienst steht für Ihre Rolle nicht in der Liste dieser Seite: Er liegt außerhalb Ihres Zugriffs oder meldet hier nicht mehr. Stattdessen wird {name} angezeigt.",
+  "You do not have access to the {group} services of the {layer} layer.": "Sie haben keinen Zugriff auf die {group}-Dienste des Layers {layer}.",
   "This read does not name a service, and you can read only some services.": "Diese Abfrage nennt keinen Service, und Sie dürfen nur einige Services lesen.",
   "Previewing a draft template needs the layer-template:write permission.": "Die Vorschau eines Vorlagenentwurfs erfordert die Berechtigung layer-template:write.",
   "Access denied ({reason}).": "Zugriff verweigert ({reason}).",
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 4e605c53..5540e1be 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -292,7 +292,7 @@
   "Bundled-only metrics will be installed.": "Bundled-only metrics will be installed.",
   "CONFIGURED": "CONFIGURED",
   "CURRENT": "CURRENT",
-  "Callee outside this layer — no instance dashboard here.": "Callee outside this layer — no instance dashboard here.",
+  "Not in this page's service list (another layer or group, or outside your access) — no instance dashboard here.": "Not in this page's service list (another layer or group, or outside your access) — no instance dashboard here.",
   "Callees ({n})": "Callees ({n})",
   "Callers ({n})": "Callers ({n})",
   "Calls": "Calls",
@@ -925,7 +925,7 @@
   "Newest": "Newest",
   "Next": "Next",
   "No API-dependency data was captured for {serviceName} in this window.": "No API-dependency data was captured for {serviceName} in this window.",
-  "No Horizon layer template configured for {layer}": "No Horizon layer template configured for {layer}",
+  "{layer} has no entry in your menu for this service: Horizon has no page for it, or your role cannot open it": "{layer} has no entry in your menu for this service: Horizon has no page for it, or your role cannot open it",
   "No KPI rows. Add one to surface a metric on the tile.": "No KPI rows. Add one to surface a metric on the tile.",
   "No MQE snapshot was recorded with this alarm. Upgrade OAP or enable the snapshot capture in the alarm rule to see the trigger values here.": "No MQE snapshot was recorded with this alarm. Upgrade OAP or enable the snapshot capture in the alarm rule to see the trigger values here.",
   "No OAP layer matches": "No OAP layer matches",
@@ -1309,6 +1309,8 @@
   "Records": "Records",
   "Ref type": "Ref type",
   "Refetched after timeout — the push may have completed; please verify.": "Refetched after timeout — the push may have completed; please verify.",
+  "Saved, but the stored page could not be read back, so your edits are kept. Reload to check it before editing again.": "Saved, but the stored page could not be read back, so your edits are kept. Reload to check it before editing again.",
+  "Reset, but the stored page could not be read back, so this page may still show the old values. Reload before editing again.": "Reset, but the stored page could not be read back, so this page may still show the old values. Reload before editing again.",
   "Refetched after timeout — verify on OAP.": "Refetched after timeout — verify on OAP.",
   "Refresh": "Refresh",
   "Refresh service list": "Refresh service list",
@@ -3430,6 +3432,12 @@
   "You do not have access to this service.": "You do not have access to this service.",
   "You do not have access to the {layer} layer.": "You do not have access to the {layer} layer.",
   "You do not have access to this layer.": "You do not have access to this layer.",
+  "blocked": "blocked",
+  "Metrics blocked: your role cannot read this service.": "Metrics blocked: your role cannot read this service.",
+  "Metrics blocked: your role reads neither end of this call.": "Metrics blocked: your role reads neither end of this call.",
+  "Your role does not allow this read.": "Your role does not allow this read.",
+  "The requested service is not in this page's list for your role: it is outside your access, or no longer reports here. Showing {name} instead.": "The requested service is not in this page's list for your role: it is outside your access, or no longer reports here. Showing {name} instead.",
+  "You do not have access to the {group} services of the {layer} layer.": "You do not have access to the {group} services of the {layer} layer.",
   "This read does not name a service, and you can read only some services.": "This read does not name a service, and you can read only some services.",
   "Previewing a draft template needs the layer-template:write permission.": "Previewing a draft template needs the layer-template:write permission.",
   "Access denied ({reason}).": "Access denied ({reason}).",
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 1308c00d..1d9c5641 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -292,7 +292,7 @@
   "Bundled-only metrics will be installed.": "Se instalarán las métricas que solo existen como integradas.",
   "CONFIGURED": "CONFIGURADO",
   "CURRENT": "ACTUAL",
-  "Callee outside this layer — no instance dashboard here.": "El destino está fuera de esta capa: aquí no hay panel de instancia.",
+  "Not in this page's service list (another layer or group, or outside your access) — no instance dashboard here.": "No está en la lista de servicios de esta página (otra capa u otro grupo, o fuera de tu acceso): aquí no hay dashboard de instancia.",
   "Callees ({n})": "Destinos ({n})",
   "Callers ({n})": "Emisores ({n})",
   "Calls": "Llamadas",
@@ -925,7 +925,7 @@
   "Newest": "Más recientes",
   "Next": "Siguiente",
   "No API-dependency data was captured for {serviceName} in this window.": "No se capturaron datos de dependencias de API para {serviceName} en esta ventana.",
-  "No Horizon layer template configured for {layer}": "No hay ninguna plantilla de capa de Horizon configurada para {layer}",
+  "{layer} has no entry in your menu for this service: Horizon has no page for it, or your role cannot open it": "{layer} no tiene una entrada en tu menú para este servicio: Horizon no tiene una página para él o tu rol no puede abrirla",
   "No KPI rows. Add one to surface a metric on the tile.": "No hay filas KPI. Agrega una para mostrar una métrica en la tarjeta.",
   "No MQE snapshot was recorded with this alarm. Upgrade OAP or enable the snapshot capture in the alarm rule to see the trigger values here.": "No se registró ninguna instantánea MQE con esta alarma. Actualiza OAP o habilita la captura de instantáneas en la regla de la alarma para ver aquí los valores del disparador.",
   "No OAP layer matches": "Ninguna capa de OAP coincide con",
@@ -1309,6 +1309,8 @@
   "Records": "Registros",
   "Ref type": "Tipo de referencia",
   "Refetched after timeout — the push may have completed; please verify.": "Recargado tras el tiempo de espera — puede que la publicación se haya completado; verifícalo.",
+  "Saved, but the stored page could not be read back, so your edits are kept. Reload to check it before editing again.": "Guardado, pero no se pudo volver a leer la página almacenada, así que se conservan tus cambios. Recarga para comprobarla antes de volver a editar.",
+  "Reset, but the stored page could not be read back, so this page may still show the old values. Reload before editing again.": "Restablecido, pero no se pudo volver a leer la página almacenada, así que esta página puede seguir mostrando los valores anteriores. Recarga antes de volver a editar.",
   "Refetched after timeout — verify on OAP.": "Recargado tras el tiempo de espera — verifica en OAP.",
   "Refresh": "Actualizar",
   "Refresh service list": "Actualizar la lista de servicios",
@@ -3430,6 +3432,12 @@
   "You do not have access to this service.": "No tienes acceso a este servicio.",
   "You do not have access to the {layer} layer.": "No tienes acceso a la capa {layer}.",
   "You do not have access to this layer.": "No tienes acceso a esta capa.",
+  "blocked": "bloqueado",
+  "Metrics blocked: your role cannot read this service.": "Métricas bloqueadas: tu rol no puede leer este servicio.",
+  "Metrics blocked: your role reads neither end of this call.": "Métricas bloqueadas: tu rol no puede leer ninguno de los dos extremos de esta llamada.",
+  "Your role does not allow this read.": "Tu rol no permite esta lectura.",
+  "The requested service is not in this page's list for your role: it is outside your access, or no longer reports here. Showing {name} instead.": "El servicio solicitado no está en la lista de esta página para tu rol: está fuera de tu acceso o ya no informa aquí. Se muestra {name} en su lugar.",
+  "You do not have access to the {group} services of the {layer} layer.": "No tienes acceso a los servicios {group} de la capa {layer}.",
   "This read does not name a service, and you can read only some services.": "Esta lectura no indica ningún servicio, y solo puedes leer algunos servicios.",
   "Previewing a draft template needs the layer-template:write permission.": "Previsualizar un borrador de plantilla requiere el permiso layer-template:write.",
   "Access denied ({reason}).": "Acceso denegado ({reason}).",
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 21cf55e0..8208a249 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -292,7 +292,7 @@
   "Bundled-only metrics will be installed.": "Les métriques uniquement intégrées seront installées.",
   "CONFIGURED": "CONFIGURÉ",
   "CURRENT": "ACTUEL",
-  "Callee outside this layer — no instance dashboard here.": "Appelé hors de cette couche — pas de tableau de bord d'instance ici.",
+  "Not in this page's service list (another layer or group, or outside your access) — no instance dashboard here.": "Absent de la liste des services de cette page (autre couche ou autre groupe, ou hors de votre accès) — pas de tableau de bord d’instance ici.",
   "Callees ({n})": "Appelés ({n})",
   "Callers ({n})": "Appelants ({n})",
   "Calls": "Appels",
@@ -925,7 +925,7 @@
   "Newest": "Plus récents",
   "Next": "Suivant",
   "No API-dependency data was captured for {serviceName} in this window.": "Aucune donnée de dépendance d'API n'a été capturée pour {serviceName} dans cette fenêtre.",
-  "No Horizon layer template configured for {layer}": "Aucun modèle de couche Horizon configuré pour {layer}",
+  "{layer} has no entry in your menu for this service: Horizon has no page for it, or your role cannot open it": "{layer} n’a pas d’entrée dans votre menu pour ce service : Horizon n’a pas de page pour lui, ou votre rôle ne peut pas l’ouvrir",
   "No KPI rows. Add one to surface a metric on the tile.": "Aucune ligne KPI. Ajoutez-en une pour faire apparaître une métrique sur la tuile.",
   "No MQE snapshot was recorded with this alarm. Upgrade OAP or enable the snapshot capture in the alarm rule to see the trigger values here.": "Aucun snapshot MQE n'a été enregistré avec cette alerte. Mettez à jour OAP ou activez la capture de snapshot dans la règle d'alerte pour voir ici les valeurs de déclenchement.",
   "No OAP layer matches": "Aucune couche OAP ne correspond à",
@@ -1309,6 +1309,8 @@
   "Records": "Enregistrements",
   "Ref type": "Type de référence",
   "Refetched after timeout — the push may have completed; please verify.": "Nouveau chargement après délai dépassé — la publication a peut-être abouti ; veuillez vérifier.",
+  "Saved, but the stored page could not be read back, so your edits are kept. Reload to check it before editing again.": "Enregistré, mais la page stockée n’a pas pu être relue : vos modifications sont donc conservées. Rechargez pour la vérifier avant de reprendre l’édition.",
+  "Reset, but the stored page could not be read back, so this page may still show the old values. Reload before editing again.": "Réinitialisé, mais la page stockée n’a pas pu être relue : cette page peut donc encore afficher les anciennes valeurs. Rechargez avant de reprendre l’édition.",
   "Refetched after timeout — verify on OAP.": "Nouveau chargement après délai dépassé — vérifiez sur OAP.",
   "Refresh": "Actualiser",
   "Refresh service list": "Actualiser la liste des services",
@@ -3430,6 +3432,12 @@
   "You do not have access to this service.": "Vous n’avez pas accès à ce service.",
   "You do not have access to the {layer} layer.": "Vous n’avez pas accès à la couche {layer}.",
   "You do not have access to this layer.": "Vous n’avez pas accès à cette couche.",
+  "blocked": "bloqué",
+  "Metrics blocked: your role cannot read this service.": "Métriques bloquées : votre rôle ne peut pas lire ce service.",
+  "Metrics blocked: your role reads neither end of this call.": "Métriques bloquées : votre rôle ne peut lire aucune des deux extrémités de cet appel.",
+  "Your role does not allow this read.": "Votre rôle n’autorise pas cette lecture.",
+  "The requested service is not in this page's list for your role: it is outside your access, or no longer reports here. Showing {name} instead.": "Le service demandé ne figure pas dans la liste de cette page pour votre rôle : il est hors de votre accès, ou ne remonte plus de données ici. {name} est affiché à la place.",
+  "You do not have access to the {group} services of the {layer} layer.": "Vous n’avez pas accès aux services {group} de la couche {layer}.",
   "This read does not name a service, and you can read only some services.": "Cette lecture ne désigne aucun service, et vous ne pouvez lire que certains services.",
   "Previewing a draft template needs the layer-template:write permission.": "Prévisualiser un brouillon de modèle nécessite la permission layer-template:write.",
   "Access denied ({reason}).": "Accès refusé ({reason}).",
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 17c4aac3..bc59e8dc 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -292,7 +292,7 @@
   "Bundled-only metrics will be installed.": "バンドル版のみのメトリクスがインストールされます。",
   "CONFIGURED": "CONFIGURED",
   "CURRENT": "現在",
-  "Callee outside this layer — no instance dashboard here.": "呼び出し先はこのレイヤーの外です — ここにインスタンスダッシュボードはありません。",
+  "Not in this page's service list (another layer or group, or outside your access) — no instance dashboard here.": "このページのサービス一覧にありません(別のレイヤーまたはグループ、あるいはアクセス権の範囲外)。ここにはインスタンスダッシュボードがありません。",
   "Callees ({n})": "呼び出し先 ({n})",
   "Callers ({n})": "呼び出し元 ({n})",
   "Calls": "呼び出し",
@@ -925,7 +925,7 @@
   "Newest": "新しい順",
   "Next": "次へ",
   "No API-dependency data was captured for {serviceName} in this window.": "この時間範囲では {serviceName} の API 依存データはキャプチャされませんでした。",
-  "No Horizon layer template configured for {layer}": "{layer} に対応する Horizon のレイヤーテンプレートが設定されていません",
+  "{layer} has no entry in your menu for this service: Horizon has no page for it, or your role cannot open it": "{layer} には、メニュー内にこのサービスの項目がありません。Horizon にそのページがないか、あなたのロールでは開けません",
   "No KPI rows. Add one to surface a metric on the tile.": "KPI 行がありません。追加すると、タイルにメトリックを表示できます。",
   "No MQE snapshot was recorded with this alarm. Upgrade OAP or enable the snapshot capture in the alarm rule to see the trigger values here.": "このアラームには MQE スナップショットが記録されていません。OAP をアップグレードするか、アラームルールでスナップショット取得を有効化すると、ここにトリガー値が表示されます。",
   "No OAP layer matches": "一致する OAP のレイヤーがありません:",
@@ -1309,6 +1309,8 @@
   "Records": "レコード",
   "Ref type": "参照タイプ",
   "Refetched after timeout — the push may have completed; please verify.": "タイムアウト後に再取得しました。公開は完了している可能性があります。内容を確認してください。",
+  "Saved, but the stored page could not be read back, so your edits are kept. Reload to check it before editing again.": "保存しましたが、保存されたページを読み直せなかったため、編集内容はそのまま残しています。再度編集する前に、再読み込みして内容を確認してください。",
+  "Reset, but the stored page could not be read back, so this page may still show the old values. Reload before editing again.": "リセットしましたが、保存されたページを読み直せなかったため、このページには古い値が表示されたままの可能性があります。再度編集する前に再読み込みしてください。",
   "Refetched after timeout — verify on OAP.": "タイムアウト後に再取得しました — OAP で確認してください。",
   "Refresh": "更新",
   "Refresh service list": "サービス一覧を更新",
@@ -3430,6 +3432,12 @@
   "You do not have access to this service.": "このサービスへのアクセス権がありません。",
   "You do not have access to the {layer} layer.": "レイヤー {layer} へのアクセス権がありません。",
   "You do not have access to this layer.": "このレイヤーへのアクセス権がありません。",
+  "blocked": "ブロック",
+  "Metrics blocked: your role cannot read this service.": "メトリクスはブロックされています。あなたのロールではこのサービスを読み取れません。",
+  "Metrics blocked: your role reads neither end of this call.": "メトリクスはブロックされています。あなたのロールではこの呼び出しのどちらの端も読み取れません。",
+  "Your role does not allow this read.": "あなたのロールでは、この読み取りは許可されていません。",
+  "The requested service is not in this page's list for your role: it is outside your access, or no longer reports here. Showing {name} instead.": "要求されたサービスは、あなたのロールではこのページの一覧にありません。アクセス権の範囲外か、ここでは報告が止まっています。代わりに {name} を表示しています。",
+  "You do not have access to the {group} services of the {layer} layer.": "{layer} レイヤーの {group} サービスへのアクセス権がありません。",
   "This read does not name a service, and you can read only some services.": "この読み取りはサービスを指定していませんが、あなたが読み取れるのは一部のサービスのみです。",
   "Previewing a draft template needs the layer-template:write permission.": "テンプレートの下書きをプレビューするには layer-template:write 権限が必要です。",
   "Access denied ({reason}).": "アクセスが拒否されました({reason})。",
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 062d5375..aebf9e85 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -292,7 +292,7 @@
   "Bundled-only metrics will be installed.": "번들 전용 메트릭이 설치됩니다.",
   "CONFIGURED": "CONFIGURED",
   "CURRENT": "현재",
-  "Callee outside this layer — no instance dashboard here.": "피호출 대상이 이 레이어 밖에 있습니다 — 여기에는 인스턴스 대시보드가 없습니다.",
+  "Not in this page's service list (another layer or group, or outside your access) — no instance dashboard here.": "이 페이지의 서비스 목록에 없습니다(다른 레이어나 그룹이거나 접근 권한 밖). 여기에는 인스턴스 대시보드가 없습니다.",
   "Callees ({n})": "피호출 ({n})",
   "Callers ({n})": "호출자 ({n})",
   "Calls": "호출 수",
@@ -925,7 +925,7 @@
   "Newest": "최신순",
   "Next": "다음",
   "No API-dependency data was captured for {serviceName} in this window.": "이 기간에는 {serviceName}의 API 의존성 데이터가 캡처되지 않았습니다.",
-  "No Horizon layer template configured for {layer}": "{layer}에 설정된 Horizon 레이어 템플릿이 없습니다",
+  "{layer} has no entry in your menu for this service: Horizon has no page for it, or your role cannot open it": "{layer}에는 메뉴에 이 서비스 항목이 없습니다. Horizon에 해당 페이지가 없거나 역할에 열 권한이 없습니다",
   "No KPI rows. Add one to surface a metric on the tile.": "KPI 행이 없습니다. 타일에 메트릭을 표시하려면 행을 추가하세요.",
   "No MQE snapshot was recorded with this alarm. Upgrade OAP or enable the snapshot capture in the alarm rule to see the trigger values here.": "이 경보에는 기록된 MQE 스냅샷이 없습니다. 트리거 값을 여기에서 보려면 OAP를 업그레이드하거나 경보 규칙에서 스냅샷 캡처를 활성화하세요.",
   "No OAP layer matches": "일치하는 OAP 레이어가 없습니다:",
@@ -1309,6 +1309,8 @@
   "Records": "레코드",
   "Ref type": "참조 유형",
   "Refetched after timeout — the push may have completed; please verify.": "시간 초과 후 다시 조회했습니다 — 게시가 완료되었을 수 있으니 확인하세요.",
+  "Saved, but the stored page could not be read back, so your edits are kept. Reload to check it before editing again.": "저장했지만 저장된 페이지를 다시 읽지 못해 편집 내용을 그대로 두었습니다. 다시 편집하기 전에 새로고침해서 확인하세요.",
+  "Reset, but the stored page could not be read back, so this page may still show the old values. Reload before editing again.": "초기화했지만 저장된 페이지를 다시 읽지 못해 이 페이지에 이전 값이 계속 표시될 수 있습니다. 다시 편집하기 전에 새로고침하세요.",
   "Refetched after timeout — verify on OAP.": "시간 초과 후 다시 가져왔습니다 — OAP에서 확인하세요.",
   "Refresh": "새로 고침",
   "Refresh service list": "서비스 목록 새로 고침",
@@ -3430,6 +3432,12 @@
   "You do not have access to this service.": "이 서비스에 대한 접근 권한이 없습니다.",
   "You do not have access to the {layer} layer.": "{layer} 레이어에 대한 접근 권한이 없습니다.",
   "You do not have access to this layer.": "이 레이어에 대한 접근 권한이 없습니다.",
+  "blocked": "차단됨",
+  "Metrics blocked: your role cannot read this service.": "메트릭 차단됨: 역할에 이 서비스를 읽을 권한이 없습니다.",
+  "Metrics blocked: your role reads neither end of this call.": "메트릭 차단됨: 역할에 이 호출의 어느 쪽 끝도 읽을 권한이 없습니다.",
+  "Your role does not allow this read.": "역할에 이 읽기 권한이 없습니다.",
+  "The requested service is not in this page's list for your role: it is outside your access, or no longer reports here. Showing {name} instead.": "요청한 서비스가 역할 기준으로 이 페이지 목록에 없습니다. 접근 권한 밖이거나 여기서 더 이상 보고되지 않습니다. 대신 {name}을(를) 표시합니다.",
+  "You do not have access to the {group} services of the {layer} layer.": "{layer} 레이어의 {group} 서비스에 접근할 권한이 없습니다.",
   "This read does not name a service, and you can read only some services.": "이 조회는 서비스를 지정하지 않지만, 읽을 수 있는 서비스는 일부뿐입니다.",
   "Previewing a draft template needs the layer-template:write permission.": "템플릿 초안을 미리 보려면 layer-template:write 권한이 필요합니다.",
   "Access denied ({reason}).": "접근이 거부되었습니다({reason}).",
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 7a51e48f..10df66a5 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -292,7 +292,7 @@
   "Bundled-only metrics will be installed.": "Métricas presentes apenas no embutido serão instaladas.",
   "CONFIGURED": "CONFIGURADO",
   "CURRENT": "ATUAL",
-  "Callee outside this layer — no instance dashboard here.": "Chamado fora desta camada — não há dashboard de instância aqui.",
+  "Not in this page's service list (another layer or group, or outside your access) — no instance dashboard here.": "Não está na lista de serviços desta página (outra camada ou grupo, ou fora do seu acesso) — sem dashboard de instância aqui.",
   "Callees ({n})": "Chamados ({n})",
   "Callers ({n})": "Chamadores ({n})",
   "Calls": "Chamadas",
@@ -925,7 +925,7 @@
   "Newest": "Mais recentes",
   "Next": "Próximo",
   "No API-dependency data was captured for {serviceName} in this window.": "Nenhum dado de dependência de API foi capturado para {serviceName} nesta janela.",
-  "No Horizon layer template configured for {layer}": "Nenhum modelo de camada do Horizon configurado para {layer}",
+  "{layer} has no entry in your menu for this service: Horizon has no page for it, or your role cannot open it": "{layer} não tem uma entrada no seu menu para este serviço: o Horizon não tem uma página para ele, ou a sua função não pode abri-la",
   "No KPI rows. Add one to surface a metric on the tile.": "Nenhuma linha de KPI. Adicione uma para exibir uma métrica no tile.",
   "No MQE snapshot was recorded with this alarm. Upgrade OAP or enable the snapshot capture in the alarm rule to see the trigger values here.": "Nenhum snapshot de MQE foi registrado com este alarme. Atualize o OAP ou habilite a captura de snapshot na regra de alarme para ver os valores de disparo aqui.",
   "No OAP layer matches": "Nenhuma camada do OAP corresponde a",
@@ -1309,6 +1309,8 @@
   "Records": "Registros",
   "Ref type": "Tipo de referência",
   "Refetched after timeout — the push may have completed; please verify.": "Recarregado após o timeout — a publicação pode ter sido concluída; confira.",
+  "Saved, but the stored page could not be read back, so your edits are kept. Reload to check it before editing again.": "Salvo, mas não foi possível reler a página armazenada, então suas edições foram mantidas. Recarregue para conferir antes de editar de novo.",
+  "Reset, but the stored page could not be read back, so this page may still show the old values. Reload before editing again.": "Redefinido, mas não foi possível reler a página armazenada, então esta página ainda pode mostrar os valores antigos. Recarregue antes de editar de novo.",
   "Refetched after timeout — verify on OAP.": "Recarregado após o tempo esgotado — verifique no OAP.",
   "Refresh": "Atualizar",
   "Refresh service list": "Atualizar a lista de serviços",
@@ -3430,6 +3432,12 @@
   "You do not have access to this service.": "Você não tem acesso a este serviço.",
   "You do not have access to the {layer} layer.": "Você não tem acesso à camada {layer}.",
   "You do not have access to this layer.": "Você não tem acesso a esta camada.",
+  "blocked": "bloqueado",
+  "Metrics blocked: your role cannot read this service.": "Métricas bloqueadas: a sua função não pode ler este serviço.",
+  "Metrics blocked: your role reads neither end of this call.": "Métricas bloqueadas: a sua função não pode ler nenhuma das pontas desta chamada.",
+  "Your role does not allow this read.": "A sua função não permite esta leitura.",
+  "The requested service is not in this page's list for your role: it is outside your access, or no longer reports here. Showing {name} instead.": "O serviço solicitado não está na lista desta página para a sua função: está fora do seu acesso ou já não reporta aqui. Mostrando {name} no lugar.",
+  "You do not have access to the {group} services of the {layer} layer.": "Você não tem acesso aos serviços {group} da camada {layer}.",
   "This read does not name a service, and you can read only some services.": "Esta leitura não indica nenhum serviço, e você só pode ler alguns serviços.",
   "Previewing a draft template needs the layer-template:write permission.": "Pré-visualizar um rascunho de template exige a permissão layer-template:write.",
   "Access denied ({reason}).": "Acesso negado ({reason}).",
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index e3edba94..e0138b28 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -292,7 +292,7 @@
   "Bundled-only metrics will be installed.": "仅打包版本的指标将被安装。",
   "CONFIGURED": "CONFIGURED",
   "CURRENT": "当前",
-  "Callee outside this layer — no instance dashboard here.": "被调方不在此层级内 —— 这里没有实例仪表盘。",
+  "Not in this page's service list (another layer or group, or outside your access) — no instance dashboard here.": "不在本页的服务列表中(属于其他层或其他分组,或超出你的访问范围)——此处没有实例看板。",
   "Callees ({n})": "被调方({n})",
   "Callers ({n})": "调用方({n})",
   "Calls": "调用量",
@@ -925,7 +925,7 @@
   "Newest": "最新",
   "Next": "下一页",
   "No API-dependency data was captured for {serviceName} in this window.": "此时间窗口内未捕获到 {serviceName} 的 API 依赖数据。",
-  "No Horizon layer template configured for {layer}": "尚未为 {layer} 配置 Horizon layer 模板",
+  "{layer} has no entry in your menu for this service: Horizon has no page for it, or your role cannot open it": "{layer} 在你的菜单中没有此服务的入口:Horizon 没有对应页面,或你的角色无权打开",
   "No KPI rows. Add one to surface a metric on the tile.": "尚无 KPI 行。添加一行即可在图块上展示指标。",
   "No MQE snapshot was recorded with this alarm. Upgrade OAP or enable the snapshot capture in the alarm rule to see the trigger values here.": "此告警未记录 MQE 快照。请升级 OAP 或在告警规则中启用快照捕获,以便在此查看触发值。",
   "No OAP layer matches": "没有 OAP layer 匹配",
@@ -1309,6 +1309,8 @@
   "Records": "记录",
   "Ref type": "引用类型",
   "Refetched after timeout — the push may have completed; please verify.": "超时后已重新拉取 —— 推送可能已完成,请自行确认。",
+  "Saved, but the stored page could not be read back, so your edits are kept. Reload to check it before editing again.": "已保存,但未能重新读取已存储的页面,因此保留了你的编辑。请刷新并确认后再继续编辑。",
+  "Reset, but the stored page could not be read back, so this page may still show the old values. Reload before editing again.": "已重置,但未能重新读取已存储的页面,因此本页可能仍显示旧值。请刷新后再继续编辑。",
   "Refetched after timeout — verify on OAP.": "超时后已重新拉取 —— 请在 OAP 上确认。",
   "Refresh": "刷新",
   "Refresh service list": "刷新服务列表",
@@ -3430,6 +3432,12 @@
   "You do not have access to this service.": "你无权访问该服务。",
   "You do not have access to the {layer} layer.": "你无权访问 {layer} layer。",
   "You do not have access to this layer.": "你无权访问该 layer。",
+  "blocked": "已屏蔽",
+  "Metrics blocked: your role cannot read this service.": "指标已屏蔽:你的角色无权读取此服务。",
+  "Metrics blocked: your role reads neither end of this call.": "指标已屏蔽:你的角色无权读取此调用的任何一端。",
+  "Your role does not allow this read.": "你的角色不允许此读取。",
+  "The requested service is not in this page's list for your role: it is outside your access, or no longer reports here. Showing {name} instead.": "请求的服务不在你的角色可见的本页列表中:它超出了你的访问范围,或已不再在此上报。改为显示 {name}。",
+  "You do not have access to the {group} services of the {layer} layer.": "你无权访问 {layer} 层的 {group} 服务。",
   "This read does not name a service, and you can read only some services.": "此次读取未指定服务,而你只能读取部分服务。",
   "Previewing a draft template needs the layer-template:write permission.": "预览模板草稿需要 layer-template:write 权限。",
   "Access denied ({reason}).": "访问被拒绝({reason})。",
diff --git a/apps/ui/src/layer/LayerShell.vue b/apps/ui/src/layer/LayerShell.vue
index 1fc87edb..c9790ac9 100644
--- a/apps/ui/src/layer/LayerShell.vue
+++ b/apps/ui/src/layer/LayerShell.vue
@@ -462,6 +462,18 @@ const isZipkinTrace = computed<boolean>(() => {
   return scopeSegment.value === 'trace' && resolveTraceStores(layer.value?.traces).join() === 'zipkin';
 });
 
+// A requested service this page cannot show — outside the reader's grant, or
+// no longer reporting in this layer — and the one shown in its place. The page
+// says so rather than presenting another service under the link. Declared
+// above the watcher below, which runs during setup.
+const replacedService = ref<{ from: string; to: string } | null>(null);
+watch(layerKey, () => {
+  replacedService.value = null;
+});
+const replacedNotice = computed<boolean>(
+  () => replacedService.value !== null && selectedId.value === replacedService.value.to,
+);
+
 // Keep the URL-backed service selection honest for every page that
 // uses the shell picker. A `?service=` outside the landing sample is
 // trusted when it exists in the full roster; only a genuinely stale id
@@ -487,6 +499,7 @@ watch(
     if (rows.some((s) => s.serviceId === id)) return; // in the sample → keep
     if (rosterIsLoading) return; // don't clobber a pin while the roster loads
     if (roster.some((s) => s.id === id)) return; // valid in the full roster → keep
+    replacedService.value = { from: id, to: first.serviceId };
     setSelected(first.serviceId); // genuinely stale → fall back
   },
   { immediate: true },
@@ -676,6 +689,9 @@ const serviceKpis = computed<HeaderKpi[]>(() => {
           </template>
           <template v-else>{{ t('metrics for {range}', { range: kpiHourLabel }) }}</template>
         </div>
+        <div v-if="replacedNotice" class="banner warn kpi-partial">
+          {{ t('The requested service is not in this page\'s list for your role: it is outside your access, or no longer reports here. Showing {name} instead.', { name: selectedName }) }}
+        </div>
         <div v-if="landingPartial" class="banner warn kpi-partial">
           {{ t('Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.', { failed: landingPartial.failedChunks, total: landingPartial.totalChunks }) }}
         </div>
diff --git a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
index 7db0c195..154e7209 100644
--- a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
+++ b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
@@ -95,7 +95,7 @@ const { selectedId: headerSelectedId, setSelected: setSelectedService } = useSel
 const selectedId = computed<string | null>(() =>
   embedded.value ? (props.focusServiceId ?? null) : headerSelectedId.value,
 );
-const { layers } = useLayers();
+const { layers, entryKeyFor } = useLayers();
 const layer = computed<LayerDef | null>(
   // Case-insensitive: layer defs key on the uppercase OAP enum, but layerKey can
   // arrive lowercased (the AI chat block passes spec.layer.toLowerCase()).
@@ -217,6 +217,7 @@ const {
   calls: baseCalls,
   isFetching,
   phase,
+  refusal,
   data,
   refetch,
   acceptedSnapshot,
@@ -633,11 +634,17 @@ function openRouteInNewTab(to: RouteLocationRaw): void {
   window.open(href, '_blank', 'noopener');
 }
 
+/** This layer's entry for the node's service — on a layer split by service
+ *  group, a neighbour of another group opens under its own group. */
+function entryFor(sel: EndpointDependencyNode): string {
+  return entryKeyFor(layerKey.value.split('~', 1)[0]!, sel.serviceName);
+}
+
 function jumpToService(): void {
   const sel = selectedNode.value;
   if (!sel) return;
   openRouteInNewTab({
-    path: `/layer/${layerKey.value}/service`,
+    path: `/layer/${entryFor(sel)}/service`,
     query: { service: sel.serviceId },
   });
 }
@@ -651,7 +658,7 @@ function jumpToEndpointDashboard(): void {
   const sel = selectedNode.value;
   if (!sel) return;
   openRouteInNewTab({
-    path: `/layer/${layerKey.value}/endpoint`,
+    path: `/layer/${entryFor(sel)}/endpoint`,
     query: {
       service: sel.serviceId,
       endpoint: sel.name,
@@ -1092,12 +1099,14 @@ function edgeRowCrosshair(rowId: string): number | null {
               font-weight="700"
             >
               {{
-                centerDef
+                n.metricsBlocked
+                  ? t('blocked')
+                  : centerDef
                   ? (nodeVal(n, centerDef) === null
                       ? `— ${(centerDef.unit ?? '').toUpperCase()}`
                       : `${fmtMetric(nodeVal(n, centerDef))}${centerDef.unit ? ' ' + centerDef.unit.toUpperCase() : ''}`)
                   : ''
-              }}<template v-if="secondaryDef && nodeVal(n, secondaryDef) !== null"><tspan fill="var(--sw-fg-3)"> · </tspan><tspan fill="var(--sw-fg-2)" font-weight="500">{{ fmtMetric(nodeVal(n, secondaryDef)) }}{{ secondaryDef.unit ? ' ' + secondaryDef.unit.toUpperCase() : '' }}</tspan></template>
+              }}<template v-if="!n.metricsBlocked && secondaryDef && nodeVal(n, secondaryDef) !== null"><tspan fill="var(--sw-fg-3)"> · </tspan><tspan fill="var(--sw-fg-2)" font-weight="500">{{ fmtMetric(nodeVal(n, secondaryDef)) }}{{ secondaryDef.unit ? ' ' + secondaryDef.unit.toUpperCase() : '' }}</tspan></template>
             </text>
             <!-- One neutral expand handle (top-right corner) on the
                  SELECTED non-focus node. A single `getEndpointDependencies`
@@ -1156,6 +1165,7 @@ function edgeRowCrosshair(rowId: string): number | null {
           <div v-else-if="phase === 'failed' && blocked === 'layer-disabled'" class="loader">
             {{ t('This page is not available.') }}
           </div>
+          <div v-else-if="phase === 'failed' && refusal" class="loader">{{ refusal }}</div>
           <div v-else-if="phase === 'failed'" class="loader">
             {{ t('Could not load the dependency graph.') }}
             <button class="sw-btn small" type="button" @click="refetch()">{{ t('Retry') }}</button>
@@ -1215,7 +1225,8 @@ function edgeRowCrosshair(rowId: string): number | null {
           </div>
           <button class="sw-btn small" type="button" @click="selectedNodeId = null">×</button>
         </header>
-        <div class="ed-kpis">
+        <p v-if="selectedNode.metricsBlocked" class="ed-blocked">{{ t('Metrics blocked: your role cannot read this service.') }}</p>
+        <div v-else class="ed-kpis">
           <div v-for="m in cfg.nodeMetrics" :key="m.id" class="ed-kpi">
             <div class="ed-kpi-label">{{ m.label }}<span v-if="m.unit"> ({{ m.unit }})</span></div>
             <div
@@ -1688,6 +1699,11 @@ function edgeRowCrosshair(rowId: string): number | null {
   color: var(--sw-fg-0);
   word-break: break-all;
 }
+.ed-blocked {
+  margin: 0 0 10px;
+  font-size: 11.5px;
+  color: var(--sw-fg-3);
+}
 .ed-kpis {
   display: grid;
   grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
diff --git a/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts b/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts
index 8328180c..2d7987b4 100644
--- a/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts
+++ b/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts
@@ -174,7 +174,7 @@ export function useLayerEndpointDependency(
   // failed round and a remount; `latestAttempt` is whatever came back last and
   // drives the banners. `phase` is derived from both so a view cannot read half
   // its picture from one and half from the other.
-  const { acceptedSnapshot, latestAttempt, phase, predicateGeneration } =
+  const { acceptedSnapshot, latestAttempt, phase, predicateGeneration, refusal } =
     useGraphState<EndpointDependencyResponse>({
       data: computed(() => q.data.value?.response),
       error: q.error,
@@ -210,6 +210,7 @@ export function useLayerEndpointDependency(
     data: latestAttempt,
     latestAttempt,
     phase,
+    refusal,
     drawable: acceptedSnapshot,
     previewConfig: previewCfg,
     // The window the graph ON SCREEN was read with — not the live one.
diff --git a/apps/ui/src/layer/graphQuery.test.ts b/apps/ui/src/layer/graphQuery.test.ts
index 142d96e5..7fc6ab2d 100644
--- a/apps/ui/src/layer/graphQuery.test.ts
+++ b/apps/ui/src/layer/graphQuery.test.ts
@@ -31,6 +31,7 @@ import { useColdStageStore } from '@/controls/coldStage';
 import { accepts, fetchDrawable, GraphUnavailableError, predicateKey, predicateService, useGraphState, useTimeIdentity, useTriggeredRefetch, anchorForMount, useRoundWindow } from './graphQuery';
 import { useTimeRangeStore } from '@/controls/timeRange';
 import { useAutoRefreshStore } from '@/controls/autoRefresh';
+import { BffApiError } from '@/api/client';
 
 interface Resp {
   reachable?: boolean;
@@ -99,6 +100,22 @@ describe('the graph state machine', () => {
     await expect(fetchDrawable(async () => good(['a']))).resolves.toEqual(good(['a']));
   });
 
+  // A refusal is an answer: the screen says why instead of offering a Retry
+  // that cannot succeed.
+  it('says why the BFF refused the read, and nothing for any other failure', () => {
+    const refused = new BffApiError(403, 'GET /api/layer/general/topology failed (403)', {
+      error: 'permission_denied',
+      verb: 'topology:read',
+      reason: 'service_not_granted',
+    });
+    const s = state({ error: refused });
+    expect(s.phase.value).toBe('failed');
+    expect(s.refusal.value).toBe('You do not have access to this service.');
+    expect(state({ error: new GraphUnavailableError(failed()) }).refusal.value).toBeNull();
+    expect(state({ error: new BffApiError(403, 'x', { error: 'permission_denied', verb: 'topology:read' }) }).refusal.value)
+      .toBe('Your role does not allow this read.');
+  });
+
   it('keeps drawing the cached graph while the latest attempt failed', () => {
     const s = state({ data: good(['a']), error: new GraphUnavailableError(failed()) });
     expect(s.acceptedSnapshot.value?.nodes).toEqual(['a']);
diff --git a/apps/ui/src/layer/graphQuery.ts b/apps/ui/src/layer/graphQuery.ts
index 79cfd27d..f2a5c310 100644
--- a/apps/ui/src/layer/graphQuery.ts
+++ b/apps/ui/src/layer/graphQuery.ts
@@ -30,6 +30,7 @@ import { computed, ref, watch } from 'vue';
 import type { ComputedRef, Ref } from 'vue';
 import { useTimeRangeStore } from '@/controls/timeRange';
 import { useAutoRefreshStore } from '@/controls/autoRefresh';
+import { refusalText } from '@/api/permissionDenied';
 
 /** Anything the graph routes return. They all carry this flag; see `accepts`. */
 export interface GraphResponse {
@@ -127,6 +128,8 @@ export interface GraphState<T extends GraphResponse> {
    *  anything else built on a snapshot can compare against this to know
    *  whether it still belongs to the question on screen. */
   predicateGeneration: ComputedRef<number>;
+  /** Why the BFF refused the read, when it did — shown without a Retry. */
+  refusal: ComputedRef<string | null>;
 }
 
 /**
@@ -212,6 +215,7 @@ export function useGraphState<T extends GraphResponse>(opts: {
     latestAttempt,
     phase,
     predicateGeneration: computed(() => generation.value),
+    refusal: computed(() => refusalText(opts.error.value)),
   };
 }
 
diff --git a/apps/ui/src/layer/service-map/LayerDeploymentView.vue b/apps/ui/src/layer/service-map/LayerDeploymentView.vue
index dd548b8c..ab4f5682 100644
--- a/apps/ui/src/layer/service-map/LayerDeploymentView.vue
+++ b/apps/ui/src/layer/service-map/LayerDeploymentView.vue
@@ -127,7 +127,7 @@ const focusWindowMinutes = computed<number | null>(() =>
   embedded.value ? (props.focusWindowMinutes ?? 60) : null,
 );
 const replayDataRef = computed<DeploymentResponse | null>(() => props.replayData ?? null);
-const { data, acceptedSnapshot, nodes, calls, isFetching, phase, predicateKey, refetch } =
+const { data, acceptedSnapshot, nodes, calls, isFetching, phase, predicateKey, refetch, refusal } =
   useDeployment(layerKey, service, enabled, focusWindowMinutes, replayDataRef);
 // The drawn graph's service, not the failed attempt's — a failed round emptied
 // the heading above a map that was still showing that service's pods.
@@ -860,6 +860,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown, true));
         <div v-else-if="blocked === 'layer-disabled'" class="sit-state">
           {{ t('This page is not available.') }}
         </div>
+        <div v-else-if="showFailed && refusal" class="sit-state">{{ refusal }}</div>
         <div v-else-if="showFailed" class="sit-state">
           {{ t('Could not load the deployment topology.') }}
           <button class="sw-btn small" type="button" @click="refetch()">{{ t('Retry') }}</button>
diff --git a/apps/ui/src/layer/service-map/LayerInstanceTopologyView.vue b/apps/ui/src/layer/service-map/LayerInstanceTopologyView.vue
index 563da421..254cc7d3 100644
--- a/apps/ui/src/layer/service-map/LayerInstanceTopologyView.vue
+++ b/apps/ui/src/layer/service-map/LayerInstanceTopologyView.vue
@@ -214,7 +214,7 @@ function pick(which: 'client' | 'server', val: string): void {
 
 const enabled = computed(() => !!clientId.value && !!serverId.value);
 const replayDataRef = computed<InstanceTopologyResponse | null>(() => props.replayData ?? null);
-const { data, acceptedSnapshot, nodes, calls, isFetching, phase, predicateKey, refetch } =
+const { data, acceptedSnapshot, nodes, calls, isFetching, phase, predicateKey, refetch, refusal } =
   useInstanceTopology(
   layerKey,
   clientId,
@@ -547,11 +547,12 @@ const popoverStyle = computed<Record<string, string>>(() => {
   const style: Record<string, string> = { left: `${left}px`, top: `${top}px`, width: `${POP_W}px`, transform: 'translateY(-50%)' };
   return style;
 });
-// A node's service is part of THIS layer only when it's a `getAllServices`
-// entry. Conjectured / cross-layer callees (e.g. `rcmd:80`, surfaced only
-// because a layer service calls them) are NOT — they have no instance
-// dashboard in this layer, so we don't offer to open one. The check is
-// per-node, so it gates both the client and the server side.
+// A node's service has an instance dashboard on this page only when it is on
+// the page's roster. Conjectured / cross-layer callees (e.g. `rcmd:80`,
+// surfaced only because a layer service calls them) are not, and neither is a
+// service of another group of a layer split by group, or one outside the
+// reader's grant. The check is per-node, so it gates both the client and the
+// server side.
 const layerServiceIds = computed<Set<string>>(
   () => new Set(roster.services.value.map((s) => s.id)),
 );
@@ -711,6 +712,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown, true));
         <div v-else-if="blocked === 'layer-disabled'" class="imv-state">
           {{ t('This page is not available.') }}
         </div>
+        <div v-else-if="showFailed && refusal" class="imv-state">{{ refusal }}</div>
         <div v-else-if="showFailed" class="imv-state">
           {{ t('Could not load the instance relationship.') }}
           <button class="sw-btn small" type="button" @click="refetch()">{{ t('Retry') }}</button>
@@ -753,8 +755,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown, true));
                 @click.stop="selectNode(n.id)"
               >
                 <circle :r="NODE_R" class="node-bg" :stroke="ringColor(n)" :stroke-width="popoverNodeId === n.id ? 4 : 3" />
-                <text class="node-center" text-anchor="middle" :dy="centerDef?.unit ? '-1' : '0.36em'">{{ fmtVal(nodeVal(n, centerDef), undefined, centerDef?.format, true) }}</text>
-                <text v-if="centerDef?.unit" class="node-unit" text-anchor="middle" dy="12">{{ centerDef.unit }}</text>
+                <text class="node-center" text-anchor="middle" :dy="n.metricsBlocked || !centerDef?.unit ? '0.36em' : '-1'">{{ n.metricsBlocked ? t('blocked') : fmtVal(nodeVal(n, centerDef), undefined, centerDef?.format, true) }}</text>
+                <text v-if="centerDef?.unit && !n.metricsBlocked" class="node-unit" text-anchor="middle" dy="12">{{ centerDef.unit }}</text>
                 <text class="node-label mono" text-anchor="middle" :y="NODE_R + 15">{{ n.name }}</text>
               </g>
             </g>
@@ -766,7 +768,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown, true));
               <button class="sw-btn small ghost" type="button" @click="popoverNodeId = null">×</button>
             </header>
             <div class="np-svc mono dim">{{ displayName(popoverNode.serviceName) }}</div>
-            <dl class="np-kv">
+            <p v-if="popoverNode.metricsBlocked" class="np-note">{{ t('Metrics blocked: your role cannot read this service.') }}</p>
+            <dl v-else class="np-kv">
               <template v-for="def in cfg.nodeMetrics" :key="def.id">
                 <dt>{{ def.label }}</dt>
                 <dd class="mono">{{ fmtVal(nodeVal(popoverNode, def), def.unit, def.format) }}</dd>
@@ -780,7 +783,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown, true));
             >
               {{ t('Open instance dashboard') }} ↗
             </button>
-            <p v-else class="np-note">{{ t('Callee outside this layer — no instance dashboard here.') }}</p>
+            <p v-else class="np-note">{{ t('Not in this page\'s service list (another layer or group, or outside your access) — no instance dashboard here.') }}</p>
           </div>
 
           <div class="imv-zoom">
diff --git a/apps/ui/src/layer/service-map/LayerServiceMapView.vue b/apps/ui/src/layer/service-map/LayerServiceMapView.vue
index 14223d5d..9291ab1b 100644
--- a/apps/ui/src/layer/service-map/LayerServiceMapView.vue
+++ b/apps/ui/src/layer/service-map/LayerServiceMapView.vue
@@ -132,7 +132,7 @@ const layerKey = computed(() =>
 );
 const embedded = computed(() => Boolean(props.embedded));
 
-const { layers } = useLayers();
+const { layers, entryKeyFor } = useLayers();
 const auto = useAutoRefreshStore();
 const layer = computed<LayerDef | null>(
   // Case-insensitive: layer defs key on the uppercase OAP enum, but layerKey can
@@ -201,7 +201,7 @@ function truncateLabel(s: string, n: number): string {
 const depth = ref<number>(props.focusDepth ?? 2);
 const focusWindowMinutes = computed<number | null>(() => props.focusWindowMinutes ?? null);
 const replayDataRef = computed<TopologyResponse | null>(() => props.replayData ?? null);
-const { nodes, calls, isFetching, data, acceptedSnapshot, refetch, phase, predicateKey } =
+const { nodes, calls, isFetching, data, acceptedSnapshot, refetch, phase, predicateKey, refusal } =
   useLayerTopology(
   layerKey,
   focusServices,
@@ -763,13 +763,14 @@ function isCallFocused(id: string): boolean {
  * Resolve the layer key we should jump into for the selected node. A
  * service may belong to multiple OAP layers; OAP returns the complete
  * list on `node.layers`. Stay in the current layer when it's in that
- * list, else fall back to the first one.
+ * list, else fall back to the first one. The current key of a layer split
+ * by service group carries `~<group>`, which no OAP layer does.
  */
 function targetLayerFor(n: TopologyNode): string {
-  const current = layerKey.value.toUpperCase();
+  const current = layerKey.value.split('~', 1)[0]!.toUpperCase();
   const ls = n.layers ?? [];
   const pick = ls.includes(current) ? current : (ls[0] ?? current);
-  return pick.toLowerCase();
+  return entryKeyFor(pick, n.name);
 }
 function jumpToService(): void {
   const sel = selectedNode.value;
@@ -1102,6 +1103,16 @@ onBeforeUnmount(() => {
                   </text>
                 </g>
               </template>
+              <g
+                v-else-if="c.metricsBlocked && edgeMidpoint(c)"
+                :transform="`translate(${edgeMidpoint(c)!.x - 44}, ${edgeMidpoint(c)!.y - 13})`"
+                style="pointer-events: none"
+              >
+                <rect x="0" y="0" width="88" height="24" rx="12" fill="var(--sw-bg-1)" stroke="var(--sw-line-2)" stroke-width="1" />
+                <text x="44" y="17" text-anchor="middle" fill="var(--sw-fg-3)" font-size="12" font-family="var(--sw-mono)" font-weight="600">
+                  {{ t('blocked') }}
+                </text>
+              </g>
             </g>
 
             <!-- Polished linear-chain node — pure SVG, no PNGs. Three
@@ -1256,8 +1267,21 @@ onBeforeUnmount(() => {
                    it from the latency line beneath the name. Hidden
                    when the node reports no RPM so silent nodes don't
                    carry an empty placeholder. -->
+              <!-- Drawn because it is connected; the role may not read it,
+                   so its metrics were not read — say so, not "no value". -->
+              <text
+                v-if="n.metricsBlocked"
+                text-anchor="middle"
+                y="-50"
+                fill="var(--sw-fg-3)"
+                font-size="13"
+                font-family="var(--sw-mono)"
+                font-weight="600"
+              >
+                {{ t('blocked') }}
+              </text>
               <text
-                v-if="centerDef && nodeVal(n, centerDef) !== null"
+                v-else-if="centerDef && nodeVal(n, centerDef) !== null"
                 text-anchor="middle"
                 y="-50"
                 :fill="ringColor(n)"
@@ -1313,6 +1337,7 @@ onBeforeUnmount(() => {
         <div v-else-if="phase === 'failed' && blocked === 'layer-disabled'" class="loader">
           {{ t('This page is not available.') }}
         </div>
+        <div v-else-if="phase === 'failed' && refusal" class="loader">{{ refusal }}</div>
         <div v-else-if="phase === 'failed'" class="loader">
           {{ t('Could not load the topology.') }}
           <button class="sw-btn small" type="button" @click="refetch()">{{ t('Retry') }}</button>
diff --git a/apps/ui/src/layer/service-map/ServiceHierarchyOverlay.vue b/apps/ui/src/layer/service-map/ServiceHierarchyOverlay.vue
index 06763611..fd440409 100644
--- a/apps/ui/src/layer/service-map/ServiceHierarchyOverlay.vue
+++ b/apps/ui/src/layer/service-map/ServiceHierarchyOverlay.vue
@@ -41,6 +41,7 @@ import { useRouter } from 'vue-router';
 import { useI18n } from 'vue-i18n';
 import type {
   HierarchyPeer,
+  LayerDef,
   LayerLevel,
   ServiceHierarchyResponse,
   ServiceNamingRule,
@@ -81,7 +82,7 @@ const props = defineProps<{
 
 const { t } = useI18n({ useScope: 'global' });
 const store = useHierarchyOverlayStore();
-const { layers: allLayers, findLayer } = useLayers();
+const { layers: allLayers, findLayer, entryKeyFor } = useLayers();
 
 // Focus + open state come from props in standalone mode, else the shared store.
 const fLayer = computed<string | null>(() => (props.standalone ? props.focus?.layer : store.focusLayer) ?? null);
@@ -326,14 +327,15 @@ watch(
   },
 );
 
-/** True when the peer's layer has an active template the UI can route
- *  to. OAP can report cross-layer relations into layers the operator
- *  hasn't configured a Horizon template for (e.g. an obscure
- *  observability layer); we surface the peer hex but disable the
- *  action chip so a misleading "page not found" never happens. */
-function hasTemplate(layer: string): boolean {
-  const def = findLayer(layer.toLowerCase());
-  return Boolean(def && def.active);
+/** The menu entry a peer opens under, or null when the reader's menu has
+ *  none: OAP can report relations into a layer Horizon has no template for,
+ *  or one the reader's role cannot open — the menu leaves out both. We
+ *  surface the peer hex but disable the action chip so a dead end never
+ *  opens. On a layer split by service group the entry is the peer's
+ *  group's. */
+function peerEntry(layer: string, name: string): LayerDef | null {
+  const def = findLayer(entryKeyFor(layer, name));
+  return def && def.active ? def : null;
 }
 
 /** First click: arm the peer (show the side action chip). Second
@@ -341,12 +343,12 @@ function hasTemplate(layer: string): boolean {
  *  re-arms onto that one. Disabled peers (no layer template) skip
  *  arming and surface a notice instead — there's no useful chip to
  *  show. */
-function onPeerClick(peerKey: string, layer: string): void {
-  if (!hasTemplate(layer)) {
+function onPeerClick(peerKey: string, layer: string, name: string): void {
+  if (!peerEntry(layer, name)) {
     pushEvent(
       'hierarchy',
       'err',
-      `No layer template configured for ${labelForLayer(layer)} (${layer}). The peer service exists on OAP but Horizon has no menu / page set up for this layer.`,
+      `${labelForLayer(layer)} (${layer}) has no entry in this menu for ${name}: Horizon has no layer template for it, or this role cannot open it.`,
     );
     return;
   }
@@ -361,11 +363,13 @@ function onPeerClick(peerKey: string, layer: string): void {
  *  no extra URL flag is required — landing → serviceName → list →
  *  pick happens naturally there. */
 function confirmOpen(p: HierarchyPeer, layer: string): void {
-  const def = findLayer(layer.toLowerCase());
-  if (!def || !def.active) return;
+  const def = peerEntry(layer, p.name);
+  if (!def) return;
   const tab = firstLayerTab(def);
   const href = router.resolve({
-    path: `/layer/${def.key.toLowerCase()}/${tab}`,
+    // The entry key as the menu spells it: a group entry keeps its group's
+    // case, which the BFF compares exactly.
+    path: `/layer/${def.key}/${tab}`,
     query: { service: p.id },
   }).href;
   // `noopener` so the new tab can't reach back into window.opener.
@@ -375,23 +379,22 @@ function confirmOpen(p: HierarchyPeer, layer: string): void {
 
 /** Display name of a peer's destination layer — the template's `name`
  *  when one exists, the raw layer key otherwise. */
-function layerNameFor(layer: string): string {
-  const def = findLayer(layer.toLowerCase());
-  return def?.name ?? layer;
+function layerNameFor(layer: string, name: string): string {
+  return peerEntry(layer, name)?.name ?? layer;
 }
 
 /** Human label for the action chip — e.g. "Open in MESH_DP". The
  *  destination tab is implied (first menu of the layer). */
-function openLabelFor(layer: string): string {
-  return t('Open in {layer}', { layer: layerNameFor(layer) });
+function openLabelFor(layer: string, name: string): string {
+  return t('Open in {layer}', { layer: layerNameFor(layer, name) });
 }
 
 /** Approximate rendered width of the chip label at font-size 12 — the
  *  translated label can carry fullwidth CJK glyphs (~12px each) that the
  *  old flat 7.6px-per-char estimate under-measured, overlapping the icon. */
-function openLabelWidth(layer: string): number {
+function openLabelWidth(layer: string, name: string): number {
   let w = 0;
-  for (const ch of openLabelFor(layer)) {
+  for (const ch of openLabelFor(layer, name)) {
     w += /[ᄀ-ᇿ⺀-꓏가-힣豈-﫿︰-﹏＀-₩ -ヿ]/.test(ch) ? 12 : 7.6;
   }
   return w;
@@ -617,15 +620,15 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown));
           :transform="`translate(${r.x}, ${r.y})`"
           class="sm-h-peer"
           :class="{
-            'is-disabled': !hasTemplate(r.layer),
+            'is-disabled': !peerEntry(r.layer, r.peer.name),
             'is-armed': armedPeerKey === r.key,
           }"
-          @click.stop="onPeerClick(r.key, r.layer)"
+          @click.stop="onPeerClick(r.key, r.layer, r.peer.name)"
         >
           <title>
-            {{ hasTemplate(r.layer)
-              ? t('Click to select {name}; the action chip opens it in {layer} (new tab)', { name: r.identity.display, layer: layerNameFor(r.layer) })
-              : t('No Horizon layer template configured for {layer}', { layer: labelForLayer(r.layer) }) }}
+            {{ peerEntry(r.layer, r.peer.name)
+              ? t('Click to select {name}; the action chip opens it in {layer} (new tab)', { name: r.identity.display, layer: layerNameFor(r.layer, r.peer.name) })
+              : t('{layer} has no entry in your menu for this service: Horizon has no page for it, or your role cannot open it', { layer: labelForLayer(r.layer) }) }}
           </title>
           <!-- Selection ring when armed — same vocabulary as the
                topology's selected-hex halo, scaled for the peer's
@@ -721,7 +724,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown));
           <rect
             x="0"
             y="-14"
-            :width="openLabelWidth(r.layer) + 36"
+            :width="openLabelWidth(r.layer, r.peer.name) + 36"
             height="28"
             rx="6"
             :fill="r.color"
@@ -737,11 +740,11 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown));
             :fill="r.color"
             font-family="var(--sw-mono)"
           >
-            {{ openLabelFor(r.layer) }}
+            {{ openLabelFor(r.layer, r.peer.name) }}
           </text>
           <!-- External-link glyph: square with arrow out the top-right -->
           <g
-            :transform="`translate(${openLabelWidth(r.layer) + 18}, 0)`"
+            :transform="`translate(${openLabelWidth(r.layer, r.peer.name) + 18}, 0)`"
             :stroke="r.color"
             stroke-width="1.6"
             fill="none"
diff --git a/apps/ui/src/layer/service-map/TopologyDetailPanels.vue b/apps/ui/src/layer/service-map/TopologyDetailPanels.vue
index fd2080a6..65d2d6a2 100644
--- a/apps/ui/src/layer/service-map/TopologyDetailPanels.vue
+++ b/apps/ui/src/layer/service-map/TopologyDetailPanels.vue
@@ -321,7 +321,8 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st
         </div>
         <button class="sw-btn small" type="button" @click="emit('clearNode')">×</button>
       </header>
-      <div class="sp-kpis">
+      <div v-if="selectedNode.metricsBlocked" class="sp-empty">{{ t('Metrics blocked: your role cannot read this service.') }}</div>
+      <div v-else class="sp-kpis">
         <div v-for="m in cfg.nodeMetrics" :key="m.id" class="sp-kpi">
           <div class="sp-kpi-label">{{ formatRowLabel(m) }}</div>
           <div class="sp-kpi-value" :style="{ color: m.role === 'ring' ? ringColor(selectedNode) : m.role === 'center' ? 'var(--sw-accent)' : 'var(--sw-fg-0)' }">
@@ -427,7 +428,8 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st
            `fluid` to fill its cell. -->
       <div class="sp-section">
         <div class="sp-section-title">{{ t('Line metrics') }}</div>
-        <div v-if="edgeRows.length > 0" class="sp-edge-rows">
+        <div v-if="selectedCall.metricsBlocked" class="sp-empty">{{ t('Metrics blocked: your role reads neither end of this call.') }}</div>
+        <div v-else-if="edgeRows.length > 0" class="sp-edge-rows">
           <div v-for="row in edgeRows" :key="row.id" class="sp-edge-row-card">
             <div class="sp-edge-row-head">
               <span class="sp-edge-row-label">{{ formatRowLabel(row) }}</span>
diff --git a/apps/ui/src/layer/service-map/useDeployment.ts b/apps/ui/src/layer/service-map/useDeployment.ts
index d4251de4..f6eba8af 100644
--- a/apps/ui/src/layer/service-map/useDeployment.ts
+++ b/apps/ui/src/layer/service-map/useDeployment.ts
@@ -147,7 +147,7 @@ export function useDeployment(
   // failed round and a remount; `latestAttempt` is whatever came back last and
   // drives the banners. `phase` is derived from both so a view cannot read half
   // its picture from one and half from the other.
-  const { acceptedSnapshot, latestAttempt, phase, predicateGeneration } =
+  const { acceptedSnapshot, latestAttempt, phase, predicateGeneration, refusal } =
     useGraphState<DeploymentResponse>({
       data: q.data,
       error: q.error,
@@ -164,6 +164,7 @@ export function useDeployment(
     data: latestAttempt,
     latestAttempt,
     phase,
+    refusal,
     drawable: acceptedSnapshot,
     acceptedSnapshot,
     predicateGeneration,
diff --git a/apps/ui/src/layer/service-map/useInstanceTopology.ts b/apps/ui/src/layer/service-map/useInstanceTopology.ts
index 524dd405..1a4607b3 100644
--- a/apps/ui/src/layer/service-map/useInstanceTopology.ts
+++ b/apps/ui/src/layer/service-map/useInstanceTopology.ts
@@ -154,7 +154,7 @@ export function useInstanceTopology(
   // failed round and a remount; `latestAttempt` is whatever came back last and
   // drives the banners. `phase` is derived from both so a view cannot read half
   // its picture from one and half from the other.
-  const { acceptedSnapshot, latestAttempt, phase, predicateGeneration } =
+  const { acceptedSnapshot, latestAttempt, phase, predicateGeneration, refusal } =
     useGraphState<InstanceTopologyResponse>({
       data: q.data,
       error: q.error,
@@ -171,6 +171,7 @@ export function useInstanceTopology(
     data: latestAttempt,
     latestAttempt,
     phase,
+    refusal,
     drawable: acceptedSnapshot,
     acceptedSnapshot,
     predicateGeneration,
diff --git a/apps/ui/src/layer/service-map/useLayerTopology.ts b/apps/ui/src/layer/service-map/useLayerTopology.ts
index 2f4dc90f..3b9badce 100644
--- a/apps/ui/src/layer/service-map/useLayerTopology.ts
+++ b/apps/ui/src/layer/service-map/useLayerTopology.ts
@@ -98,7 +98,7 @@ export function useLayerTopology(
   const predicate = computed<GraphPredicate>(() => ({
     layer: layerKey.value,
     focus: services.value.map((s) => predicateService(s)).filter((s): s is NonNullable<typeof s> => s !== null),
-    depth: depth.value,
+    depth: services.value.length > 0 ? depth.value : 1,
     time: timeIdentity.value,
     preview: previewCfg.value ?? null,
   }));
@@ -168,7 +168,7 @@ export function useLayerTopology(
   // failed round and a remount; `latestAttempt` is whatever came back last and
   // drives the banners. `phase` is derived from both so a view cannot read half
   // its picture from one and half from the other.
-  const { acceptedSnapshot, latestAttempt, phase, predicateGeneration } =
+  const { acceptedSnapshot, latestAttempt, phase, predicateGeneration, refusal } =
     useGraphState<TopologyResponse>({
       data: q.data,
       error: q.error,
@@ -185,6 +185,7 @@ export function useLayerTopology(
     data: latestAttempt,
     latestAttempt,
     phase,
+    refusal,
     drawable: acceptedSnapshot,
     acceptedSnapshot,
     predicateGeneration,
diff --git a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
index ea37621a..1f1ecd54 100644
--- a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
+++ b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
@@ -54,6 +54,7 @@ import { ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { type CompareScope, compoundKey, splitCompound } from '@/state/layerSelection';
 import { tracesInclude } from '@skywalking-horizon-ui/api-client';
+import { refusalText } from '@/api/permissionDenied';
 
 const { t } = useI18n({ useScope: 'global' });
 const auth = useAuthStore();
@@ -499,6 +500,9 @@ const reachable = computed(() => {
   // still rendering pinned-entity data (or still arriving).
   return compareLoading.value || compareEntities.value.some((e) => resultByEntity.value.get(e) !== undefined);
 });
+/** A refused read is an answer, not a wait: without this the grid stays on
+ *  "Reading data" for good, since no fresh result ever arrives. */
+const refusal = computed<string | null>(() => refusalText(error.value));
 const errorText = computed(() => {
   const failed = error.value instanceof GraphUnavailableError ? (error.value.response as { error?: string }) : null;
   return failed?.error ?? data.value?.error ?? (error.value ? String(error.value) : null);
@@ -830,8 +834,9 @@ const tabHostCtx = computed<TabHostCtx>(() => ({
          sequence (which read as a slow, jumpy entry). The "no widgets"
          branch below only shows once config has actually loaded and the
          layer genuinely defines none. -->
+    <div v-if="refusal" class="empty">{{ refusal }}</div>
     <div
-      v-if="reachable && !noEntityToChart && (configLoading || (!dataIsFresh && widgets.length > 0 && widgetsForQuery.length > 0))"
+      v-else-if="reachable && !noEntityToChart && (configLoading || (!dataIsFresh && widgets.length > 0 && widgetsForQuery.length > 0))"
       class="empty reading"
     >
       <span class="reading-dot" />
diff --git a/apps/ui/src/render/widgets/KpiTileWidget.vue b/apps/ui/src/render/widgets/KpiTileWidget.vue
index 9f24871e..60021be3 100644
--- a/apps/ui/src/render/widgets/KpiTileWidget.vue
+++ b/apps/ui/src/render/widgets/KpiTileWidget.vue
@@ -21,6 +21,7 @@ import { useI18n } from 'vue-i18n';
 import type { OverviewKpi } from '@skywalking-horizon-ui/api-client';
 import { formatValue } from './ValueFormat';
 import WidgetTip from '@/components/primitives/WidgetTip.vue';
+import { layerRouteKey } from '@/utils/layerRoute';
 
 const props = defineProps<{
   title: string;
@@ -40,9 +41,7 @@ const { t } = useI18n();
 // and per-layer routes all use. The overview JSON authors uppercase
 // (matching OAP's enum), so normalise here so the click-through URL
 // matches the rest of the app.
-const tileTo = computed(() =>
-  props.layer ? `/layer/${props.layer.toLowerCase()}/service` : '',
-);
+const tileTo = computed(() => (props.layer ? `/layer/${layerRouteKey(props.layer)}/service` : ''));
 
 /** Clamp value/max into a 0..100 percentage for the progress-bar
  *  width. `null` / non-finite / max=0 collapse to 0. */
diff --git a/apps/ui/src/render/widgets/MetricCompositeWidget.vue b/apps/ui/src/render/widgets/MetricCompositeWidget.vue
index cae3876c..41926742 100644
--- a/apps/ui/src/render/widgets/MetricCompositeWidget.vue
+++ b/apps/ui/src/render/widgets/MetricCompositeWidget.vue
@@ -39,6 +39,7 @@ import { RouterLink } from 'vue-router';
 import type { OverviewKpi } from '@skywalking-horizon-ui/api-client';
 import { formatValue } from './ValueFormat';
 import WidgetTip from '@/components/primitives/WidgetTip.vue';
+import { layerRouteKey } from '@/utils/layerRoute';
 
 const props = defineProps<{
   title: string;
@@ -63,9 +64,7 @@ function isBar(k: OverviewKpi): boolean {
 const counts = computed(() => rows.value.filter((k) => !isBar(k)));
 const bars = computed(() => rows.value.filter(isBar));
 
-const tileTo = computed(() =>
-  props.layer ? `/layer/${props.layer.toLowerCase()}/service` : '',
-);
+const tileTo = computed(() => (props.layer ? `/layer/${layerRouteKey(props.layer)}/service` : ''));
 
 /* Bar fill: when `max` is set the bar plots `value / max`; otherwise
  * the value is assumed to already be a 0..100 percentage (matches
diff --git a/apps/ui/src/render/widgets/RankingWidget.vue b/apps/ui/src/render/widgets/RankingWidget.vue
index 7e07ccfe..cd60f0f6 100644
--- a/apps/ui/src/render/widgets/RankingWidget.vue
+++ b/apps/ui/src/render/widgets/RankingWidget.vue
@@ -28,6 +28,7 @@ import { useI18n } from 'vue-i18n';
 import { formatValue } from './ValueFormat';
 import { rankingLayout, type RankingRow } from './ranking';
 import WidgetTip from '@/components/primitives/WidgetTip.vue';
+import { layerRouteKey } from '@/utils/layerRoute';
 
 const props = defineProps<{
   title: string;
@@ -45,7 +46,7 @@ const props = defineProps<{
 
 const { t } = useI18n();
 
-const tileTo = computed(() => (props.layer ? `/layer/${props.layer.toLowerCase()}/service` : ''));
+const tileTo = computed(() => (props.layer ? `/layer/${layerRouteKey(props.layer)}/service` : ''));
 // The list is measured so the rows can split into as many columns as its
 // height calls for; until measured, a column is taken to hold five.
 const ROW_PITCH = 26;
diff --git a/apps/ui/src/shell/useLayers.test.ts b/apps/ui/src/shell/useLayers.test.ts
index 71e93192..c82eeb34 100644
--- a/apps/ui/src/shell/useLayers.test.ts
+++ b/apps/ui/src/shell/useLayers.test.ts
@@ -17,7 +17,7 @@
 
 import { describe, it, expect } from 'vitest';
 import type { LayerDef } from '@skywalking-horizon-ui/api-client';
-import { firstLayerTab } from './useLayers';
+import { entryKeyIn, firstLayerTab } from './useLayers';
 
 /**
  * Helper to build a partial-but-typed LayerDef. Real layers carry
@@ -124,3 +124,22 @@ describe('firstLayerTab — routing decision per layer caps', () => {
     expect(firstLayerTab(L({ logs: true, traceProfiling: true }))).toBe('logs');
   });
 });
+
+describe('entryKeyIn — the sidebar entry a service opens under', () => {
+  const menu = ['general~payments', 'general~risk', 'mesh'].map((key) => ({ ...L(), key }));
+
+  it('takes the service\'s group entry on a layer split by service group', () => {
+    expect(entryKeyIn(menu, 'GENERAL', 'risk::scorer')).toBe('general~risk');
+    expect(entryKeyIn(menu, 'GENERAL', 'payments::ledger')).toBe('general~payments');
+  });
+
+  it('names a group entry the reader lacks, so the page says why', () => {
+    expect(entryKeyIn(menu, 'GENERAL', 'audit::log')).toBe('general~audit');
+    expect(entryKeyIn(menu, 'GENERAL', 'ungrouped')).toBe('general~');
+  });
+
+  it('keeps the layer key on a layer not split, or not in the menu', () => {
+    expect(entryKeyIn(menu, 'MESH', 'mesh-svr::reviews')).toBe('mesh');
+    expect(entryKeyIn(menu, 'SO11Y_JAVA_AGENT', 'payments::ledger')).toBe('so11y_java_agent');
+  });
+});
diff --git a/apps/ui/src/shell/useLayers.ts b/apps/ui/src/shell/useLayers.ts
index c2e65176..268ecdb3 100644
--- a/apps/ui/src/shell/useLayers.ts
+++ b/apps/ui/src/shell/useLayers.ts
@@ -34,6 +34,7 @@ import { usePreviewMode, getPreviewSource } from '@/controls/previewMode';
 import { usePreviewOverride } from '@/controls/previewOverride';
 import { useLocalTemplateEdits, layerEditName } from '@/controls/localTemplateEdits';
 import { layerContentToDef, overlayLayerDef, type LayerTemplateContent } from '@/shell/layerFromTemplate';
+import { serviceGroupName } from '@/utils/serviceName';
 
 /**
  * Live OAP-driven layer + menu state. Fed by `GET /api/menu`. Refetches on
@@ -126,6 +127,10 @@ export function useLayers() {
     return layers.value.find((L) => L.key === key);
   }
 
+  function entryKeyFor(layer: string, serviceName: string): string {
+    return entryKeyIn(layers.value, layer, serviceName);
+  }
+
   /**
    * `caps.serviceMap || caps.instanceTopology || caps.processTopology`.
    * Pulled out of `layers.ts` so the sidebar can stay UI-only.
@@ -145,11 +150,24 @@ export function useLayers() {
     oapReachable,
     oapError,
     findLayer,
+    entryKeyFor,
     hasTopology,
     refetch: q.refetch,
   };
 }
 
+/**
+ * The sidebar entry a service of the OAP layer `layer` opens under. On a
+ * layer split by service group that is its group's entry — OAP's group is
+ * the name before `::` — even when this reader has no such entry, so the
+ * page says why; no entry carries the bare key of a split layer.
+ */
+export function entryKeyIn(menu: readonly LayerDef[], layer: string, serviceName: string): string {
+  const key = layer.toLowerCase();
+  const split = menu.some((L) => L.key.startsWith(`${key}~`));
+  return split ? `${key}~${serviceGroupName(serviceName) ?? ''}` : key;
+}
+
 /**
  * The layer's sub-page rows, in sidebar order. Prefers the list the BFF
  * resolved and served; falls back to resolving locally so a layer built
diff --git a/apps/ui/src/utils/layerRoute.test.ts b/apps/ui/src/utils/layerRoute.test.ts
new file mode 100644
index 00000000..934830b4
--- /dev/null
+++ b/apps/ui/src/utils/layerRoute.test.ts
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, expect, it } from 'vitest';
+import { layerRouteKey } from './layerRoute';
+
+describe('layerRouteKey', () => {
+  it('lower-cases the layer and keeps a service group as written', () => {
+    expect(layerRouteKey('GENERAL')).toBe('general');
+    expect(layerRouteKey('GENERAL~Payments')).toBe('general~Payments');
+    expect(layerRouteKey('general~')).toBe('general~');
+  });
+});
diff --git a/apps/ui/src/utils/layerRoute.ts b/apps/ui/src/utils/layerRoute.ts
new file mode 100644
index 00000000..87cd16eb
--- /dev/null
+++ b/apps/ui/src/utils/layerRoute.ts
@@ -0,0 +1,24 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** The route segment for a layer or sidebar entry key: the layer lower-cased,
+ *  as routes spell it, and a service group after `~` kept as written — the
+ *  BFF compares groups exactly. */
+export function layerRouteKey(key: string): string {
+  const cut = key.indexOf('~');
+  return cut < 0 ? key.toLowerCase() : `${key.slice(0, cut).toLowerCase()}${key.slice(cut)}`;
+}
diff --git a/docs/access-control/rbac.md b/docs/access-control/rbac.md
index 92259886..986bd1e6 100644
--- a/docs/access-control/rbac.md
+++ b/docs/access-control/rbac.md
@@ -134,11 +134,11 @@ Only verbs whose data belongs to a service can carry a layer: `metrics:read`, `t
 
 **What the role sees.** The sidebar lists the layers, and the group entries, that the role's layer grants reach — each with all of its pages, as for any role; a page whose data the role cannot read answers with a refusal. The Zipkin and TraceQL pages have no per-service control of their own and follow the layer's visibility. Every request is checked on its own: a page, an API call, and every read the AI assistant or an MCP agent makes on the role's behalf must name a service the grant co [...]
 
-**What the role still sees of other services.** Links between services are navigation, not access: the page a link opens is checked like any other. The views that draw relationships still show what OAP returns for the role's own service — the topology map shows its neighbours in other layers or groups with their names and headline metrics, the hierarchy view names the same workload's services in other layers, and an instance map or a network-profiling edge between two services opens when [...]
+**What the role still sees of other services.** Links between services are navigation, not access: the page a link opens is checked like any other. The views that draw relationships still draw what OAP returns around the role's own services, but never the metrics of a service the role may not read. The topology map draws every connected service, in any layer or group; a service the role may not read keeps its name and says its metrics are **blocked**, and a call between two services show [...]
 
 **Traces cross layers.** A trace follows a request through services of any layer, so Horizon does not narrow traces by layer. A role with `traces:read` on a layer reads that layer's trace tabs — the SkyWalking trace list for the service picked on the page, and the Zipkin and TraceQL stores the layer lists — and opens any trace by its id, every span included. Traces are not narrowed by group either: the Zipkin and TraceQL searches reach every service in those stores.
 
-**Alarms.** With `alarms:read` limited to layers, every alarm page, the alarm count in the sidebar and the top bar, the overview **Alarms** widget and the 3D map show the alarms of the services the grant covers: an alarm on a service, on one of its instances or endpoints, or on a relation whose source or destination is one of those services. Which named alarm pages a role sees follows from the same grant — see [Alarm Pages](../customization/alarm-pages.md). A page arranges the alarms a r [...]
+**Alarms.** With `alarms:read` limited to layers, every alarm page, the alarm count in the sidebar and the top bar, the overview **Alarms** widget and the 3D map show the alarms of the services the grant covers: an alarm on a service, on one of its instances or endpoints, or on a relation whose source or destination is one of those services. OAP gives a relation's destination by name only, so when a normal and a conjectured service share that name, a role that reads the relation only thr [...]
 
 **Evaluation records.** They belong to a call from an application service to a GenAI provider and are read on the VIRTUAL_GENAI layer, so the role needs that layer granted: add `logs:read@VIRTUAL_GENAI`. Its services are the providers (`openai`, `anthropic`, …), which carry no group, so grant the whole layer. With it, a record is readable when the role reads either the provider or the calling service — so the role also reads other teams' calls to the same providers.
 
@@ -155,6 +155,32 @@ Only verbs whose data belongs to a service can carry a layer: `metrics:read`, `t
 
 A role with no `@` grant is unaffected by any of this: it sees the menu and the data it always has.
 
+### Page by page
+
+What each page checks for a role limited to layers or service groups. "Readable" means the role's grant for the page's verb reaches the service; a role without `cluster:read` also finds services that are only in a Platform monitoring layer unreadable.
+
+| Page or read | Verb | What is checked, and what the role sees |
+|---|---|---|
+| Sidebar | the page verbs | Only the layers, and the group entries of a layer split by service group, that a grant reaches. A group entry opened by its address that the grant does not reach answers "No access to this layer". |
+| Service pickers and lists | the page verbs | Only readable services. A link naming a service outside the list shows the page's first service, with a note saying so. |
+| Service, instance and endpoint dashboards | `metrics:read` | The picked service must be readable. A refused read says why instead of loading. |
+| Layer header totals, overview KPI tiles across a layer | `metrics:read` | Only for a role that reads the whole layer without `@`. The service list keeps the role's own services. |
+| Topology map | `topology:read` | The focus services must be readable. Every connected service is drawn; one the role may not read shows **blocked** instead of its metrics, and its metrics are not read. A call shows both its client-side and server-side values when the role reads either end. The **All services** map is one hop out from the role's services; the Depth control (1–3 hops) applies to a focused map. |
+| Instance map | `topology:read` | Opens when the role reads either service. The other service's instances show **blocked**; the calls between the two carry their values. |
+| API dependency graph | `topology:read` | The focus service and endpoint must be readable. Endpoints of other services show **blocked**; expanding one of them is refused. |
+| Deployment map | `topology:read` | One service, which must be readable. |
+| Service hierarchy | `topology:read` | The focus service must be readable; related services in other layers are named, without metrics. |
+| Traces | `traces:read` | The SkyWalking trace list needs a picked, readable service. A trace opened by id, and the Zipkin and TraceQL searches, are not narrowed. |
+| Logs, browser logs, pod logs | `logs:read`, `browser-errors:read` | A picked, readable service (and instance or endpoint); pod logs per pod. Log tag autocomplete and source maps need the verb without `@`. |
+| Evaluation records | `logs:read` on VIRTUAL_GENAI | Readable when the role reads the provider or the calling service. |
+| Events | `events:read` | A named, readable service. |
+| Alarms, the alarm count, the Alarms widget | `alarms:read` | The alarms of readable services, as described above; alarm pages list only what their pins cover. |
+| AI agent conversations | `ai-conversation:read` | A named, readable service. |
+| Profiling | `profile:read`, `profile:enable` | Tasks, policies and results of a readable service or instance. Results looked up by segment or schedule id, and keeping a network-profiling task alive, need the verb without `@`. A network-profiling edge opens when the role reads either process's service. |
+| MQE in the template editor, Metrics Inspect | `metrics:read`, `inspect:read` | Through the expression's entity service; an expression that names no service needs `inspect:read`. |
+| 3D map | `infra-3d:read` | Draws only the services of the role's own sidebar layers; the verb cannot carry a layer. |
+| AI assistant and MCP agents | `ai:read`, `mcp:read` + each tool's verb | Every tool checks its read as the matching page does, and a map or graph it returns withholds the same metrics. |
+
 ## Built-in roles
 
 Default definitions (used when `rbac.roles` is not overridden):
diff --git a/docs/changelog/1.1.0.md b/docs/changelog/1.1.0.md
index 0e6d2473..58ae4173 100644
--- a/docs/changelog/1.1.0.md
+++ b/docs/changelog/1.1.0.md
@@ -48,7 +48,7 @@
 
 ### Features
 
-**A role can be limited to layers, and to service groups within a layer.** A data permission can now name the layer it applies to — `metrics:read@GENERAL`, or `metrics:read@GENERAL[payments,risk]` for the services whose OAP service group is `payments` or `risk` — so one Horizon can serve several teams that each see only their own services. The sidebar follows the grant: such a role sees the layers, and the per-group entries of a layer split by service group, that its grants reach, with s [...]
+**A role can be limited to layers, and to service groups within a layer.** A data permission can now name the layer it applies to — `metrics:read@GENERAL`, or `metrics:read@GENERAL[payments,risk]` for the services whose OAP service group is `payments` or `risk` — so one Horizon can serve several teams that each see only their own services. The sidebar follows the grant: such a role sees the layers, and the per-group entries of a layer split by service group, that its grants reach, with s [...]
 
 **Any permission that opens a layer page can fill its pickers.** The service picker at the top of a layer, and the instance and endpoint pickers on its tabs, used to need `metrics:read` (the instance list also took `logs:read`), so a role granted only `traces:read` or `logs:read` could not pick what its own tab was about. They now open for any of the layer-page permissions — and the instance and endpoint pickers also for `alarms:read`, since the Alarms page narrows by them — and list onl [...]
 
@@ -76,6 +76,12 @@
 
 **A database outage no longer costs the hourly sign-in counts it spanned.** Each Horizon process now records a running total for the hour rather than a count of what happened since its last write, so the first write after the database comes back restores the figures for the hour in progress — and a write that is retried after an uncertain outcome leaves the same number instead of counting twice. The sign-in list is unchanged: rows from an outage are still not recorded.
 
+**A service-group entry the role cannot read says "No access to this layer".** Opening such an entry by its address — `/layer/general~risk` for a role limited to `GENERAL[payments]` — said the layer was not found.
+
+**A link to a service the page cannot show now says so.** A `?service=` naming a service outside the role's grant, or one no longer reporting, silently showed the page's first service under the link; the page now says it is showing another service instead.
+
+**The Service dashboard shows a refused read instead of reading for ever.** When the dashboard's read was refused, the grid stayed on "Reading data …"; it now shows the reason.
+
 ## Refreshing
 
 ### Features
@@ -138,6 +144,14 @@ The **3D Infra Map** opens framed on the tiers that hold services. A deployment
 
 **Pods drifted on the Deployment map, and dragging could stop working.** A placement was held as an offset from the packed position, so a refresh that added a pod carried yours along with the re-pack; and the map only re-armed its drag handles when a count changed, so a refresh that swapped one instance for another silently left them unbound. Placements are absolute now, and a node on the API dependency graph keeps its place the same way.
 
+**Open service and API map took a node on a layer split by service group to the wrong layer.** From the Topology tab of an entry such as `general~payments`, both opened the node's first OAP layer — often a self-observability layer such as SO11Y_JAVA_AGENT — so a role limited to a service group was told "No access to this layer" even for its own service, and a full-access role landed on the wrong page. They now open the node's own group entry, and so do **Service →** and **Open endpoint** [...]
+
+**A graph the role may not read says so.** The Topology, Deployment, API dependency and instance map graphs showed "Could not load …" with a **Retry** that could never succeed when the read was refused; they now show the reason, such as "You do not have access to this service."
+
+**The service hierarchy no longer blames a missing template for a peer it cannot open.** A peer in a layer absent from the reader's menu was labelled "No Horizon layer template configured", which is also what a role that may not open that layer saw; the label now names both causes.
+
+**The instance map no longer calls a service of the same layer "outside this layer".** A callee of another service group, or one outside the reader's access, was described that way; the note now says the service is not in the page's service list.
+
 ## Dashboard templates
 
 ### Features
@@ -170,6 +184,8 @@ The **3D Infra Map** opens framed on the tiers that hold services. A deployment
 
 **A widget can be placed on a layer that has no services yet.** The layer list in the overview template editor offered only the layers with services at that moment, plus those the dashboard already used. It now offers every layer Horizon knows, so a dashboard can be prepared before a layer's first service reports. A layer split by service group is offered with each group as written — it was offered upper-cased (`GENERAL~PAYMENTS`), so a widget bound to a group found no services.
 
+**A KPI tile, ranking or metric widget bound to a service-group entry opens that entry.** Its click-through lower-cased the whole key, so a group spelled with capitals (`GENERAL~Payments`) opened an entry that does not exist.
+
 ## Operating Horizon
 
 ### Fixes
@@ -278,6 +294,8 @@ The **3D Infra Map** opens framed on the tiers that hold services. A deployment
 
 **A layer filter on the Alarms page finds that layer's alarms however many other alarms come first.** It reads as far into the window as its page needs, up to the window's first 5,000 alarms. On BanyanDB, OAP 11.0.0 and later return nothing for any page after the first of an alarm list read without a service, so every read now starts at the window's first alarm, and `/api/alarms` answers page 2 and later correctly there too.
 
+**The 3D map marks alarmed services on a layer split by service group.** A service on such a layer never turned red, because the map looked its alarms up under the group's sidebar entry instead of the layer.
+
 **A failed alarm read says so.** When the alarms could not be read, the Alarms page and the dashboard **Alarms** widget showed "No alarms in the current window" and an **Active** count of 0. They now say the read failed and why, and the tiles show no count.
 
 **Adding a pin on the Alarm pages setup offers every layer, not only those with services.** It listed only the layers with services at that moment, so a layer that was quiet when the page was set up could not be pinned, and a layer split by service group was offered once per group under a key no alarm matches. It now lists every layer the editing role sees in the sidebar, whether or not it has services, each once.
diff --git a/packages/api-client/src/topology.ts b/packages/api-client/src/topology.ts
index 4a7085b1..6c0edffb 100644
--- a/packages/api-client/src/topology.ts
+++ b/packages/api-client/src/topology.ts
@@ -237,6 +237,9 @@ export interface TopologyNode {
   respTime: number | null;
   /** @deprecated use `metrics['sla']`. */
   sla: number | null;
+  /** Drawn because it is connected, but the caller may not read it: its
+   *  metrics were not read, and the node says so rather than showing none. */
+  metricsBlocked?: boolean;
 }
 
 export interface TopologyCall {
@@ -262,6 +265,8 @@ export interface TopologyCall {
   clientCpm: number | null;
   /** @deprecated convenience read of `clientMetrics['respTime']`. */
   clientRespTime: number | null;
+  /** The caller reads neither end: the call is drawn without its values. */
+  metricsBlocked?: boolean;
 }
 
 export interface TopologyResponse {
@@ -304,6 +309,9 @@ export interface InstanceTopologyNode {
   isReal: boolean;
   /** Keyed by `InstanceTopologyConfig.nodeMetrics[].id`. */
   metrics: Record<string, number | null>;
+  /** The caller may not read this instance's service: its metrics were not
+   *  read, and the node says so. */
+  metricsBlocked?: boolean;
 }
 
 /** One instance-to-instance call. Same per-side metric shape as
@@ -360,6 +368,9 @@ export interface EndpointDependencyNode {
   respTime: number | null;
   /** @deprecated use `metrics['sla']`. */
   sla: number | null;
+  /** The caller may not read this endpoint's service: its metrics were not
+   *  read, and the node says so. */
+  metricsBlocked?: boolean;
 }
 
 export interface EndpointDependencyCall {
@@ -377,6 +388,9 @@ export interface EndpointDependencyCall {
   cpm: number | null;
   /** @deprecated convenience read. */
   respTime: number | null;
+  /** The caller reads neither end's service: the call is drawn without its
+   *  values. */
+  metricsBlocked?: boolean;
 }
 
 export interface EndpointDependencyResponse {