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

wu-sheng pushed a commit to branch refactor/drop-bff-state-files
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 0d2d473283d4fa792563f2ddf27305f95b25c724
Author: Wu Sheng <[email protected]>
AuthorDate: Mon Jul 6 00:51:20 2026 +0800

    refactor(config): move per-layer + alarm setup to OAP, add an audit toggle; 
always-show the AI assistant
    
    Two operator-facing cleanups from the same review.
    
    State-file / config rework — config lives in OAP, not local BFF files:
    - Remove the dead per-layer setup override store (SetupStore + /api/setup +
      setup.file/HORIZON_SETUP_FILE). Nothing wrote it and the layer loader 
never
      read it; per-layer config is authored in the layer-dashboards template 
and the
      browser resolves the rendered LayerConfig from those template defaults.
    - The alarm-page setup (pinned layers / default window / overview fetch cap 
— the
      whole horizon.alert.page-setup singleton) is OAP-only now: 
bff.alarms.config()
      reads its effective copy from the config-bundle sync status like the 
theme /
      time-defaults singletons, and the editor saves only via 
templateSync.save. The
      redundant local read-cache (AlarmsStore / horizon-alarms.json), the
      /api/alarms/config route, and alarms.file/HORIZON_ALARMS_FILE are gone; 
the
      Alert-page lede drops its stale "defaults are GENERAL + MESH" note.
    - Add audit.enabled (env HORIZON_AUDIT_ENABLED, default true) so the audit 
trail
      can be disabled entirely — matching debugLog's enabled + file shape. 
Prune the
      now-dead setup/alarms .gitignore entries.
    
    AI assistant launcher — discoverable to everyone:
    - Show the launcher + panel for every signed-in user. Until a model is
      configured the panel is read-only with an "ask your administrator to set 
it up"
      notice (docked drawer and full /ai page). The config probe is 
auth-readable so
      the panel reflects readiness for all; ai:read is enforced on the chat 
request
      (rejected on send, with a clear message), granted to every role by 
default.
    
    Not yet validated against a live OAP: the alarm-page-setup read/write 
repoint
    needs a save round-trip smoke on a real OAP (the read path renders 
correctly).
---
 .gitignore                                         |   2 -
 CHANGELOG.md                                       |   4 +-
 Dockerfile                                         |   2 -
 apps/bff/src/audit/logger.ts                       |  12 +-
 apps/bff/src/config/schema.test.ts                 |   4 +-
 apps/bff/src/config/schema.ts                      |  23 +--
 apps/bff/src/http/config/alarms.ts                 | 130 -------------
 apps/bff/src/http/config/setup.ts                  | 121 ------------
 apps/bff/src/logic/alarms/bundled.ts               |  13 +-
 apps/bff/src/logic/alarms/store.ts                 | 188 ------------------
 apps/bff/src/logic/layers/loader.ts                |   4 +-
 apps/bff/src/logic/setup/store.ts                  |  92 ---------
 apps/bff/src/rbac/route-policy.test.ts             |   3 -
 apps/bff/src/rbac/route-policy.ts                  |  25 ++-
 apps/bff/src/server.ts                             |  12 +-
 apps/ui/src/ai/AiChatPanel.vue                     |  72 ++++++-
 apps/ui/src/ai/AiFullPageView.vue                  |  67 ++++++-
 apps/ui/src/ai/useAiChat.ts                        |  23 ++-
 apps/ui/src/api/alarmsConfig.ts                    |  87 +++++++++
 apps/ui/src/api/client.ts                          |  43 ++--
 apps/ui/src/api/scopes/alarms.test.ts              |  48 +++--
 apps/ui/src/api/scopes/alarms.ts                   |  19 +-
 apps/ui/src/api/scopes/setup.ts                    |  31 ---
 .../admin/alert-page/AlertPageSetupView.vue        |  18 +-
 apps/ui/src/i18n/locales/de.json                   |   7 +-
 apps/ui/src/i18n/locales/en.json                   |   7 +-
 apps/ui/src/i18n/locales/es.json                   |   7 +-
 apps/ui/src/i18n/locales/fr.json                   |   7 +-
 apps/ui/src/i18n/locales/ja.json                   |   7 +-
 apps/ui/src/i18n/locales/ko.json                   |   7 +-
 apps/ui/src/i18n/locales/pt.json                   |   7 +-
 apps/ui/src/i18n/locales/zh-CN.json                |   7 +-
 apps/ui/src/state/setup.ts                         | 216 ++-------------------
 docs/access-control/audit-log.md                   |   4 +-
 docs/design-target.md                              |   2 +-
 docs/operate/ai-assistant.md                       |   2 +-
 docs/setup/audit.md                                |   2 +
 docs/setup/container-image.md                      |  14 +-
 docs/setup/files.md                                |  42 ++--
 docs/setup/overview.md                             |   2 -
 horizon.yaml                                       |  15 +-
 packages/api-client/src/index.ts                   |   2 -
 packages/api-client/src/setup.ts                   |  21 +-
 43 files changed, 434 insertions(+), 987 deletions(-)

diff --git a/.gitignore b/.gitignore
index bc3b51b..6d991e5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,8 +44,6 @@ Thumbs.db
 # BFF runtime files (horizon.yaml itself is committed — the env-driven config)
 horizon-audit.jsonl
 horizon-wire.jsonl
-horizon-setup.json
-horizon-alarms.json
 
 # Release
 scripts/.release-work
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 74866f0..23a652b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,7 +14,7 @@ The version line is shared by every package in the monorepo 
(apps + shared packa
 - **Read live Kubernetes pod logs, right in the chat.** For a k8s workload the 
assistant pulls a pod container's on-demand logs (the error stack) and shows 
the fetched lines inline as a read-only result — the same on-demand-log path as 
the Pod Logs tab, so nothing is stored and it inherits your `logs:read` 
permission. It's a result, not a console: no tail or refresh controls (operate 
a live tail in the Pod Logs tab); when a content filter was applied the block 
shows it, so an empty resul [...]
 - **It can propose profiling — and only you start it.** When metrics and 
traces can't localise a cause, the assistant presents a **decision card** 
explaining what it found, why profiling would help, and what it expects to 
reveal; nothing runs until you **approve it in the popout**, and only if you 
hold the profiling permission. On approve it starts the trace-profiling task; 
you then ask it to analyze the results once they've collected. It never 
triggers anything on its own.
 - **Guided root-cause analysis.** Ask "what's the root cause?" and the 
assistant follows built-in investigation playbooks — a master method (locate 
the root service → calling chain → error stack; walk the dependency topology 
upstream and fix a sick upstream first; a remote / Virtual_* dependency exposes 
only its client-side edge metric) plus latency, error-rate/SLA, saturation, 
middleware, **Kubernetes-workload**, and service-mesh specializations. It can 
also follow the **cross-layer hie [...]
-- **Bring your own LLM — vendor-neutral, and off by default.** Enable it with 
the new `ai:` config block (`HORIZON_AI_*`). The default transport is 
**OpenAI-compatible** (any OpenAI-shaped endpoint — a hosted model, a local 
model, or an AI gateway; set model + base URL + API key); **Amazon Bedrock** is 
also supported (`provider: bedrock`). The API key is a secret, env-only, 
redacted from logs and excluded from the audit trail. The launcher stays hidden 
until the feature is enabled and a  [...]
+- **Bring your own LLM — vendor-neutral, and off by default.** Enable it with 
the new `ai:` config block (`HORIZON_AI_*`). The default transport is 
**OpenAI-compatible** (any OpenAI-shaped endpoint — a hosted model, a local 
model, or an AI gateway; set model + base URL + API key); **Amazon Bedrock** is 
also supported (`provider: bedrock`). The API key is a secret, env-only, 
redacted from logs and excluded from the audit trail. The launcher shows for 
every signed-in user so the AI-powered [...]
 - **Both prompts are yours.** The assistant's system prompt 
(`ai.systemPrompt`) and the starter example chips shown in an empty chat 
(`ai.starters`) ship with sensible defaults and can be replaced entirely in 
`horizon.yaml`.
 - **Starter chips can ask for a service first.** A starter that names a 
`<service>` (e.g. "Investigate latency for &lt;service&gt;") opens a one-field 
fill-in on click: type the service free-form — a partial name or a description 
is fine — and it's dropped into the prompt before sending. You don't have to 
know the exact name or its layer; the assistant matches what you typed to a 
real service through its own cross-layer search. Plain starters without a 
placeholder still send in one click [...]
 
@@ -26,6 +26,8 @@ The version line is shared by every package in the monorepo 
(apps + shared packa
 
 - **Run on the bundled templates, read-only — no OAP ui_template API needed.** 
A new `templates.mode` setting (`HORIZON_TEMPLATES_MODE`) adds a `readonly` 
mode: Horizon renders every dashboard / overview / alert-page / 3D-map / 
translation from the **local bundle** and never calls OAP's ui_template admin 
API. The whole config surface goes **read-only** — the admin pages still open 
and show the bundled config, but editing and publishing are disabled (and the 
BFF rejects a write even if it [...]
 - **The container image runs with environment variables only — no mounted 
config file.** There is now **one committed, env-driven `horizon.yaml`** (the 
former `horizon.example.yaml` / local-copy split is gone): every field is a 
`${HORIZON_…:default}` token, and the image bakes that same file. So `docker 
run -e HORIZON_OAP_QUERY_URL=… -e HORIZON_AUTH_LOCAL_USERS='[…]' …` is enough — 
no `-v` mount, no repackaging. Previously `oap.*`, `auth.*`, users, LDAP, RBAC, 
and performance tuning were [...]
+- **Per-layer and alarm-page setup live only in OAP now — two local state 
files retired.** Horizon no longer writes `horizon-setup.json` or 
`horizon-alarms.json`: the `setup.file` / `HORIZON_SETUP_FILE` config is gone 
(the per-layer override it backed was unused — per-layer config is authored in 
the layer-dashboards template), and the **alarm-page setup** (pinned layers, 
default window, overview fetch cap) now reads and writes only OAP's 
`horizon.alert.page-setup` template, exactly like  [...]
+- **Audit logging can be turned off.** `audit.enabled` (env 
`HORIZON_AUDIT_ENABLED`, default `true`) — the audit trail stays on by default 
(it is a security record) but can be disabled entirely, mirroring `debugLog`'s 
enabled + file shape.
 - **Cluster Status now reports admin-feature reachability, not just 
config-presence.** The admin-host pane fires a safe GET at the real REST path 
each feature calls on OAP — dashboard templates → `/ui-management/templates`, 
DSL management → `/runtime/rule/list`, live debugger → `/dsl-debugging/status`, 
Inspect → `/inspect/metrics` — and colors each row by whether that path 
actually responds. A feature whose module is loaded but whose endpoint 404s (a 
renamed or forked module, a selector  [...]
 
 ### General Service — PHP runtime (PHM)
diff --git a/Dockerfile b/Dockerfile
index aa8238c..ad8ce6d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -81,8 +81,6 @@ ENV NODE_ENV=production \
     HORIZON_STATIC_DIR=/app/static \
     HORIZON_CONFIG=/app/horizon.yaml \
     HORIZON_AUDIT_FILE=/data/horizon-audit.jsonl \
-    HORIZON_SETUP_FILE=/data/horizon-setup.json \
-    HORIZON_ALARMS_FILE=/data/horizon-alarms.json \
     HORIZON_WIRE_LOG_FILE=/data/horizon-wire.jsonl \
     HORIZON_SOURCEMAPS_DIR=/app/sourcemaps \
     # Match this to the container memory limit and your sourceMaps budget — 
the in-heap map cache lives inside it.
diff --git a/apps/bff/src/audit/logger.ts b/apps/bff/src/audit/logger.ts
index 325c656..46d6d04 100644
--- a/apps/bff/src/audit/logger.ts
+++ b/apps/bff/src/audit/logger.ts
@@ -38,18 +38,28 @@ export interface AuditEvent {
 export class AuditLogger {
   private stream: WriteStream | null = null;
   private readonly absPath: string;
+  /** When false (`audit.enabled=false`) the logger is a no-op — no file is
+   *  opened and `record()` drops every event. On by default: the audit trail 
is
+   *  a security record, so silencing it takes an explicit opt-out. */
+  private readonly enabled: boolean;
 
-  constructor(filePath: string) {
+  constructor(filePath: string, enabled = true) {
     this.absPath = resolve(filePath);
+    this.enabled = enabled;
   }
 
   async open(): Promise<void> {
+    if (!this.enabled) {
+      logger.info('audit disabled (audit.enabled=false) — no audit log is 
written');
+      return;
+    }
     await mkdir(dirname(this.absPath), { recursive: true });
     this.stream = createWriteStream(this.absPath, { flags: 'a' });
     this.stream.on('error', (err) => logger.error({ err }, 'audit stream 
error'));
   }
 
   record(evt: Omit<AuditEvent, 'ts'>): void {
+    if (!this.enabled) return;
     const line: AuditEvent = { ts: new Date().toISOString(), ...evt };
     if (!this.stream) {
       logger.warn({ evt: line }, 'audit logged before open()');
diff --git a/apps/bff/src/config/schema.test.ts 
b/apps/bff/src/config/schema.test.ts
index ae16cd0..ae5b4ea 100644
--- a/apps/bff/src/config/schema.test.ts
+++ b/apps/bff/src/config/schema.test.ts
@@ -48,8 +48,8 @@ describe('horizon.yaml — tokenized default + parity', () => {
     // in a dev/CI env is ignored (it would otherwise read as drift, since the
     // schema default for e.g. oap.queryUrl is a literal, not env-read).
     const SCHEMA_ENV = [
-      'HORIZON_SERVER_HOST', 'HORIZON_SERVER_PORT', 'HORIZON_AUDIT_FILE', 
'HORIZON_SETUP_FILE',
-      'HORIZON_ALARMS_FILE', 'HORIZON_WIRE_LOG_FILE', 
'HORIZON_SOURCEMAPS_DIR', 'HORIZON_TEMPLATES_MODE',
+      'HORIZON_SERVER_HOST', 'HORIZON_SERVER_PORT', 'HORIZON_AUDIT_FILE',
+      'HORIZON_WIRE_LOG_FILE', 'HORIZON_SOURCEMAPS_DIR', 
'HORIZON_TEMPLATES_MODE',
     ];
     const env: NodeJS.ProcessEnv = {};
     for (const k of SCHEMA_ENV) if (process.env[k] !== undefined) env[k] = 
process.env[k];
diff --git a/apps/bff/src/config/schema.ts b/apps/bff/src/config/schema.ts
index 782e145..6454506 100644
--- a/apps/bff/src/config/schema.ts
+++ b/apps/bff/src/config/schema.ts
@@ -273,30 +273,17 @@ const sessionSchema = z
 // that omits these blocks) gets writes routed to the writable `/data`
 // volume instead of `/app` (which is root-owned and EACCESes).
 const auditDefault = process.env.HORIZON_AUDIT_FILE ?? './horizon-audit.jsonl';
-const setupDefault = process.env.HORIZON_SETUP_FILE ?? './horizon-setup.json';
-const alarmsDefault = process.env.HORIZON_ALARMS_FILE ?? 
'./horizon-alarms.json';
 const wireLogDefault = process.env.HORIZON_WIRE_LOG_FILE ?? 
'./horizon-wire.jsonl';
 
 const auditSchema = z
   .object({
+    // On by default — the audit trail is a security record; disabling it is an
+    // explicit opt-out. Mirrors `debugLog.enabled` (which is opt-IN instead).
+    enabled: z.boolean().default(true),
     file: z.string().default(auditDefault),
   })
   .strict()
-  .default({ file: auditDefault });
-
-const setupSchema = z
-  .object({
-    file: z.string().default(setupDefault),
-  })
-  .strict()
-  .default({ file: setupDefault });
-
-const alarmsSchema = z
-  .object({
-    file: z.string().default(alarmsDefault),
-  })
-  .strict()
-  .default({ file: alarmsDefault });
+  .default({ enabled: true, file: auditDefault });
 
 
 const debugLogSchema = z
@@ -546,8 +533,6 @@ export const configSchema = z
     rbac: rbacSchema,
     session: sessionSchema,
     audit: auditSchema,
-    setup: setupSchema,
-    alarms: alarmsSchema,
     debugLog: debugLogSchema,
     query: querySchema,
     sourceMaps: sourceMapsSchema,
diff --git a/apps/bff/src/http/config/alarms.ts 
b/apps/bff/src/http/config/alarms.ts
deleted file mode 100644
index 467259d..0000000
--- a/apps/bff/src/http/config/alarms.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-/*
- * 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.
- */
-
-/**
- * `/api/alarms/config` — read + write the alarm-page setup. The shape
- * is `{ pinnedLayers: string[] }`: OAP layer keys that get a
- * dedicated tile + breakdown chip on the alarms page header. The
- * `serviceLayer` cache is invalidated on save so per-row layer tags
- * pick up any new layer immediately instead of waiting for the 60s
- * TTL.
- */
-
-import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
-import { z } from 'zod';
-import type { ConfigSource } from '../../config/loader.js';
-import type { SessionStore } from '../../user/sessions.js';
-import type { AuditLogger } from '../../audit/logger.js';
-import { requireAuth } from '../../user/middleware.js';
-import { isTemplateReadOnly } from '../../logic/templates/sync.js';
-import { loadBundledAlertPageSetup } from '../../logic/alarms/bundled.js';
-import type { ServiceLayerCatalog } from 
'../../logic/services/service-layer-catalog.js';
-import {
-  ALARMS_WINDOW_OPTIONS,
-  OVERVIEW_ALARMS_LIMIT_MAX,
-  OVERVIEW_ALARMS_LIMIT_MIN,
-  type AlarmsStore,
-  type AlarmsConfig,
-} from '../../logic/alarms/store.js';
-
-export interface AlarmsConfigRouteDeps {
-  config: ConfigSource;
-  sessions: SessionStore;
-  audit: AuditLogger;
-  store: AlarmsStore;
-  serviceLayer: ServiceLayerCatalog;
-}
-
-const configSaveSchema = z.object({
-  pinnedLayers: z.array(z.string().min(1)).max(8),
-  /* Locked to the page-picker preset set so the admin's pick always
-   * lights up a real tab. Hand-edited values are rejected with a 400
-   * rather than silently coerced — keeps debugging predictable. */
-  defaultWindowMs: z
-    .number()
-    .int()
-    .refine((v) => (ALARMS_WINDOW_OPTIONS as readonly number[]).includes(v), {
-      message: `must be one of ${ALARMS_WINDOW_OPTIONS.join(', ')}`,
-    }),
-  /* Bounded range — too low starves the widget's incident merge of
-   * variety; too high pulls more bytes per poll than necessary. */
-  overviewAlarmsLimit: z
-    .number()
-    .int()
-    .min(OVERVIEW_ALARMS_LIMIT_MIN)
-    .max(OVERVIEW_ALARMS_LIMIT_MAX),
-});
-
-export function registerAlarmsConfigRoutes(
-  app: FastifyInstance,
-  deps: AlarmsConfigRouteDeps,
-): void {
-  const auth = requireAuth(deps);
-
-  app.get(
-    '/api/alarms/config',
-    { preHandler: auth },
-    async (_req: FastifyRequest, reply: FastifyReply) => {
-      // Readonly mode serves the alert page-setup from the bundle like every
-      // other template; the local store (a prior live session's edits) is not
-      // the source here. Writes are denied by the route-policy backstop.
-      const cfg = isTemplateReadOnly() ? loadBundledAlertPageSetup() : await 
deps.store.load();
-      return reply.send(cfg);
-    },
-  );
-
-  app.post(
-    '/api/alarms/config',
-    { preHandler: auth },
-    async (req: FastifyRequest, reply: FastifyReply) => {
-      const parsed = configSaveSchema.safeParse(req.body);
-      if (!parsed.success) {
-        return reply.code(400).send({ error: 'invalid_body', detail: 
parsed.error.flatten() });
-      }
-      /* Normalise layer keys to upper-case + dedup so the persisted
-       * shape is canonical regardless of what the admin form sent. */
-      const seen = new Set<string>();
-      const pinnedLayers: string[] = [];
-      for (const raw of parsed.data.pinnedLayers) {
-        const k = raw.trim().toUpperCase();
-        if (!k || seen.has(k)) continue;
-        seen.add(k);
-        pinnedLayers.push(k);
-      }
-      const next: AlarmsConfig = {
-        pinnedLayers,
-        defaultWindowMs: parsed.data.defaultWindowMs,
-        overviewAlarmsLimit: parsed.data.overviewAlarmsLimit,
-      };
-      await deps.store.save(next);
-      deps.serviceLayer.invalidate();
-      deps.audit.record({
-        action: 'alarms.config.save',
-        actor: req.session?.username ?? null,
-        outcome: 'ok',
-        details: {
-          pinnedLayers: next.pinnedLayers,
-          defaultWindowMs: next.defaultWindowMs,
-          overviewAlarmsLimit: next.overviewAlarmsLimit,
-        },
-        fromIp: req.ip,
-        sessionId: req.session?.sid,
-      });
-      return reply.send(next);
-    },
-  );
-}
diff --git a/apps/bff/src/http/config/setup.ts 
b/apps/bff/src/http/config/setup.ts
deleted file mode 100644
index 6c473ba..0000000
--- a/apps/bff/src/http/config/setup.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-/*
- * 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 type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
-import { z } from 'zod';
-import type { SetupResponse, SetupSavePayload } from 
'@skywalking-horizon-ui/api-client';
-import type { AuditLogger } from '../../audit/logger.js';
-import { requireAuth } from '../../user/middleware.js';
-import type { ConfigSource } from '../../config/loader.js';
-import type { SessionStore } from '../../user/sessions.js';
-import { badRequest } from '../../errors.js';
-import type { SetupStore } from '../../logic/setup/store.js';
-
-export interface SetupRouteDeps {
-  config: ConfigSource;
-  sessions: SessionStore;
-  audit: AuditLogger;
-  store: SetupStore;
-}
-
-const aggregationSchema = z.enum(['sum', 'avg']);
-
-const landingColumnSchema = z
-  .object({
-    metric: z.string().min(1),
-    label: z.string().min(1),
-    unit: z.string().optional(),
-    mqe: z.string().optional(),
-    aggregation: aggregationSchema.optional(),
-    scale: z.number().finite().optional(),
-    precision: z.number().int().min(0).max(6).optional(),
-  })
-  .strict();
-
-const landingSchema = z
-  .object({
-    priority: z.number().int().min(0).max(99),
-    topN: z.number().int().min(5).max(8),
-    orderBy: z.string().min(1),
-    columns: z.array(landingColumnSchema).max(5),
-    style: z.enum(['table', 'bar', 'mini-topology']),
-  })
-  // `.strip()` (not `.strict()`): setups persisted by older builds carry the
-  // retired `spark` / `throughput` keys — drop them silently on re-save
-  // instead of failing the whole payload.
-  .strip();
-
-const layerConfigSchema = z
-  .object({
-    displayName: z.string().optional(),
-    slots: z
-      .object({
-        services: z.string().optional(),
-        instances: z.string().optional(),
-        endpoints: z.string().optional(),
-        endpointDependency: z.string().optional(),
-      })
-      .strict(),
-    caps: z
-      .object({
-        serviceMap: z.boolean().optional(),
-        endpointDependency: z.boolean().optional(),
-        instanceTopology: z.boolean().optional(),
-        processTopology: z.boolean().optional(),
-        dashboards: z.boolean().optional(),
-        traces: z.boolean().optional(),
-        logs: z.boolean().optional(),
-        profiling: z.boolean().optional(),
-        events: z.boolean().optional(),
-      })
-      .strict(),
-    landing: landingSchema,
-  })
-  .strict();
-
-const savePayloadSchema = z
-  .object({
-    layers: z.record(z.string().min(1), layerConfigSchema),
-  })
-  .strict();
-
-export function registerSetupRoutes(app: FastifyInstance, deps: 
SetupRouteDeps): void {
-  const auth = requireAuth(deps);
-
-  app.get('/api/setup', { preHandler: auth }, async (_req: FastifyRequest, 
reply: FastifyReply) => {
-    const layers = await deps.store.load();
-    const body: SetupResponse = { generatedAt: Date.now(), layers };
-    return reply.send(body);
-  });
-
-  app.post('/api/setup', { preHandler: auth }, async (req: FastifyRequest, 
reply: FastifyReply) => {
-    const parsed = savePayloadSchema.safeParse(req.body);
-    if (!parsed.success) {
-      throw badRequest('invalid setup payload', parsed.error.flatten());
-    }
-    const payload = parsed.data as SetupSavePayload;
-    await deps.store.save(payload.layers);
-    deps.audit.record({
-      actor: req.session?.username ?? null,
-      action: 'setup.save',
-      outcome: 'success',
-      details: { layerCount: Object.keys(payload.layers).length },
-    });
-    const body: SetupResponse = { generatedAt: Date.now(), layers: 
payload.layers };
-    return reply.send(body);
-  });
-}
diff --git a/apps/bff/src/logic/alarms/bundled.ts 
b/apps/bff/src/logic/alarms/bundled.ts
index 40a89ba..b4e658e 100644
--- a/apps/bff/src/logic/alarms/bundled.ts
+++ b/apps/bff/src/logic/alarms/bundled.ts
@@ -29,7 +29,18 @@
 import { existsSync, readFileSync } from 'node:fs';
 import { dirname, resolve } from 'node:path';
 import { fileURLToPath } from 'node:url';
-import type { AlarmsConfig } from './store.js';
+
+/** The Alert page-setup config — the whole of the `horizon.alert.page-setup`
+ *  template. Authored in the Alert-page admin, persisted to OAP like every 
other
+ *  template; the UI reads the effective (bundled ↔ OAP) copy from the config
+ *  bundle's sync status. `pinnedLayers` get a header KPI tile on the Alarms 
page;
+ *  `defaultWindowMs` is the shared default window for the badge + page + 
widget;
+ *  `overviewAlarmsLimit` caps the overview "Active alarms" widget's per-poll 
fetch. */
+export interface AlarmsConfig {
+  pinnedLayers: string[];
+  defaultWindowMs: number;
+  overviewAlarmsLimit: number;
+}
 
 const HERE = dirname(fileURLToPath(import.meta.url));
 
diff --git a/apps/bff/src/logic/alarms/store.ts 
b/apps/bff/src/logic/alarms/store.ts
deleted file mode 100644
index df7603f..0000000
--- a/apps/bff/src/logic/alarms/store.ts
+++ /dev/null
@@ -1,188 +0,0 @@
-/*
- * 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.
- */
-
-/**
- * File-backed store for the Alarms-page setup. Holds the list of OAP
- * layers that get their own dedicated KPI tile + breakdown chip on the
- * alarms page header (the "pinned" layers). Everything not on this
- * list shows up under the overflow-chip row when it has at least one
- * alarm in the visible window.
- *
- * Defaults seed agent (`GENERAL`) + mesh (`MESH`) so a fresh install
- * has sensible top-of-page tiles out-of-the-box.
- *
- * Backward compatibility: an older shape stored `trafficLayers:
- * [{layerKey, mqe, label}]` for the (now-removed) traffic-backdrop
- * chart. The loader silently projects that down to `pinnedLayers`
- * (just the layer keys, deduped, order preserved) so operators don't
- * have to re-pick their layers after upgrade.
- *
- * Storage convention mirrors `SetupStore`: serialised writes with a
- * `.tmp` rename so the file never appears half-written to a watcher.
- */
-
-import { existsSync } from 'node:fs';
-import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
-import { dirname, resolve } from 'node:path';
-import { logger } from '../../logger.js';
-
-export interface AlarmsConfig {
-  /** OAP layer keys (canonical `GENERAL`, `MESH`, `K8S_SERVICE`, …)
-   *  that get a dedicated tile on the alarms page header. The order
-   *  here is the render order on the page. Defaults to
-   *  `['GENERAL', 'MESH']`. */
-  pinnedLayers: string[];
-  /** Default time window in milliseconds for the topbar alarm badge
-   *  AND the alarms page's initial picker selection. The overview
-   *  "Active alarms" widget keeps its own 60m window; this only
-   *  affects the recent-triage surfaces. Must be one of the page
-   *  picker presets — 20m / 2h / 4h. Defaults to 20m. */
-  defaultWindowMs: number;
-  /** Fetch cap for the overview "Active alarms" widget. The widget
-   *  pulls this many rows from `/api/alarms` per poll, merges them
-   *  into incidents client-side, and surfaces the top `widget.limit`
-   *  rows. Higher caps catch more incident variety in noisy
-   *  installs at the cost of more wire bytes per poll. Defaults to
-   *  200; must be in [10, 500]. */
-  overviewAlarmsLimit: number;
-}
-
-/** Allowed values for `defaultWindowMs`. Matches the alarms page's
- *  preset list so the admin's choice always lights up an existing
- *  tab on the page. */
-export const ALARMS_WINDOW_OPTIONS = [
-  20 * 60_000,
-  2 * 60 * 60_000,
-  4 * 60 * 60_000,
-] as const;
-
-export const OVERVIEW_ALARMS_LIMIT_MIN = 10;
-export const OVERVIEW_ALARMS_LIMIT_MAX = 500;
-export const OVERVIEW_ALARMS_LIMIT_DEFAULT = 200;
-
-const DEFAULT_CONFIG: AlarmsConfig = {
-  pinnedLayers: ['GENERAL', 'MESH'],
-  defaultWindowMs: ALARMS_WINDOW_OPTIONS[0],
-  overviewAlarmsLimit: OVERVIEW_ALARMS_LIMIT_DEFAULT,
-};
-
-/* Legacy shape — kept only as a tolerant input type for `load()`. New
- * code reads / writes `pinnedLayers` exclusively; the next `save()`
- * after an upgraded load rewrites the file in the new shape. */
-interface LegacyTrafficLayer {
-  layerKey?: unknown;
-}
-
-export class AlarmsStore {
-  private readonly absPath: string;
-  private cache: AlarmsConfig | null = null;
-  private writing: Promise<void> | null = null;
-
-  constructor(filePath: string) {
-    this.absPath = resolve(filePath);
-  }
-
-  async load(): Promise<AlarmsConfig> {
-    if (this.cache) return this.cache;
-    if (!existsSync(this.absPath)) {
-      this.cache = { ...DEFAULT_CONFIG, pinnedLayers: 
[...DEFAULT_CONFIG.pinnedLayers] };
-      return this.cache;
-    }
-    try {
-      const raw = await readFile(this.absPath, 'utf8');
-      const parsed = JSON.parse(raw) as {
-        pinnedLayers?: unknown;
-        trafficLayers?: unknown;
-        defaultWindowMs?: unknown;
-        overviewAlarmsLimit?: unknown;
-      };
-
-      let pinned: string[] | null = null;
-      if (Array.isArray(parsed.pinnedLayers)) {
-        pinned = parsed.pinnedLayers.filter(
-          (v): v is string => typeof v === 'string' && v.length > 0,
-        );
-      } else if (Array.isArray(parsed.trafficLayers)) {
-        // Project legacy `trafficLayers: [{layerKey,...}]` down to the
-        // pinned-layer set. Dedup + preserve first-seen order so the
-        // header reads the same as the prior traffic-chart order.
-        const seen = new Set<string>();
-        const out: string[] = [];
-        for (const l of parsed.trafficLayers as LegacyTrafficLayer[]) {
-          const k = typeof l?.layerKey === 'string' ? l.layerKey : '';
-          if (!k || seen.has(k)) continue;
-          seen.add(k);
-          out.push(k);
-        }
-        pinned = out.length > 0 ? out : null;
-      }
-
-      /* defaultWindowMs is one of the page-picker preset values; any
-       * out-of-allowlist value (file edited by hand, drifted enum)
-       * falls back to the default. */
-      const rawWin = typeof parsed.defaultWindowMs === 'number' ? 
parsed.defaultWindowMs : NaN;
-      const defaultWindowMs = (ALARMS_WINDOW_OPTIONS as readonly 
number[]).includes(rawWin)
-        ? rawWin
-        : DEFAULT_CONFIG.defaultWindowMs;
-
-      const rawLim = typeof parsed.overviewAlarmsLimit === 'number' ? 
parsed.overviewAlarmsLimit : NaN;
-      const overviewAlarmsLimit =
-        Number.isInteger(rawLim) &&
-        rawLim >= OVERVIEW_ALARMS_LIMIT_MIN &&
-        rawLim <= OVERVIEW_ALARMS_LIMIT_MAX
-          ? rawLim
-          : DEFAULT_CONFIG.overviewAlarmsLimit;
-
-      this.cache = {
-        pinnedLayers: pinned ?? [...DEFAULT_CONFIG.pinnedLayers],
-        defaultWindowMs,
-        overviewAlarmsLimit,
-      };
-      return this.cache;
-    } catch (err) {
-      logger.warn({ err, path: this.absPath }, 'alarms store unreadable; using 
defaults');
-      this.cache = { ...DEFAULT_CONFIG, pinnedLayers: 
[...DEFAULT_CONFIG.pinnedLayers] };
-      return this.cache;
-    }
-  }
-
-  async save(next: AlarmsConfig): Promise<void> {
-    while (this.writing) await this.writing;
-    const tmp = `${this.absPath}.tmp`;
-    const snapshot: AlarmsConfig = {
-      pinnedLayers: [...next.pinnedLayers],
-      defaultWindowMs: next.defaultWindowMs,
-      overviewAlarmsLimit: next.overviewAlarmsLimit,
-    };
-    const work = (async () => {
-      await mkdir(dirname(this.absPath), { recursive: true });
-      await writeFile(
-        tmp,
-        JSON.stringify({ generatedAt: Date.now(), ...snapshot }, null, 2),
-        'utf8',
-      );
-      await rename(tmp, this.absPath);
-      this.cache = snapshot;
-    })();
-    this.writing = work;
-    try {
-      await work;
-    } finally {
-      this.writing = null;
-    }
-  }
-}
diff --git a/apps/bff/src/logic/layers/loader.ts 
b/apps/bff/src/logic/layers/loader.ts
index 8d6d8fd..080d036 100644
--- a/apps/bff/src/logic/layers/loader.ts
+++ b/apps/bff/src/logic/layers/loader.ts
@@ -21,8 +21,8 @@
  * shape (landing card columns, dashboard widgets, MQE expressions).
  *
  * The bundled defaults live under `../bundled_templates/layers/<key>.json`,
- * one file per OAP layer enum. Operator overrides land in the SetupStore
- * (JSON file on disk) and merge on top.
+ * one file per OAP layer enum. Operator edits are authored in the Layer
+ * dashboards admin and persisted to OAP as `horizon.layer.<KEY>` templates.
  *
  * Lifting these from TS code into JSON gets us:
  *   - One file per layer to review or copy
diff --git a/apps/bff/src/logic/setup/store.ts 
b/apps/bff/src/logic/setup/store.ts
deleted file mode 100644
index fbabdb5..0000000
--- a/apps/bff/src/logic/setup/store.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-/*
- * 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 { existsSync } from 'node:fs';
-import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
-import { dirname, resolve } from 'node:path';
-import type { LayerConfig } from '@skywalking-horizon-ui/api-client';
-import { logger } from '../../logger.js';
-
-/**
- * File-backed store for per-layer setup overrides.
- *
- * Holds only what the operator has changed away from defaults. Defaults
- * live in horizon's frontend (and a soon-to-come BFF defaults table) so
- * the stored JSON stays sparse and human-readable.
- *
- * Future swap-point: replace this implementation with one that writes
- * to OAP via `addTemplate` mutations under the `horizon-` prefix, once
- * horizon's runtime template format is ready and operators have
- * `core.enableUpdateUITemplate: true` flipped on. The interface below
- * is what callers code against.
- */
-export class SetupStore {
-  private readonly absPath: string;
-  private cache: Record<string, LayerConfig> | null = null;
-  private writing: Promise<void> | null = null;
-
-  constructor(filePath: string) {
-    this.absPath = resolve(filePath);
-  }
-
-  async load(): Promise<Record<string, LayerConfig>> {
-    if (this.cache) return this.cache;
-    if (!existsSync(this.absPath)) {
-      this.cache = {};
-      return this.cache;
-    }
-    try {
-      const raw = await readFile(this.absPath, 'utf8');
-      const parsed = JSON.parse(raw) as { layers?: Record<string, LayerConfig> 
} | Record<string, LayerConfig>;
-      // Tolerate both `{layers: {...}}` and the bare map for forward-compat.
-      const layers = (parsed as { layers?: Record<string, LayerConfig> 
}).layers ?? (parsed as Record<string, LayerConfig>);
-      this.cache = layers && typeof layers === 'object' ? layers : {};
-      return this.cache;
-    } catch (err) {
-      logger.warn({ err, path: this.absPath }, 'setup store unreadable; 
starting empty');
-      this.cache = {};
-      return this.cache;
-    }
-  }
-
-  async save(layers: Record<string, LayerConfig>): Promise<void> {
-    // Serialize writes — multiple concurrent POSTs from the UI shouldn't
-    // race against each other.
-    while (this.writing) await this.writing;
-    const tmp = `${this.absPath}.tmp`;
-    const next: Record<string, LayerConfig> = 
JSON.parse(JSON.stringify(layers));
-    const work = (async () => {
-      await mkdir(dirname(this.absPath), { recursive: true });
-      await writeFile(
-        tmp,
-        JSON.stringify({ generatedAt: Date.now(), layers: next }, null, 2),
-        'utf8',
-      );
-      await rename(tmp, this.absPath);
-      this.cache = next;
-    })();
-    this.writing = work.finally(() => {
-      this.writing = null;
-    });
-    await this.writing;
-  }
-
-  /** Force reload from disk — useful when an admin edits the JSON externally. 
*/
-  invalidate(): void {
-    this.cache = null;
-  }
-}
diff --git a/apps/bff/src/rbac/route-policy.test.ts 
b/apps/bff/src/rbac/route-policy.test.ts
index 750dc3a..7997356 100644
--- a/apps/bff/src/rbac/route-policy.test.ts
+++ b/apps/bff/src/rbac/route-policy.test.ts
@@ -27,13 +27,10 @@ describe('isTemplateWriteRoute — which routes the readonly 
backstop covers', (
     expect(isTemplateWriteRoute('POST', 
'/api/admin/templates/sync-all')).toBe(true);
     expect(isTemplateWriteRoute('POST', 
'/api/admin/overview-templates')).toBe(true);
     expect(isTemplateWriteRoute('DELETE', 
'/api/admin/overview-templates/x')).toBe(true);
-    // The alert page-setup (horizon.alert.page-setup) is config-surface too.
-    expect(isTemplateWriteRoute('POST', '/api/alarms/config')).toBe(true);
   });
   it('does NOT match reads, nor non-config writes (runtime-rule / live-debug 
stay editable)', () => {
     expect(isTemplateWriteRoute('GET', 
'/api/admin/templates/sync-status')).toBe(false);
     expect(isTemplateWriteRoute('HEAD', 
'/api/admin/templates/sync-status')).toBe(false);
-    expect(isTemplateWriteRoute('GET', '/api/alarms/config')).toBe(false); // 
read stays open
     expect(isTemplateWriteRoute('POST', '/api/rule')).toBe(false); // runtime 
rule
     expect(isTemplateWriteRoute('POST', '/api/debug/session')).toBe(false); // 
live-debug
   });
diff --git a/apps/bff/src/rbac/route-policy.ts 
b/apps/bff/src/rbac/route-policy.ts
index bc946c0..8e0a861 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -33,18 +33,17 @@ import { logger } from '../logger.js';
 
 export type RoutePolicy = 'public' | 'auth' | string;
 
-/** A config-surface write. The template routes push to OAP's ui_template 
store;
- *  `/api/alarms/config` writes the alert page-setup (the 
`horizon.alert.page-setup`
- *  template, file-backed). In `templates.mode=readonly` the whole config 
surface
- *  is served from the local bundle and read-only, so these are denied at the 
edge
+/** A config-surface write. The template routes push to OAP's ui_template store
+ *  (the alert page-setup rides this path too, as the 
`horizon.alert.page-setup`
+ *  singleton). In `templates.mode=readonly` the whole config surface is served
+ *  from the local bundle and read-only, so these are denied at the edge
  *  regardless of the verb grant (the UI hides them too, but a direct request 
must
  *  still fail — the BFF is the authority). */
 export function isTemplateWriteRoute(method: string, url: string): boolean {
   if (method === 'GET' || method === 'HEAD') return false;
   return (
     url.startsWith('/api/admin/templates') ||
-    url.startsWith('/api/admin/overview-templates') ||
-    url.startsWith('/api/alarms/config')
+    url.startsWith('/api/admin/overview-templates')
   );
 }
 export async function denyTemplateWriteWhenReadOnly(
@@ -168,9 +167,13 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
   'GET /api/infra-3d/config':                      'infra-3d:read',
   'POST /api/infra-3d/metrics':                    'infra-3d:read',
 
-  // AI assistant. Chat entry is read-tier; each of the agent's data tools
-  // ALSO checks its own read verb, so the agent inherits the caller's scopes.
-  'GET /api/ai/config':                            'ai:read',
+  // AI assistant. The config probe is readable by any signed-in user so the
+  // launcher shows for everyone (discoverable) and the panel reflects whether 
a
+  // model is configured; `ai:read` is enforced on the CHAT request itself — a
+  // user without it still sees the launcher, but their send is rejected (403).
+  // Each of the agent's data tools ALSO checks its own read verb, so the agent
+  // inherits the caller's scopes.
+  'GET /api/ai/config':                            'auth',
   'POST /api/ai/chat':                             'ai:read',
 
   // ── Maintainer — platform-monitoring reads ──
@@ -185,10 +188,6 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
   'GET /api/oap/config':                           'config:read',
 
   // ── Operator — config / dashboard / rule / diagnostics writes ──
-  'GET /api/setup':                                'setup:read',
-  'POST /api/setup':                               'setup:write',
-  'GET /api/alarms/config':                        'alarm-setup:read',
-  'POST /api/alarms/config':                       'alarm-setup:write',
 
   'GET /api/admin/layer-templates':                'dashboard:read',
 
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index fae5a81..fda8bf5 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -61,10 +61,8 @@ import { registerAsyncProfileRoutes } from 
'./http/query/async-profile.js';
 import { registerDashboardConfigRoute } from './http/config/dashboard.js';
 import { registerLayerTemplateRoutes } from './http/config/layer-template.js';
 import { startLayerTemplateWatcher } from './logic/layers/loader.js';
-import { registerAlarmsConfigRoutes } from './http/config/alarms.js';
 import { registerInfra3dConfigRoutes } from './http/config/infra-3d.js';
 import { registerInfra3dMetricsRoute } from './http/query/infra-3d-metrics.js';
-import { registerSetupRoutes } from './http/config/setup.js';
 import { registerOverviewRoutes } from './http/config/overview.js';
 import { registerConfigBundleRoute } from './http/config/bundle.js';
 import { registerTemplateSyncAdminRoutes } from 
'./http/admin/template-sync.js';
@@ -88,8 +86,6 @@ import { registerSourceMapRoutes } from 
'./http/admin/source-maps.js';
 import { registerAuthHealthRoute } from './http/auth-health.js';
 import { registerColdStageHook } from './util/duration.js';
 // Logic / stores
-import { AlarmsStore } from './logic/alarms/store.js';
-import { SetupStore } from './logic/setup/store.js';
 import { SourceMapStore } from './logic/browser-errors/store.js';
 import { serviceLayerCatalog } from 
'./logic/services/service-layer-catalog.js';
 import { HttpError } from './errors.js';
@@ -181,14 +177,10 @@ app.addHook('onSend', (_req, reply, payload, done) => {
 });
 
 const sessions = new SessionStore({ ttlMinutes: 
source.current.session.ttlMinutes });
-const audit = new AuditLogger(source.current.audit.file);
+const audit = new AuditLogger(source.current.audit.file, 
source.current.audit.enabled);
 await audit.open();
 const ldapHealth = new LdapHealth();
 const seenCache = new UserSeenCache();
-const setupStore = new SetupStore(source.current.setup.file);
-await setupStore.load();
-const alarmsStore = new AlarmsStore(source.current.alarms.file);
-await alarmsStore.load();
 // In-memory source-map cache for the Browser Errors tab (#6784). Process-
 // global (NOT per-session); statically-mounted maps are indexed once here.
 // The store reads config through a live getter so `enabled` + the budgets
@@ -312,14 +304,12 @@ registerLayerTemplateRoutes(app, { config: source, 
sessions });
 // before (each test file imports the loader; a watcher per import
 // would exhaust the fd ceiling on low-ulimit CI).
 if (process.env.NODE_ENV === 'development') startLayerTemplateWatcher();
-registerAlarmsConfigRoutes(app, { config: source, sessions, audit, store: 
alarmsStore, serviceLayer });
 registerInfra3dConfigRoutes(app, {
   config: source,
   sessions,
   uiTemplateClient: () => buildOapClients(source.current).uiTemplate(),
 });
 registerInfra3dMetricsRoute(app, { config: source, sessions });
-registerSetupRoutes(app, { config: source, sessions, audit, store: setupStore 
});
 registerOverviewRoutes(app, {
   config: source,
   sessions,
diff --git a/apps/ui/src/ai/AiChatPanel.vue b/apps/ui/src/ai/AiChatPanel.vue
index 60ab083..c3c51b9 100644
--- a/apps/ui/src/ai/AiChatPanel.vue
+++ b/apps/ui/src/ai/AiChatPanel.vue
@@ -164,15 +164,30 @@ onBeforeUnmount(() => {
           </div>
         </header>
 
-        <div class="ai-drawer__scopebar"><ChatScopeBar /></div>
+        <template v-if="chat.ready.value">
+          <div class="ai-drawer__scopebar"><ChatScopeBar /></div>
 
-        <div ref="body" class="ai-drawer__body">
-          <ChatTranscript :messages="messages" :starters="chat.starters.value" 
@ask="onSend" />
-        </div>
+          <div ref="body" class="ai-drawer__body">
+            <ChatTranscript :messages="messages" 
:starters="chat.starters.value" @ask="onSend" />
+          </div>
+
+          <footer class="ai-drawer__composer">
+            <ChatComposer :streaming="conv.streaming.value" @send="onSend" 
@stop="conv.stop()" />
+          </footer>
+        </template>
 
-        <footer class="ai-drawer__composer">
-          <ChatComposer :streaming="conv.streaming.value" @send="onSend" 
@stop="conv.stop()" />
-        </footer>
+        <!-- Read-only setup state: the launcher shows for everyone, but the 
panel
+             can only chat once an admin has configured a model provider. -->
+        <div v-else class="ai-drawer__body ai-drawer__setup">
+          <span class="ai-drawer__setup-mark" aria-hidden="true"><Icon 
name="ai" :size="30" /></span>
+          <h3 class="ai-drawer__setup-title">{{ t('The AI Assistant is not set 
up yet') }}</h3>
+          <p class="ai-drawer__setup-text">
+            {{ t('It answers questions about your services, metrics, traces 
and logs from live data — but a model provider must be configured first.') }}
+          </p>
+          <p class="ai-drawer__setup-tip">
+            {{ t('Ask your administrator to enable it and configure a model 
provider and API key.') }}
+          </p>
+        </div>
       </aside>
     </Transition>
   </Teleport>
@@ -299,6 +314,49 @@ onBeforeUnmount(() => {
   border-top: 1px solid var(--sw-line);
   background: var(--sw-bg-2);
 }
+.ai-drawer__setup {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  text-align: center;
+  gap: 12px;
+  padding: 32px 28px;
+}
+.ai-drawer__setup-mark {
+  width: 52px;
+  height: 52px;
+  display: grid;
+  place-items: center;
+  border-radius: 14px;
+  background: linear-gradient(135deg, var(--sw-accent) 0%, var(--sw-purple) 
115%);
+  color: #fff;
+  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06) inset;
+}
+.ai-drawer__setup-title {
+  margin: 0;
+  font-size: var(--sw-fs-lg);
+  font-weight: var(--sw-fw-semibold);
+  color: var(--sw-fg-0);
+}
+.ai-drawer__setup-text {
+  margin: 0;
+  max-width: 46ch;
+  font-size: var(--sw-fs-sm);
+  color: var(--sw-fg-2);
+  line-height: var(--sw-lh-normal);
+}
+.ai-drawer__setup-tip {
+  margin: 4px 0 0;
+  max-width: 46ch;
+  font-size: var(--sw-fs-sm);
+  font-weight: var(--sw-fw-medium);
+  color: var(--sw-fg-1);
+  padding: 10px 14px;
+  border: 1px solid var(--sw-line-2);
+  border-radius: 8px;
+  background: var(--sw-bg-2);
+}
 .ai-drawer__scopebar {
   flex: 0 0 auto;
   padding: 8px 12px;
diff --git a/apps/ui/src/ai/AiFullPageView.vue 
b/apps/ui/src/ai/AiFullPageView.vue
index 636718d..0c28610 100644
--- a/apps/ui/src/ai/AiFullPageView.vue
+++ b/apps/ui/src/ai/AiFullPageView.vue
@@ -69,13 +69,28 @@ function when(ts: number): string {
       <span class="aifp__mark" aria-hidden="true"><Icon name="ai" :size="16" 
/></span>
       <strong class="aifp__brand">{{ t('AI Assistant') }}</strong>
       <div class="aifp__top-acts">
-        <ChatScopeBar />
-        <button type="button" class="aifp__btn" @click="conv.newChat()"><Icon 
name="plus" :size="13" />{{ t('New chat') }}</button>
+        <template v-if="chat.ready.value">
+          <ChatScopeBar />
+          <button type="button" class="aifp__btn" 
@click="conv.newChat()"><Icon name="plus" :size="13" />{{ t('New chat') 
}}</button>
+        </template>
         <button type="button" class="aifp__btn" @click="dockToSide"><Icon 
name="expand" :size="13" />{{ t('Dock to side') }}</button>
       </div>
     </header>
 
-    <div class="aifp__main">
+    <!-- Read-only setup state: the assistant is reachable by everyone, but can
+         only chat once an admin has configured a model provider. -->
+    <div v-if="!chat.ready.value" class="aifp__setup">
+      <span class="aifp__setup-mark" aria-hidden="true"><Icon name="ai" 
:size="34" /></span>
+      <h2 class="aifp__setup-title">{{ t('The AI Assistant is not set up yet') 
}}</h2>
+      <p class="aifp__setup-text">
+        {{ t('It answers questions about your services, metrics, traces and 
logs from live data — but a model provider must be configured first.') }}
+      </p>
+      <p class="aifp__setup-tip">
+        {{ t('Ask your administrator to enable it and configure a model 
provider and API key.') }}
+      </p>
+    </div>
+
+    <div v-else class="aifp__main">
       <aside class="aifp__hist">
         <div class="aifp__hist-head">{{ t('History') }}</div>
         <div v-if="ordered.length === 0" class="aifp__hist-empty">{{ t('No 
conversations yet.') }}</div>
@@ -277,4 +292,50 @@ function when(ts: number): string {
   max-width: 900px;
   margin: 0 auto;
 }
+
+.aifp__setup {
+  flex: 1 1 auto;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  text-align: center;
+  gap: 14px;
+  padding: 40px;
+}
+.aifp__setup-mark {
+  width: 60px;
+  height: 60px;
+  display: grid;
+  place-items: center;
+  border-radius: 16px;
+  background: linear-gradient(135deg, var(--sw-accent) 0%, var(--sw-purple) 
115%);
+  color: #fff;
+  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06) inset;
+}
+.aifp__setup-title {
+  margin: 0;
+  font-size: var(--sw-fs-xl);
+  font-weight: var(--sw-fw-semibold);
+  color: var(--sw-fg-0);
+}
+.aifp__setup-text {
+  margin: 0;
+  max-width: 52ch;
+  font-size: var(--sw-fs-base);
+  color: var(--sw-fg-2);
+  line-height: var(--sw-lh-normal);
+}
+.aifp__setup-tip {
+  margin: 6px 0 0;
+  max-width: 52ch;
+  font-size: var(--sw-fs-base);
+  font-weight: var(--sw-fw-medium);
+  color: var(--sw-fg-1);
+  padding: 12px 16px;
+  border: 1px solid var(--sw-line-2);
+  border-radius: 10px;
+  background: var(--sw-bg-1);
+}
 </style>
diff --git a/apps/ui/src/ai/useAiChat.ts b/apps/ui/src/ai/useAiChat.ts
index 00224d3..99e0497 100644
--- a/apps/ui/src/ai/useAiChat.ts
+++ b/apps/ui/src/ai/useAiChat.ts
@@ -57,8 +57,13 @@ async function ensureConfig(): Promise<void> {
 export interface AiChatController {
   /** Whether the slide-over panel is open. */
   open: Ref<boolean>;
-  /** Whether the launcher should be shown: authenticated AND the server 
reports ready. */
+  /** Whether the launcher + panel are shown — any authenticated user. */
   available: ComputedRef<boolean>;
+  /** Whether the assistant can actually chat (feature enabled + a usable
+   *  provider). When false, the panel is read-only with a setup notice. */
+  ready: ComputedRef<boolean>;
+  /** Whether the operator has enabled the AI feature at all (`ai.enabled`). */
+  enabled: ComputedRef<boolean>;
   /** Whether to play the first-run attention nudge on the launcher. */
   showHint: ComputedRef<boolean>;
   /** Server-supplied starter prompts (empty until config loads). */
@@ -74,8 +79,18 @@ export interface AiChatController {
 
 export function useAiChat(): AiChatController {
   const auth = useAuthStore();
-  const available = computed<boolean>(() => auth.isAuthenticated && 
(aiConfig.value?.ready ?? false));
-  const showHint = computed<boolean>(() => available.value && !hintSeen.value);
+  // The launcher + panel show for ANY authenticated user — to surface the
+  // AI-powered APM to everyone. `ai:read` is default for all roles; a user
+  // without it (custom RBAC) still sees the launcher and just gets the chat
+  // request rejected on send. `ready` (feature enabled + a usable provider)
+  // gates whether the panel can chat vs. show a read-only "ask your admin"
+  // setup notice.
+  const available = computed<boolean>(() => auth.isAuthenticated);
+  const ready = computed<boolean>(() => aiConfig.value?.ready ?? false);
+  const enabled = computed<boolean>(() => aiConfig.value?.enabled ?? false);
+  // Only nudge with the first-run hint when the assistant actually works —
+  // don't draw attention to a read-only, not-yet-configured panel.
+  const showHint = computed<boolean>(() => ready.value && !hintSeen.value);
   const starters = computed<string[]>(() => aiConfig.value?.starters ?? []);
 
   function dismissHint(): void {
@@ -100,5 +115,5 @@ export function useAiChat(): AiChatController {
     else openPanel();
   }
 
-  return { open: openState, available, showHint, starters, ensureConfig, 
openPanel, closePanel, toggle, dismissHint };
+  return { open: openState, available, ready, enabled, showHint, starters, 
ensureConfig, openPanel, closePanel, toggle, dismissHint };
 }
diff --git a/apps/ui/src/api/alarmsConfig.ts b/apps/ui/src/api/alarmsConfig.ts
new file mode 100644
index 0000000..7c3539a
--- /dev/null
+++ b/apps/ui/src/api/alarmsConfig.ts
@@ -0,0 +1,87 @@
+/*
+ * 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 Alarms page-setup config and its parse/validate helper. A leaf module
+// (imports nothing internal) so the alarms api scope can read the effective
+// config from OAP without a cycle through client.ts. The config lives ONLY in
+// OAP as the `horizon.alert.page-setup` singleton template — edited on the 
Alert
+// page admin and read from the config bundle's sync status, like every other
+// template.
+
+/** Allowed values for `AlarmsConfig.defaultWindowMs`, in ms. Matches the 
alarms
+ *  page's preset list so the admin's choice always corresponds to a real tab. 
*/
+export const ALARMS_WINDOW_OPTIONS = [
+  20 * 60_000,
+  2 * 60 * 60_000,
+  4 * 60 * 60_000,
+] as const;
+export type AlarmsWindowMs = (typeof ALARMS_WINDOW_OPTIONS)[number];
+
+export const OVERVIEW_ALARMS_LIMIT_MIN = 10;
+export const OVERVIEW_ALARMS_LIMIT_MAX = 500;
+export const OVERVIEW_ALARMS_LIMIT_DEFAULT = 200;
+
+export interface AlarmsConfig {
+  /** OAP layer keys (canonical `GENERAL`, `MESH`, …) that get a dedicated tile
+   *  on the alarms page header. Render order matches the array order. */
+  pinnedLayers: string[];
+  /** Default time window in ms for the topbar alarm badge AND the alarms 
page's
+   *  initial picker selection. */
+  defaultWindowMs: number;
+  /** Fetch cap for the overview "Active alarms" widget. Default 200, range
+   *  [10, 500]. Bigger = more incident variety; smaller = cheaper poll. */
+  overviewAlarmsLimit: number;
+}
+
+/** Shipped default — kept in sync with the bundled alert page-setup template
+ *  (`apps/bff/src/bundled_templates/alert/page-setup.json`). */
+export const DEFAULT_ALARMS_CONFIG: AlarmsConfig = {
+  pinnedLayers: ['GENERAL', 'MESH'],
+  defaultWindowMs: ALARMS_WINDOW_OPTIONS[0],
+  overviewAlarmsLimit: OVERVIEW_ALARMS_LIMIT_DEFAULT,
+};
+
+/** Parse + validate the alert page-setup from a template-sync `configuration`
+ *  string — the `{ name, kind, version, content }` envelope OAP stores 
verbatim.
+ *  Any missing / out-of-range field falls back to the shipped default, so a
+ *  partial or absent template still yields a usable config. */
+export function normalizeAlarmsConfig(configuration: string | null | 
undefined): AlarmsConfig {
+  if (!configuration) return { ...DEFAULT_ALARMS_CONFIG };
+  let content: Partial<AlarmsConfig>;
+  try {
+    content = (JSON.parse(configuration) as { content?: Partial<AlarmsConfig> 
}).content ?? {};
+  } catch {
+    return { ...DEFAULT_ALARMS_CONFIG };
+  }
+  const pinnedLayers = Array.isArray(content.pinnedLayers)
+    ? content.pinnedLayers.filter((l): l is string => typeof l === 'string')
+    : DEFAULT_ALARMS_CONFIG.pinnedLayers;
+  const defaultWindowMs = (ALARMS_WINDOW_OPTIONS as readonly 
number[]).includes(
+    content.defaultWindowMs as number,
+  )
+    ? (content.defaultWindowMs as number)
+    : DEFAULT_ALARMS_CONFIG.defaultWindowMs;
+  const lim = content.overviewAlarmsLimit;
+  const overviewAlarmsLimit =
+    typeof lim === 'number' &&
+    Number.isInteger(lim) &&
+    lim >= OVERVIEW_ALARMS_LIMIT_MIN &&
+    lim <= OVERVIEW_ALARMS_LIMIT_MAX
+      ? lim
+      : DEFAULT_ALARMS_CONFIG.overviewAlarmsLimit;
+  return { pinnedLayers, defaultWindowMs, overviewAlarmsLimit };
+}
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 09579cb..010a2a8 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -74,7 +74,6 @@ export function withBase(path: string): string {
 }
 import { MenuApi } from './scopes/menu';
 import { OverviewApi } from './scopes/overview';
-import { SetupApi } from './scopes/setup';
 import { LayerApi } from './scopes/layer';
 import { TraceApi } from './scopes/trace';
 import { ZipkinApi } from './scopes/zipkin';
@@ -115,8 +114,6 @@ export type {
   OapTtlResponse,
   OapConfigEntry,
   OapConfigResponse,
-  SetupResponse,
-  SetupSavePayload,
   LayerConfig,
   LandingConfig,
   LandingColumn,
@@ -643,33 +640,18 @@ export interface AlertingRuleContextResponse {
   error?: string;
   nodes: Array<{ address: string; ok: boolean; error?: string; context: 
AlarmRunningContext | null }>;
 }
-/** Allowed values for `AlarmsConfig.defaultWindowMs`, in ms. Matches
- *  the alarms page's preset list so the admin's choice always
- *  corresponds to a real tab. */
-export const ALARMS_WINDOW_OPTIONS = [
-  20 * 60_000,
-  2 * 60 * 60_000,
-  4 * 60 * 60_000,
-] as const;
-export type AlarmsWindowMs = (typeof ALARMS_WINDOW_OPTIONS)[number];
-
-export const OVERVIEW_ALARMS_LIMIT_MIN = 10;
-export const OVERVIEW_ALARMS_LIMIT_MAX = 500;
-export const OVERVIEW_ALARMS_LIMIT_DEFAULT = 200;
-
-export interface AlarmsConfig {
-  /** OAP layer keys (canonical `GENERAL`, `MESH`, …) that get a
-   *  dedicated tile on the alarms page header. Render order matches
-   *  the array order. */
-  pinnedLayers: string[];
-  /** Default time window in milliseconds for the topbar alarm badge
-   *  AND the alarms page's initial picker selection. */
-  defaultWindowMs: number;
-  /** Fetch cap for the overview "Active alarms" widget. Default 200,
-   *  range [10, 500]. Bigger = more incident variety; smaller =
-   *  cheaper poll. */
-  overviewAlarmsLimit: number;
-}
+// Alarms page-setup config + parse/validate helper live in a leaf module (no
+// cycle with the alarms scope, which reads it from OAP). Re-exported here so
+// `@/api/client` stays the single import site for consumers.
+export {
+  ALARMS_WINDOW_OPTIONS,
+  OVERVIEW_ALARMS_LIMIT_MIN,
+  OVERVIEW_ALARMS_LIMIT_MAX,
+  OVERVIEW_ALARMS_LIMIT_DEFAULT,
+  DEFAULT_ALARMS_CONFIG,
+  normalizeAlarmsConfig,
+} from './alarmsConfig';
+export type { AlarmsConfig, AlarmsWindowMs } from './alarmsConfig';
 
 // ── 3D Infrastructure Map admin config ───────────────────────────────
 // Mirrors `apps/bff/src/logic/infra-3d/types.ts`. Kept structurally
@@ -916,7 +898,6 @@ export class BffClient {
   readonly session = new SessionApi(this);
   readonly menu = new MenuApi(this);
   readonly overview = new OverviewApi(this);
-  readonly setup = new SetupApi(this);
   readonly layer = new LayerApi(this);
   readonly trace = new TraceApi(this);
   readonly zipkin = new ZipkinApi(this);
diff --git a/apps/ui/src/api/scopes/alarms.test.ts 
b/apps/ui/src/api/scopes/alarms.test.ts
index ca6cc5a..5806b2d 100644
--- a/apps/ui/src/api/scopes/alarms.test.ts
+++ b/apps/ui/src/api/scopes/alarms.test.ts
@@ -85,23 +85,37 @@ describe('AlarmsApi.services + config + count', () => {
     expect(calls[0][1]).toBe('/api/alarms/services?layer=MESH');
   });
 
-  it('config GET / saveConfig POST hit /api/alarms/config', async () => {
-    const { bff, calls } = makeStub();
-    const api = new AlarmsApi(bff);
-    await api.config();
-    await api.saveConfig({
-      pinnedLayers: ['GENERAL', 'MESH'],
-      defaultWindowMs: 1200000,
-      overviewAlarmsLimit: 200,
-    });
-    expect(calls[0]).toEqual(['GET', '/api/alarms/config', undefined]);
-    expect(calls[1][0]).toBe('POST');
-    expect(calls[1][1]).toBe('/api/alarms/config');
-    expect(calls[1][2]).toEqual({
-      pinnedLayers: ['GENERAL', 'MESH'],
-      defaultWindowMs: 1200000,
-      overviewAlarmsLimit: 200,
-    });
+  function stubSyncStatus(rows: unknown[]) {
+    const { bff } = makeStub();
+    (bff as unknown as { templateSync: { syncStatus: () => Promise<unknown> } 
}).templateSync = {
+      syncStatus: vi.fn(async () => ({ rows })),
+    };
+    return bff;
+  }
+
+  it('config reads + normalizes the alert page-setup from the template sync 
status', async () => {
+    const bff = stubSyncStatus([
+      {
+        name: 'horizon.alert.page-setup',
+        effective: 'remote',
+        remote: {
+          configuration: JSON.stringify({
+            name: 'horizon.alert.page-setup',
+            kind: 'alert',
+            version: 1,
+            content: { pinnedLayers: ['MESH'], defaultWindowMs: 7200000, 
overviewAlarmsLimit: 300 },
+          }),
+        },
+        bundled: null,
+      },
+    ]);
+    const cfg = await new AlarmsApi(bff).config();
+    expect(cfg).toEqual({ pinnedLayers: ['MESH'], defaultWindowMs: 7200000, 
overviewAlarmsLimit: 300 });
+  });
+
+  it('config falls back to shipped defaults when the alert row is absent', 
async () => {
+    const cfg = await new AlarmsApi(stubSyncStatus([])).config();
+    expect(cfg).toEqual({ pinnedLayers: ['GENERAL', 'MESH'], defaultWindowMs: 
1200000, overviewAlarmsLimit: 200 });
   });
 
   it('adminRules + adminRule hit /api/admin/alarm-rules', async () => {
diff --git a/apps/ui/src/api/scopes/alarms.ts b/apps/ui/src/api/scopes/alarms.ts
index cb8e2dc..b377a65 100644
--- a/apps/ui/src/api/scopes/alarms.ts
+++ b/apps/ui/src/api/scopes/alarms.ts
@@ -16,7 +16,6 @@
  */
 
 import type {
-  AlarmsConfig,
   AlarmsCountResponse,
   AlarmsQuery,
   AlarmsResponse,
@@ -25,6 +24,7 @@ import type {
   AlertingRulesListResponse,
   BffClient,
 } from '../client';
+import { normalizeAlarmsConfig, type AlarmsConfig } from '../alarmsConfig';
 
 /** `bff.alarms` — alarm list + topbar count probe + alarm-page
  *  config CRUD. */
@@ -66,12 +66,19 @@ export class AlarmsApi {
     return this.bff.request('GET', `/api/alarms/services?${p.toString()}`);
   }
 
+  /** The alarms page-setup — the `horizon.alert.page-setup` singleton 
template.
+   *  Read its effective (OAP-remote ↔ bundled) copy from the config bundle's
+   *  sync status, like the theme / time-defaults singletons; there is no local
+   *  BFF store. Edits are saved to OAP via `bff.templateSync.save`. */
   config(): Promise<AlarmsConfig> {
-    return this.bff.request<AlarmsConfig>('GET', '/api/alarms/config');
-  }
-
-  saveConfig(next: AlarmsConfig): Promise<AlarmsConfig> {
-    return this.bff.request<AlarmsConfig>('POST', '/api/alarms/config', next);
+    return this.bff.templateSync.syncStatus().then((status) => {
+      const row = status.rows.find((r) => r.name === 
'horizon.alert.page-setup');
+      const source =
+        row?.effective === 'remote' && row.remote
+          ? row.remote.configuration
+          : row?.bundled?.configuration;
+      return normalizeAlarmsConfig(source);
+    });
   }
 
   /** OAP `/status/alarm/rules` fan-out + per-rule detail merge.
diff --git a/apps/ui/src/api/scopes/setup.ts b/apps/ui/src/api/scopes/setup.ts
deleted file mode 100644
index 96a8095..0000000
--- a/apps/ui/src/api/scopes/setup.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * 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 type { SetupResponse, SetupSavePayload } from 
'@skywalking-horizon-ui/api-client';
-import type { BffClient } from '../client';
-
-/** `bff.setup` — per-layer enabled-scope toggles (first-run setup). */
-export class SetupApi {
-  constructor(private readonly bff: BffClient) {}
-
-  load(): Promise<SetupResponse> {
-    return this.bff.request<SetupResponse>('GET', '/api/setup');
-  }
-  save(payload: SetupSavePayload): Promise<SetupResponse> {
-    return this.bff.request<SetupResponse>('POST', '/api/setup', payload);
-  }
-}
diff --git a/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue 
b/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue
index b56c8ae..c787b29 100644
--- a/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue
+++ b/apps/ui/src/features/admin/alert-page/AlertPageSetupView.vue
@@ -27,7 +27,7 @@
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
-import { useQuery } from '@tanstack/vue-query';
+import { useQuery, useQueryClient } from '@tanstack/vue-query';
 import { useLayers } from '@/shell/useLayers';
 import {
   ALARMS_WINDOW_OPTIONS,
@@ -42,6 +42,7 @@ import TemplateDiffModal from 
'@/features/admin/_shared/TemplateDiffModal.vue';
 import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync';
 
 const { t } = useI18n({ useScope: 'global' });
+const queryClient = useQueryClient();
 
 // OAP UI-template sync status for the alert page-setup template
 // (`horizon.alert.page-setup`). Drives the read-only banner + Save
@@ -163,12 +164,13 @@ async function onSave(): Promise<void> {
       defaultWindowMs: draftWindowMs.value,
       overviewAlarmsLimit: Number(draftLimit.value),
     };
-    // Save to OAP via the template-sync proxy (canonical envelope
-    // wrapped server-side). The local AlarmsStore is still the read
-    // source for /api/alarms/config; the BFF refreshes it after the
-    // OAP write succeeds.
+    // Save to OAP via the template-sync proxy (canonical envelope wrapped
+    // server-side) — the alert page-setup is the `horizon.alert.page-setup`
+    // singleton, stored only on OAP like every other template.
     await bff.templateSync.save('horizon.alert.page-setup', next);
-    await bff.alarms.saveConfig(next);
+    // Refetch the shared config so the alarms page + topbar badge + overview
+    // widget pick up the change (all read it from the sync status).
+    await queryClient.invalidateQueries({ queryKey: ['alarms/config'] });
     draft.value = [...next.pinnedLayers];
     draftWindowMs.value = next.defaultWindowMs;
     draftLimit.value = next.overviewAlarmsLimit;
@@ -228,10 +230,8 @@ function prettyLayer(k: string): string {
                sentence; the agent's earlier split-into-three-t-calls left
                operators on zh-CN/de seeing English prose with one Chinese
                word in the middle. -->
-          <i18n-t keypath="Pin the OAP layers that get their own KPI tile at 
the top of {alarms}. Defaults are {general} + {mesh}." tag="span" 
scope="global">
+          <i18n-t keypath="Pin the OAP layers that get their own KPI tile at 
the top of {alarms}." tag="span" scope="global">
             <template #alarms><RouterLink to="/alarms">{{ t('Alarms') 
}}</RouterLink></template>
-            <template #general><code>GENERAL</code></template>
-            <template #mesh><code>MESH</code></template>
           </i18n-t>
           {{ ' ' }}
           {{ t('Every other layer with at least one firing alarm appears in 
the overflow chip row underneath. Reorder with the arrows — left-to-right 
matches the page header. Up to {n} layers.', { n: MAX_PINNED }) }}
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index 676729a..7852374 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -1091,7 +1091,6 @@
   "Observability Analysis Language": "Observability Analysis Language",
   "Operate · Data retention": "Betrieb · Datenaufbewahrung",
   "Per-data-class · widths proportional to total retention": "Pro Datenklasse 
· Breiten proportional zur Gesamtaufbewahrung",
-  "Pin the OAP layers that get their own KPI tile at the top of {alarms}. 
Defaults are {general} + {mesh}.": "Hefte die OAP-Ebenen an, die oben auf 
{alarms} eine eigene KPI-Kachel erhalten. Standard sind {general} + {mesh}.",
   "Push one with": "Pushe eine mit",
   "Records": "Datensätze",
   "Retention": "Aufbewahrung",
@@ -1654,5 +1653,9 @@
   "Content filter": "Inhaltsfilter",
   "match": "passt",
   "exclude": "ausschließen",
-  "No lines matched this content filter in the window.": "Keine Zeilen 
entsprachen diesem Inhaltsfilter im Fenster."
+  "No lines matched this content filter in the window.": "Keine Zeilen 
entsprachen diesem Inhaltsfilter im Fenster.",
+  "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": 
"Hefte die OAP-Ebenen an, die oben auf {alarms} eine eigene KPI-Kachel 
erhalten.",
+  "The AI Assistant is not set up yet": "Der KI-Assistent ist noch nicht 
eingerichtet",
+  "It answers questions about your services, metrics, traces and logs from 
live data — but a model provider must be configured first.": "Er beantwortet 
Fragen zu Ihren Services, Metriken, Traces und Logs aus Live-Daten — aber 
zuerst muss ein Modellanbieter konfiguriert werden.",
+  "Ask your administrator to enable it and configure a model provider and API 
key.": "Bitten Sie Ihren Administrator, ihn zu aktivieren und einen 
Modellanbieter sowie einen API-Schlüssel zu konfigurieren."
 }
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 6b045e6..1b9d2ee 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1072,7 +1072,6 @@
   "Observability Analysis Language": "Observability Analysis Language",
   "Operate · Data retention": "Operate · Data retention",
   "Per-data-class · widths proportional to total retention": "Per-data-class · 
widths proportional to total retention",
-  "Pin the OAP layers that get their own KPI tile at the top of {alarms}. 
Defaults are {general} + {mesh}.": "Pin the OAP layers that get their own KPI 
tile at the top of {alarms}. Defaults are {general} + {mesh}.",
   "Push one with": "Push one with",
   "Records": "Records",
   "Retention": "Retention",
@@ -1654,5 +1653,9 @@
   "Content filter": "Content filter",
   "match": "match",
   "exclude": "exclude",
-  "No lines matched this content filter in the window.": "No lines matched 
this content filter in the window."
+  "No lines matched this content filter in the window.": "No lines matched 
this content filter in the window.",
+  "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": 
"Pin the OAP layers that get their own KPI tile at the top of {alarms}.",
+  "The AI Assistant is not set up yet": "The AI Assistant is not set up yet",
+  "It answers questions about your services, metrics, traces and logs from 
live data — but a model provider must be configured first.": "It answers 
questions about your services, metrics, traces and logs from live data — but a 
model provider must be configured first.",
+  "Ask your administrator to enable it and configure a model provider and API 
key.": "Ask your administrator to enable it and configure a model provider and 
API key."
 }
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 1db48e5..1d87a5f 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -1091,7 +1091,6 @@
   "Observability Analysis Language": "Observability Analysis Language",
   "Operate · Data retention": "Operar · Retención de datos",
   "Per-data-class · widths proportional to total retention": "Por clase de 
datos · anchos proporcionales a la retención total",
-  "Pin the OAP layers that get their own KPI tile at the top of {alarms}. 
Defaults are {general} + {mesh}.": "Fija las capas de OAP que obtienen su 
propia tarjeta KPI en la parte superior de {alarms}. Las predeterminadas son 
{general} + {mesh}.",
   "Push one with": "Empuja uno con",
   "Records": "Registros",
   "Retention": "Retención",
@@ -1654,5 +1653,9 @@
   "Content filter": "Filtro de contenido",
   "match": "coincide",
   "exclude": "excluye",
-  "No lines matched this content filter in the window.": "Ninguna línea 
coincidió con este filtro de contenido en la ventana."
+  "No lines matched this content filter in the window.": "Ninguna línea 
coincidió con este filtro de contenido en la ventana.",
+  "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": 
"Fija las capas de OAP que obtienen su propia tarjeta KPI en la parte superior 
de {alarms}.",
+  "The AI Assistant is not set up yet": "El asistente de IA aún no está 
configurado",
+  "It answers questions about your services, metrics, traces and logs from 
live data — but a model provider must be configured first.": "Responde 
preguntas sobre tus servicios, métricas, trazas y registros a partir de datos 
en vivo, pero primero se debe configurar un proveedor de modelo.",
+  "Ask your administrator to enable it and configure a model provider and API 
key.": "Pide a tu administrador que lo habilite y configure un proveedor de 
modelo y una clave de API."
 }
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index e249454..fff8b37 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -1091,7 +1091,6 @@
   "Observability Analysis Language": "Observability Analysis Language",
   "Operate · Data retention": "Exploitation · Rétention des données",
   "Per-data-class · widths proportional to total retention": "Par classe de 
données · largeurs proportionnelles à la rétention totale",
-  "Pin the OAP layers that get their own KPI tile at the top of {alarms}. 
Defaults are {general} + {mesh}.": "Épinglez les couches OAP qui obtiennent 
leur propre tuile KPI en haut de {alarms}. Les valeurs par défaut sont 
{general} + {mesh}.",
   "Push one with": "Poussez-en un avec",
   "Records": "Enregistrements",
   "Retention": "Rétention",
@@ -1654,5 +1653,9 @@
   "Content filter": "Filtre de contenu",
   "match": "correspond",
   "exclude": "exclut",
-  "No lines matched this content filter in the window.": "Aucune ligne ne 
correspond à ce filtre de contenu dans la fenêtre."
+  "No lines matched this content filter in the window.": "Aucune ligne ne 
correspond à ce filtre de contenu dans la fenêtre.",
+  "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": 
"Épinglez les couches OAP qui obtiennent leur propre tuile KPI en haut de 
{alarms}.",
+  "The AI Assistant is not set up yet": "L'assistant IA n'est pas encore 
configuré",
+  "It answers questions about your services, metrics, traces and logs from 
live data — but a model provider must be configured first.": "Il répond aux 
questions sur vos services, métriques, traces et logs à partir de données en 
direct, mais un fournisseur de modèle doit d'abord être configuré.",
+  "Ask your administrator to enable it and configure a model provider and API 
key.": "Demandez à votre administrateur de l'activer et de configurer un 
fournisseur de modèle et une clé d'API."
 }
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 3834227..f218286 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -1091,7 +1091,6 @@
   "Observability Analysis Language": "Observability Analysis Language",
   "Operate · Data retention": "運用 · データ保持",
   "Per-data-class · widths proportional to total retention": "データクラスごと · 
幅は合計保持期間に比例",
-  "Pin the OAP layers that get their own KPI tile at the top of {alarms}. 
Defaults are {general} + {mesh}.": "{alarms} の上部に独自の KPI タイルを持つ OAP 
レイヤーをピン留めします。デフォルトは {general} + {mesh}。",
   "Push one with": "次でプッシュ:",
   "Records": "レコード",
   "Retention": "保持期間",
@@ -1654,5 +1653,9 @@
   "Content filter": "コンテンツフィルター",
   "match": "一致",
   "exclude": "除外",
-  "No lines matched this content filter in the window.": 
"このウィンドウ内でこのコンテンツフィルターに一致するログ行はありません。"
+  "No lines matched this content filter in the window.": 
"このウィンドウ内でこのコンテンツフィルターに一致するログ行はありません。",
+  "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": 
"{alarms} の上部に独自の KPI タイルを持つ OAP レイヤーをピン留めします。",
+  "The AI Assistant is not set up yet": "AI アシスタントはまだセットアップされていません",
+  "It answers questions about your services, metrics, traces and logs from 
live data — but a model provider must be configured first.": 
"サービス、メトリクス、トレース、ログに関する質問にライブデータから回答しますが、先にモデルプロバイダーを構成する必要があります。",
+  "Ask your administrator to enable it and configure a model provider and API 
key.": "管理者に有効化とモデルプロバイダー・API キーの構成を依頼してください。"
 }
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 40e2988..ded3542 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -1091,7 +1091,6 @@
   "Observability Analysis Language": "Observability Analysis Language",
   "Operate · Data retention": "운영 · 데이터 보존",
   "Per-data-class · widths proportional to total retention": "데이터 클래스별 · 너비는 총 
보존 기간에 비례",
-  "Pin the OAP layers that get their own KPI tile at the top of {alarms}. 
Defaults are {general} + {mesh}.": "{alarms} 상단에 전용 KPI 타일이 표시되는 OAP 레이어를 
고정합니다. 기본값은 {general} + {mesh}.",
   "Push one with": "다음으로 푸시:",
   "Records": "레코드",
   "Retention": "보존",
@@ -1654,5 +1653,9 @@
   "Content filter": "내용 필터",
   "match": "일치",
   "exclude": "제외",
-  "No lines matched this content filter in the window.": "이 창에서 이 내용 필터와 일치하는 
로그 줄이 없습니다."
+  "No lines matched this content filter in the window.": "이 창에서 이 내용 필터와 일치하는 
로그 줄이 없습니다.",
+  "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": 
"{alarms} 상단에 전용 KPI 타일이 표시되는 OAP 레이어를 고정합니다.",
+  "The AI Assistant is not set up yet": "AI 어시스턴트가 아직 설정되지 않았습니다",
+  "It answers questions about your services, metrics, traces and logs from 
live data — but a model provider must be configured first.": "실시간 데이터를 기반으로 
서비스, 메트릭, 트레이스, 로그에 대한 질문에 답하지만 먼저 모델 제공자를 구성해야 합니다.",
+  "Ask your administrator to enable it and configure a model provider and API 
key.": "관리자에게 활성화 및 모델 제공자와 API 키 구성을 요청하세요."
 }
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index bc2f8d2..a5dfe72 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -1091,7 +1091,6 @@
   "Observability Analysis Language": "Observability Analysis Language",
   "Operate · Data retention": "Operar · Retenção de dados",
   "Per-data-class · widths proportional to total retention": "Por classe de 
dado · larguras proporcionais à retenção total",
-  "Pin the OAP layers that get their own KPI tile at the top of {alarms}. 
Defaults are {general} + {mesh}.": "Fixe as camadas do OAP que ganham seu 
próprio tile de KPI no topo de {alarms}. Os padrões são {general} + {mesh}.",
   "Push one with": "Envie um com",
   "Records": "Registros",
   "Retention": "Retenção",
@@ -1654,5 +1653,9 @@
   "Content filter": "Filtro de conteúdo",
   "match": "corresponde",
   "exclude": "exclui",
-  "No lines matched this content filter in the window.": "Nenhuma linha 
correspondeu a este filtro de conteúdo na janela."
+  "No lines matched this content filter in the window.": "Nenhuma linha 
correspondeu a este filtro de conteúdo na janela.",
+  "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": 
"Fixe as camadas do OAP que ganham seu próprio tile de KPI no topo de 
{alarms}.",
+  "The AI Assistant is not set up yet": "O assistente de IA ainda não está 
configurado",
+  "It answers questions about your services, metrics, traces and logs from 
live data — but a model provider must be configured first.": "Ele responde a 
perguntas sobre seus serviços, métricas, traces e logs a partir de dados ao 
vivo — mas um provedor de modelo precisa ser configurado primeiro.",
+  "Ask your administrator to enable it and configure a model provider and API 
key.": "Peça ao seu administrador para habilitá-lo e configurar um provedor de 
modelo e uma chave de API."
 }
diff --git a/apps/ui/src/i18n/locales/zh-CN.json 
b/apps/ui/src/i18n/locales/zh-CN.json
index 7708c90..bfc7630 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -1092,7 +1092,6 @@
   "Observability Analysis Language": "Observability Analysis Language",
   "Operate · Data retention": "运维 · 数据保留",
   "Per-data-class · widths proportional to total retention": "按数据类别 · 
宽度与总保留期成正比",
-  "Pin the OAP layers that get their own KPI tile at the top of {alarms}. 
Defaults are {general} + {mesh}.": "固定要在 {alarms} 顶部拥有独立 KPI 图块的 OAP layer。默认值为 
{general} + {mesh}。",
   "Push one with": "请通过以下方式推送:",
   "Records": "记录",
   "Retention": "保留期",
@@ -1654,5 +1653,9 @@
   "Content filter": "内容过滤",
   "match": "匹配",
   "exclude": "排除",
-  "No lines matched this content filter in the window.": "该窗口内没有日志行匹配此内容过滤。"
+  "No lines matched this content filter in the window.": "该窗口内没有日志行匹配此内容过滤。",
+  "Pin the OAP layers that get their own KPI tile at the top of {alarms}.": 
"固定要在 {alarms} 顶部拥有独立 KPI 图块的 OAP layer。",
+  "The AI Assistant is not set up yet": "AI 助手尚未配置",
+  "It answers questions about your services, metrics, traces and logs from 
live data — but a model provider must be configured first.": 
"它基于实时数据回答有关服务、指标、trace 和日志的问题,但需要先配置模型提供方。",
+  "Ask your administrator to enable it and configure a model provider and API 
key.": "请让管理员启用它并配置模型提供方和 API 密钥。"
 }
diff --git a/apps/ui/src/state/setup.ts b/apps/ui/src/state/setup.ts
index 622c177..46b41f9 100644
--- a/apps/ui/src/state/setup.ts
+++ b/apps/ui/src/state/setup.ts
@@ -16,7 +16,7 @@
  */
 
 import { defineStore } from 'pinia';
-import { computed, reactive, ref } from 'vue';
+import { reactive } from 'vue';
 import type {
   AggregationKind,
   LandingConfig,
@@ -26,7 +26,6 @@ import type {
   LayerOverviewConfig,
   LayerSlots,
 } from '@skywalking-horizon-ui/api-client';
-import { bffClient } from '@/api/client';
 import {
   defaultColumnsForLayer,
   defaultOrderByForLayer,
@@ -206,77 +205,24 @@ export function defaultLayerConfig(
 }
 
 /**
- * Layer customization store.
+ * Layer config resolver.
  *
- * Lifecycle:
- *   1. `bootstrap()` hydrates the persisted overrides from `GET /api/setup`.
- *   2. `ensure(key, defaults)` returns the editable config — creating one
- *      from `defaults` on first touch.
- *   3. UI mutations mark `dirty` true.
- *   4. `save()` POSTs `/api/setup` and clears `dirty`.
- *   5. `reset(key, defaults)` rebuilds a single layer from defaults.
- *   6. `discard()` re-hydrates from server, dropping local changes.
- *
- * The BFF JSON store is the source of truth until OAP-side template
- * management lands. See packages/api-client/src/setup.ts for the wire
- * shape.
+ * Derives each layer's rendered `LayerConfig` (slots / caps / landing) from 
that
+ * layer's template defaults and caches it per layer so many components can 
read a
+ * stable object. The layer template is the source of truth — authored in the
+ * Layer dashboards admin and OAP-synced; this store holds NO server state and
+ * persists nothing (there is no separate per-layer override store).
  */
 export const useSetupStore = defineStore('setup', () => {
   const configs = reactive<Record<string, LayerConfig>>({});
-  const dirty = ref(false);
-  const loading = ref(false);
-  const saving = ref(false);
-  const lastError = ref<string | null>(null);
-  const bootstrapped = ref(false);
-  /** Last server-known shape; used by `discard()` to revert. */
-  let serverSnapshot: Record<string, LayerConfig> = {};
-  /** Set of layers whose persisted config has been reconciled against the
-   *  current bundle defaults. Each layer is reconciled exactly ONCE per
-   *  server snapshot — re-running the patches on every `ensure()` call
-   *  was unconditionally re-assigning `landing.headerColumns` (and
-   *  similar) and triggering a reactive cascade. With many components
-   *  reading `ensure(...).landing` from inside a `computed`, the cascade
-   *  hit Vue's recursive-update cap and froze the page. The bootstrap +
-   *  discard paths reset this set so the next reconciliation pass runs
-   *  against the freshly-loaded server data. */
-  const reconciledLayers = new Set<string>();
-
-  function applyServerSnapshot(layers: Record<string, LayerConfig>): void {
-    serverSnapshot = JSON.parse(JSON.stringify(layers));
-    for (const k of Object.keys(configs)) delete configs[k];
-    for (const [k, v] of Object.entries(layers)) configs[k] = 
JSON.parse(JSON.stringify(v));
-    reconciledLayers.clear();
-    dirty.value = false;
-  }
-
-  async function bootstrap(): Promise<void> {
-    if (bootstrapped.value || loading.value) return;
-    loading.value = true;
-    lastError.value = null;
-    try {
-      const res = await bffClient.setup.load();
-      applyServerSnapshot(res.layers);
-      bootstrapped.value = true;
-    } catch (err) {
-      lastError.value = err instanceof Error ? err.message : 'failed to load 
setup';
-    } finally {
-      loading.value = false;
-    }
-  }
-
-  // Form bindings mutate `configs[layer]…` through the Vue proxy, and we
-  // can't tell a user edit from a programmatic write on that proxy — so
-  // dirty tracking needs an explicit signal: callers (LayerSetupCard on
-  // input) must call markDirty from the form handler.
-  function markDirty(): void {
-    if (!dirty.value) dirty.value = true;
-  }
 
   /**
-   * Return the operator's config for a layer, creating one from defaults
-   * on first touch. Calls into this from a `computed()` MUTATE the store —
-   * intentional: the Pinia reactive proxy then makes every form-field
-   * binding write-through.
+   * Return the resolved config for a layer, deriving it from `defaults` on 
first
+   * touch and caching it. Called from `computed()` deliberately: the cache 
write
+   * happens once per layer, and every subsequent read returns the cached 
object
+   * WITHOUT mutating — so a computed reading `ensure(...).landing` never 
triggers
+   * a reactive cascade (an earlier per-read reconciliation pass did, which 
froze
+   * AppSidebar / LayerShell with "Maximum recursive updates exceeded").
    */
   function ensure(
     layerKey: string,
@@ -291,145 +237,15 @@ export const useSetupStore = defineStore('setup', () => {
     if (!cfg) {
       cfg = defaultLayerConfig(layerKey, defaults);
       configs[layerKey] = cfg;
-      // Freshly built from defaults — already in canonical shape.
-      reconciledLayers.add(layerKey);
-      return cfg;
-    }
-    // Reconciliation is a one-time bootstrap step per server snapshot,
-    // not a per-read action. The earlier code re-ran the reconciliation
-    // on every `ensure()` call, and several of those writes were
-    // unconditional (e.g. `cfg.landing.headerColumns = fresh...`). Since
-    // many computeds call `ensure()`, that turned every read into a
-    // reactive mutation and produced "Maximum recursive updates exceeded"
-    // in AppSidebar / LayerShell. Now we early-return for already-
-    // reconciled layers — the reactive read stays pure.
-    if (reconciledLayers.has(layerKey)) return cfg;
-    reconciledLayers.add(layerKey);
-    {
-      // Reconcile stale persisted state — three issues to patch:
-      //   1. caps fields added after the persisted snapshot. We fill
-      //      undefined caps from the template defaults so checkbox
-      //      toggles read the right state (undefined → defaults).
-      //   2. overview groups / metric ids that don't resolve to any
-      //      column → seed the fresh group set.
-      //   3. columns that DO resolve but carry stale data (empty mqe
-      //      from an old schema) → patch the missing fields from the
-      //      fresh template. Operator-edited fields stay untouched
-      //      (we never overwrite a non-empty persisted value).
-      for (const [k, v] of Object.entries(defaults.caps)) {
-        const key = k as keyof typeof cfg.caps;
-        if (cfg.caps[key] === undefined) cfg.caps[key] = v;
-      }
-      const fresh = defaultLandingFor(layerKey, defaults.metrics, 
defaults.overview);
-      // When the bundled template now ships `columns: []` (e.g.
-      // so11y_* layers whose meters are SERVICE_INSTANCE-only and have
-      // no service-level KPIs), clear any leftover persisted columns —
-      // they're stale baggage from a prior bundle version and would
-      // resurrect a row of `—` cells. Operators that explicitly want
-      // custom columns can re-add them via the setup admin.
-      if (fresh.columns.length === 0 && cfg.landing.columns.length > 0) {
-        cfg.landing.columns = [];
-        cfg.landing.orderBy = fresh.orderBy;
-      }
-      // Always re-derive `headerColumns` from the fresh template — it's
-      // the operator-defined header set (excludes overview-promoted
-      // synthetic cols). New field; legacy persisted configs won't
-      // have it. We trust the template here rather than preserving
-      // stale persisted state because the field is a pure derivation,
-      // not operator-editable.
-      cfg.landing.headerColumns = fresh.headerColumns;
-      // Patch / add columns from the fresh template — only fields
-      // that are missing on the persisted column get filled. mqe is
-      // the common stale field; this is also where label fixes from
-      // a renamed catalog land for un-customized columns.
-      for (const fc of fresh.columns) {
-        const existing = cfg.landing.columns.find((c) => c.metric === 
fc.metric);
-        if (!existing) {
-          cfg.landing.columns.push(fc);
-          continue;
-        }
-        if (!existing.mqe && fc.mqe) existing.mqe = fc.mqe;
-        if (existing.label === existing.metric && fc.label) existing.label = 
fc.label;
-        if (!existing.unit && fc.unit) existing.unit = fc.unit;
-        if (!existing.aggregation && fc.aggregation) existing.aggregation = 
fc.aggregation;
-        if (!existing.tip && fc.tip) existing.tip = fc.tip;
-      }
-      // Refresh overviewMetrics / overviewGroups when persisted state
-      // is stale (missing groups, or ids that don't resolve).
-      const ids = cfg.landing.overviewMetrics ?? [];
-      const allResolve = ids.length > 0 && ids.every(
-        (id) => cfg.landing.columns.some((c) => c.metric === id),
-      );
-      const hasGroups = (cfg.landing.overviewGroups?.length ?? 0) > 0;
-      if (!allResolve || !hasGroups) {
-        cfg.landing.overviewMetrics = fresh.overviewMetrics;
-        cfg.landing.overviewGroups = fresh.overviewGroups;
-      }
     }
     return cfg;
   }
 
-  function reset(
-    layerKey: string,
-    defaults: {
-      slots: LayerSlots;
-      caps: LayerCaps;
-      metrics?: LayerMetricsConfig;
-      overview?: LayerOverviewConfig;
-    },
-  ): void {
-    configs[layerKey] = defaultLayerConfig(layerKey, defaults);
-    markDirty();
-  }
-
-  async function save(): Promise<void> {
-    if (saving.value) return;
-    saving.value = true;
-    lastError.value = null;
-    try {
-      // Client sends the full touched set; the server stores it sparse.
-      const payload = JSON.parse(JSON.stringify(configs)) as Record<string, 
LayerConfig>;
-      const res = await bffClient.setup.save({ layers: payload });
-      applyServerSnapshot(res.layers);
-    } catch (err) {
-      lastError.value = err instanceof Error ? err.message : 'save failed';
-      throw err;
-    } finally {
-      saving.value = false;
-    }
-  }
-
-  async function discard(): Promise<void> {
-    applyServerSnapshot(serverSnapshot);
-  }
-
-  /** Side-effect-free priority read. Returns the persisted priority
-   *  for a layer when one exists, or the static default otherwise.
-   *
-   *  Critically, this MUST NOT call `ensure()` or otherwise mutate the
-   *  store. `useLandingOrder` reads priority from inside a computed —
-   *  using `ensure()` there triggered a recursive-update loop
-   *  ("Maximum recursive updates exceeded in component <AppSidebar>")
-   *  because `ensure` writes to `configs.<layer>.landing.headerColumns`
-   *  on every call, and that's a dependency of the same computed. */
+  /** Side-effect-free priority read — used from `useLandingOrder` inside a
+   *  computed, so it MUST NOT call `ensure()` or otherwise mutate the store. 
*/
   function priorityFor(layerKey: string): number {
     return configs[layerKey]?.landing.priority ?? defaultPriority(layerKey);
   }
 
-  return {
-    configs,
-    dirty,
-    loading,
-    saving,
-    bootstrapped,
-    lastError,
-    layerCount: computed(() => Object.keys(configs).length),
-    bootstrap,
-    ensure,
-    priorityFor,
-    reset,
-    markDirty,
-    save,
-    discard,
-  };
+  return { configs, ensure, priorityFor };
 });
diff --git a/docs/access-control/audit-log.md b/docs/access-control/audit-log.md
index 1275cef..e8490f5 100644
--- a/docs/access-control/audit-log.md
+++ b/docs/access-control/audit-log.md
@@ -1,6 +1,6 @@
 # Audit Log
 
-The audit log records sensitive operations as JSON Lines, one event per line, 
append-only. Configure the path via `audit.file` in `horizon.yaml` (see [Setup 
→ audit](../setup/audit.md)).
+The audit log records sensitive operations as JSON Lines, one event per line, 
append-only. It is **on by default**; disable it with `audit.enabled: false` 
and set the path via `audit.file` in `horizon.yaml` (see [Setup → 
audit](../setup/audit.md)).
 
 ## Event Fields
 
@@ -36,8 +36,6 @@ The recorded set can grow between releases. The current set:
 | `addOrUpdate` | OAP `applyStatus`, or `http_<code>` on failure | DSL 
Management — create / update a rule. `details.name` is the rule name; 
`details.catalog` is the rule catalog (alarm, MAL, OAL, …). |
 | `inactivate` | OAP `applyStatus`, or `http_<code>` | DSL Management — 
deactivate a rule. |
 | `delete` | OAP `applyStatus`, or `http_<code>` | DSL Management — delete a 
rule. `details.mode` is the delete mode. |
-| `alarms.config.save` | `ok` | Alarm page setup save. Recorded on success 
only — an invalid payload is rejected before any audit entry. |
-| `setup.save` | `success` | Per-user setup state write (service / instance / 
endpoint setup). Recorded on success only — an invalid payload is rejected 
before any audit entry. |
 | `debug.start` | `ok`, or an error status on failure | Live Debugger — start 
a session. |
 | `debug.stop` | `ok`, or an error status on failure | Live Debugger — stop a 
session. |
 
diff --git a/docs/design-target.md b/docs/design-target.md
index e9fed63..c0d0de0 100644
--- a/docs/design-target.md
+++ b/docs/design-target.md
@@ -55,7 +55,7 @@ The renderer is template-driven (see [Customization → Layer 
Dashboard Template
 
 Every visual decision a site operator wants to make is template-driven:
 
-- **Sidebar layer order** — per-user layer priority, persisted in the setup 
state file.
+- **Sidebar layer order** — a default priority by layer family (General → 
Virtual* → Mesh → K8s).
 - **Layer alias / color / group / visibility** — layer template fields 
(`alias`, `color`, `group`, `visibility`).
 - **Which tabs appear on a layer** — `components` flags on the layer template.
 - **What appears under each tab** — `dashboards.<scope>` widget arrays.
diff --git a/docs/operate/ai-assistant.md b/docs/operate/ai-assistant.md
index 5ddd96e..92e99d1 100644
--- a/docs/operate/ai-assistant.md
+++ b/docs/operate/ai-assistant.md
@@ -31,7 +31,7 @@ Crucially, that skill is **not immutable** — it is *your* 
configuration, edite
 
 ## Enabling it
 
-The assistant is disabled until you turn it on and point it at a model. The 
launcher (a floating **AI Assistant** button on the right edge, after login) 
stays hidden until the feature is enabled *and* a usable provider is configured.
+The launcher — a floating **AI Assistant** button on the right edge, after 
login — shows for every signed-in user, so the assistant is discoverable across 
the product. Until you turn the feature on and point it at a model, the panel 
opens **read-only**: it explains what the assistant does and shows a short "ask 
your administrator to set it up" notice instead of a chat box. Who may actually 
use it once configured is controlled by RBAC — the `ai:read` permission, 
granted to every role by d [...]
 
 Configure it under the `ai:` block of `horizon.yaml`, or entirely via 
`HORIZON_AI_*` environment variables:
 
diff --git a/docs/setup/audit.md b/docs/setup/audit.md
index 1097bdd..3d02f39 100644
--- a/docs/setup/audit.md
+++ b/docs/setup/audit.md
@@ -4,6 +4,7 @@ Audit log file path. The format and event schema are documented 
in [Access Contr
 
 ```yaml
 audit:
+  enabled: true
   file: ./horizon-audit.jsonl
 ```
 
@@ -11,6 +12,7 @@ audit:
 
 | Field | Type | Default | Required | Notes |
 |---|---|---|---|---|
+| `enabled` | boolean | `true` | no | Whether to record the audit trail. **On 
by default** — the audit log is a security record, so silencing it is an 
explicit opt-out. When `false`, no file is opened and every event is dropped. 
Env: `HORIZON_AUDIT_ENABLED`. |
 | `file` | string | `./horizon-audit.jsonl` | no | Filesystem path to the JSON 
Lines audit log. Relative paths resolve from the BFF working directory. The BFF 
appends; it never rotates. |
 
 ## Operational notes
diff --git a/docs/setup/container-image.md b/docs/setup/container-image.md
index 0e89006..502656c 100644
--- a/docs/setup/container-image.md
+++ b/docs/setup/container-image.md
@@ -30,7 +30,7 @@ The full commit SHA is the canonical, immutable identifier. 
Moving tags are conv
 | `/app/static/` | root | no | Built UI assets (Vite `dist/`). |
 | `/app/horizon.yaml` | root | no | The **active** config — a **baked, fully 
tokenized default** (every field is a `${HORIZON_…:default}` env token). The 
image runs with no mounted file; override any field via env (see [Run with env 
vars only](#run-with-env-vars-only-no-mounted-file)), or bind-mount your own to 
replace it. |
 | `/app/bundled_templates/` | **horizon** | **yes** | Layer + overview JSON 
templates. Owned by `horizon` because the admin **Layer-Templates** and 
**Overview-Templates** editors write into per-key files here. |
-| `/data/` | **horizon** | **yes** | Declared `VOLUME`. Default destination 
for the audit log, setup state, alarm state, and wire debug log. Mount a PVC / 
named volume / host bind here for durable storage. |
+| `/data/` | **horizon** | **yes** | Declared `VOLUME`. Default destination 
for the audit log and wire debug log. Mount a PVC / named volume / host bind 
here for durable storage. |
 | `/app/sourcemaps/` | **horizon** | (read) | Static source maps for the 
**Browser Logs** tab. Bind-mount or copy `.map` files here and they're loaded 
at boot — durable across restarts. Optional; runtime uploads work without it. 
See [Browser Logs & Source Maps](../operate/browser-source-maps.md). |
 
 The runtime stage runs as the non-root user `horizon`. Two locations are owned 
by `horizon` so writes work without operator intervention: `bundled_templates/` 
(so the admin editors save) and `/data/` (so state files land somewhere 
durable).
@@ -44,12 +44,10 @@ The runtime stage runs as the non-root user `horizon`. Two 
locations are owned b
 | `HORIZON_CONFIG` | `/app/horizon.yaml` | Where the BFF looks for 
`horizon.yaml`. Override to mount elsewhere. |
 | `HORIZON_STATIC_DIR` | `/app/static` | Where the BFF serves UI assets from. |
 | `HORIZON_AUDIT_FILE` | `/data/horizon-audit.jsonl` | Default for 
`audit.file` when `horizon.yaml` doesn't override it. |
-| `HORIZON_SETUP_FILE` | `/data/horizon-setup.json` | Default for 
`setup.file`. |
-| `HORIZON_ALARMS_FILE` | `/data/horizon-alarms.json` | Default for 
`alarms.file`. |
 | `HORIZON_WIRE_LOG_FILE` | `/data/horizon-wire.jsonl` | Default for 
`debugLog.file`. |
 | `HORIZON_SOURCEMAPS_DIR` | `/app/sourcemaps` | Default for 
`sourceMaps.bootMountDir` — the directory scanned at boot for 
statically-provisioned `.map` files. See [Browser Logs & Source 
Maps](../operate/browser-source-maps.md). |
 
-The four `HORIZON_*_FILE` env vars seed the **defaults** the config schema 
uses when `horizon.yaml` doesn't supply a value. An explicit value in 
`horizon.yaml` always wins. The intent: an operator who runs the published 
image with only a minimal `horizon.yaml` (no `audit/setup/alarms/debugLog` 
blocks) gets state files routed to `/data/` automatically, no manual path 
overrides needed.
+The two `HORIZON_*_FILE` env vars seed the **defaults** the config schema uses 
when `horizon.yaml` doesn't supply a value. An explicit value in `horizon.yaml` 
always wins. The intent: an operator who runs the published image with only a 
minimal `horizon.yaml` (no `audit/debugLog` blocks) gets state files routed to 
`/data/` automatically, no manual path overrides needed.
 
 `server.host` and `server.port` come from the YAML when present. If they are 
omitted, the image supplies defaults via `HORIZON_SERVER_HOST=0.0.0.0` and 
`HORIZON_SERVER_PORT=8081`. The image sets `EXPOSE 8081`; if you change 
`server.port`, also publish the new port.
 
@@ -240,13 +238,13 @@ Notes:
 
 - `subPath: horizon.yaml` mounts the single file rather than the directory, so 
it doesn't shadow `/app`'s other contents.
 - Mount with `readOnly: true` on the config — the BFF only reads it.
-- `/data` is the image's declared `VOLUME` for runtime state (audit log, 
setup, alarms, wire debug). The four `HORIZON_*_FILE` env vars baked into the 
image point at `/data/*`, so mounting a PVC here is enough — no path overrides 
in `horizon.yaml` are required.
+- `/data` is the image's declared `VOLUME` for runtime state (audit log, wire 
debug). The two `HORIZON_*_FILE` env vars baked into the image point at 
`/data/*`, so mounting a PVC here is enough — no path overrides in 
`horizon.yaml` are required.
 - `fsGroup: 101` is the typical alpine `nobody` GID that `adduser -S -G 
horizon horizon` falls into. Run `docker run --rm <image> id horizon` to 
confirm if you've forked the image.
 - Run a single replica unless you accept that sessions are per-pod (the 
in-memory session store does not federate; see [session](session.md)).
 
-### Persisting state files (`audit`, `setup`, `alarms`, `debugLog`)
+### Persisting state files (`audit`, `debugLog`)
 
-The BFF writes runtime state to JSON Lines / JSON files. The image declares 
`/data` as a `VOLUME` and points the four defaults at `/data/*` via env vars, 
so **no `horizon.yaml` configuration is required** to get writable, persistable 
paths — operators just mount a volume at `/data`:
+The BFF writes runtime state to JSON Lines files. The image declares `/data` 
as a `VOLUME` and points the two defaults at `/data/*` via env vars, so **no 
`horizon.yaml` configuration is required** to get writable, persistable paths — 
operators just mount a volume at `/data`:
 
 ```sh
 docker run -d --name horizon \
@@ -265,8 +263,6 @@ If you want to override the locations, you can either:
 
   ```yaml
   audit:    { file: /var/log/horizon/audit.jsonl }
-  setup:    { file: /var/lib/horizon/setup.json }
-  alarms:   { file: /var/lib/horizon/alarms.json }
   debugLog: { file: /var/log/horizon/wire.jsonl }
   ```
 
diff --git a/docs/setup/files.md b/docs/setup/files.md
index cb98dcc..b86e80e 100644
--- a/docs/setup/files.md
+++ b/docs/setup/files.md
@@ -1,57 +1,37 @@
 # Runtime State Files
 
-State files for user-configured settings. Both write JSON, both are managed by 
the BFF, neither needs hand-editing.
+The BFF writes a small amount of runtime state to files, managed automatically 
— neither needs hand-editing.
 
-```yaml
-setup:
-  file: ./horizon-setup.json
-
-alarms:
-  file: ./horizon-alarms.json
-```
-
-## `setup.file`
+## `audit.file`
 
 | Field | Type | Default | Required | Notes |
 |---|---|---|---|---|
-| `setup.file` | string | `./horizon-setup.json` | no | Filesystem path to the 
setup state. Relative paths resolve from the BFF working directory. |
-
-Holds:
+| `audit.enabled` | boolean | `true` | no | Whether to record the audit trail. 
On by default; set `false` to disable audit logging entirely. Env: 
`HORIZON_AUDIT_ENABLED`. |
+| `audit.file` | string | `./horizon-audit.jsonl` | no | Filesystem path to 
the append-only audit log. |
 
-- Per-user landing layer order (`landing.priority`).
-- Layer-level setup state (which layers the user has marked as enabled / 
disabled in their sidebar).
-- Other persistent UI preferences that survive sessions.
+The append-only audit trail — one JSON line per mutating admin action. On by 
default; disable with `audit.enabled: false`. See [Audit 
Log](../access-control/audit-log.md).
 
-The UI writes this file as users change their landing order and layer 
enablement; the file is updated atomically (write-temp-then-rename).
-
-## `alarms.file`
+## `debugLog.file`
 
 | Field | Type | Default | Required | Notes |
 |---|---|---|---|---|
-| `alarms.file` | string | `./horizon-alarms.json` | no | Filesystem path to 
the alarm rules state. |
+| `debugLog.file` | string | `./horizon-wire.jsonl` | no | Filesystem path to 
the OAP wire debug log. Only written when `debugLog.enabled` is set. |
 
-Holds user-created alarm rules (in addition to whatever the OAP cluster ships 
bundled). The Alarm Rule Editor (Operate → Alarm Rules) writes here.
+The OAP request/response wire trace, off by default. See [Debug 
Log](debug-log.md).
 
 ## Env-var fallbacks
 
-When `horizon.yaml` does not supply a `setup.file` or `alarms.file` (or 
`audit.file` / `debugLog.file`), the default is seeded from an env var:
+When `horizon.yaml` does not supply `audit.file` or `debugLog.file`, the 
default is seeded from an env var:
 
 | YAML key | Env-var fallback | Default |
 |---|---|---|
-| `setup.file` | `HORIZON_SETUP_FILE` | `./horizon-setup.json` |
-| `alarms.file` | `HORIZON_ALARMS_FILE` | `./horizon-alarms.json` |
 | `audit.file` | `HORIZON_AUDIT_FILE` | `./horizon-audit.jsonl` |
 | `debugLog.file` | `HORIZON_WIRE_LOG_FILE` | `./horizon-wire.jsonl` |
 
-The published Docker image sets all four env vars to `/data/...` paths so an 
operator who runs the image without a `horizon.yaml` override gets writes 
routed to the declared `/data` volume — see [Container Image → Persisting state 
files](container-image.md#persisting-state-files-audit-setup-alarms-debuglog). 
An explicit value in `horizon.yaml` always wins over the env-var fallback.
+The published Docker image sets both env vars to `/data/...` paths so an 
operator who runs the image without a `horizon.yaml` override gets writes 
routed to the declared `/data` volume — see [Container Image → Persisting state 
files](container-image.md#persisting-state-files-audit-debuglog). An explicit 
value in `horizon.yaml` always wins over the env-var fallback.
 
 ## Operational notes
 
 - **Both are mutable runtime state.** They should be on durable storage, not a 
container tmpfs.
 - **Both are gitignored by default** (see `.gitignore`). They are not 
source-controlled; they are operational state.
-- **Atomic writes.** Both stores write-temp-then-rename, so a crash mid-write 
cannot truncate the file.
-- **No schema migration tooling yet.** If a future release changes the on-disk 
shape, you may need to delete the file and re-configure. (Documented per 
release.)
-
-## Hot reload
-
-The files are watched at startup. Changes made by the BFF itself take effect 
immediately. Editing the files by hand mid-run is not supported — restart the 
BFF after manual edits.
+- **Atomic appends.** The audit log is append-only, so a crash mid-write 
cannot corrupt earlier records.
diff --git a/docs/setup/overview.md b/docs/setup/overview.md
index ea218d7..1177cf0 100644
--- a/docs/setup/overview.md
+++ b/docs/setup/overview.md
@@ -143,8 +143,6 @@ Two changes still require a BFF restart:
 |---|---|---|
 | Config | `./horizon.yaml` | `HORIZON_CONFIG=` |
 | Audit log | `./horizon-audit.jsonl` | `audit.file` |
-| Setup state | `./horizon-setup.json` | `setup.file` |
-| Alarm rules | `./horizon-alarms.json` | `alarms.file` |
 | Wire debug log | `./horizon-wire.jsonl` | `debugLog.file` |
 | Bundled overview / layer templates | inside the BFF bundle | not 
user-editable as files; edit via admin pages |
 
diff --git a/horizon.yaml b/horizon.yaml
index 81fca14..8cb03c4 100644
--- a/horizon.yaml
+++ b/horizon.yaml
@@ -103,21 +103,16 @@ session:
   cookieName: "${HORIZON_SESSION_COOKIE_NAME:horizon_sid}"
   cookieSecure: ${HORIZON_SESSION_COOKIE_SECURE:false}   # set true behind 
HTTPS
 
-# State files. The image sets HORIZON_*_FILE=/data/* (its writable volume);
-# a local run defaults to ./horizon-* in the working dir. Set an explicit
-# path only if you need one the running process can write.
-audit:
-  file: "${HORIZON_AUDIT_FILE:./horizon-audit.jsonl}"
-setup:
-  file: "${HORIZON_SETUP_FILE:./horizon-setup.json}"
-alarms:
-  file: "${HORIZON_ALARMS_FILE:./horizon-alarms.json}"
-
 debugLog:
   enabled: ${HORIZON_DEBUG_LOG_ENABLED:false}
   file: "${HORIZON_WIRE_LOG_FILE:./horizon-wire.jsonl}"
   maxBodyChars: ${HORIZON_DEBUG_LOG_MAX_BODY_CHARS:8192}
   redactAuthHeaders: ${HORIZON_DEBUG_LOG_REDACT_AUTH:true}
+audit:
+  # On by default (the audit trail is a security record). Set false to disable
+  # audit logging entirely — no file is written.
+  enabled: ${HORIZON_AUDIT_ENABLED:true}
+  file: "${HORIZON_AUDIT_FILE:./horizon-audit.jsonl}"
 
 query:
   # Max services a layer landing runs metric MQE for, per request.
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index d49208b..c5e818e 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -35,8 +35,6 @@ export type {
   LandingColumn,
   LandingConfig,
   LayerConfig,
-  SetupResponse,
-  SetupSavePayload,
 } from './setup.js';
 export type { LandingAggregates, LandingResponse, LandingServiceRow } from 
'./landing.js';
 export type {
diff --git a/packages/api-client/src/setup.ts b/packages/api-client/src/setup.ts
index 5397907..8959201 100644
--- a/packages/api-client/src/setup.ts
+++ b/packages/api-client/src/setup.ts
@@ -16,12 +16,11 @@
  */
 
 /**
- * Wire shape for the per-layer customization persisted by the BFF.
- *
- * Eventually these configs will live in OAP via `addTemplate` mutations
- * (under the `horizon-` ID prefix) — see PLAN.md "Locked decisions" #2.
- * Until then, the BFF stores them in a JSON file on disk. The UI and
- * server agree on this shape so the swap is a one-place change.
+ * The resolved per-layer config shape. The UI derives a `LayerConfig` for each
+ * layer from that layer's template (slots / caps / metrics / overview) — see 
the
+ * setup resolver in `apps/ui/src/state/setup.ts`. Layer config is authored in 
the
+ * layer template (OAP-synced via the Layer dashboards admin); this is the 
shape
+ * the renderer reads, not a separately-persisted override.
  */
 
 import type { LayerCaps, LayerSlots } from './menu.js';
@@ -128,13 +127,3 @@ export interface LayerConfig {
   landing: LandingConfig;
 }
 
-export interface SetupResponse {
-  generatedAt: number;
-  /** Layer key → operator-overridden config. Layers without an override
-   *  fall through to horizon-side defaults at render time. */
-  layers: Record<string, LayerConfig>;
-}
-
-export interface SetupSavePayload {
-  layers: Record<string, LayerConfig>;
-}

Reply via email to