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

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

commit 7bb8fea7bdedbe88a4542e0fa9bb1628198d758c
Author: Wu Sheng <[email protected]>
AuthorDate: Thu May 21 17:25:09 2026 +0800

    docs: rewrite for end users — drop code-workflow internals
    
    Docs are for the operator / dashboard author, not the contributor.
    Removed implementation source pointers (apps/.../src paths, internal
    function / composable / component names) and step-by-step internal
    algorithms (e.g. the LDAP "Login flow" code steps) across the tree;
    kept config, fields, recipes, behavior, and troubleshooting. The LDAP
    doc now describes observable sign-in behavior + the service-bind group
    read. Added two CLAUDE.md principles: docs target the end user, and
    comments carry only non-obvious highlights / edge cases.
---
 CLAUDE.md                                |  4 ++-
 docs/README.md                           | 11 +-------
 docs/access-control/admin-pages.md       | 30 +++++++--------------
 docs/access-control/audit-log.md         |  8 +++---
 docs/access-control/break-glass.md       |  8 +++---
 docs/access-control/ldap-backend.md      | 37 +++++++++----------------
 docs/access-control/local-backend.md     | 12 ++++-----
 docs/access-control/rbac.md              | 44 +++++++-----------------------
 docs/compatibility/cluster-status.md     |  6 +----
 docs/compatibility/required-modules.md   |  4 +--
 docs/components/charts.md                | 46 ++++++++------------------------
 docs/components/dashboard-widgets.md     | 12 ++++-----
 docs/components/overview-widgets.md      | 16 ++++-------
 docs/customization/adding-a-new-layer.md |  2 +-
 docs/customization/layer-templates.md    |  2 +-
 docs/customization/menu-structure.md     | 24 +++++------------
 docs/customization/overview-templates.md |  4 +--
 docs/design-target.md                    |  6 ++---
 docs/operate/cluster-metadata.md         |  2 +-
 docs/operate/inspect.md                  | 16 -----------
 docs/setup/files.md                      |  8 +++---
 docs/setup/horizon-yaml.md               |  4 +--
 docs/setup/oap.md                        |  4 +--
 docs/setup/server.md                     |  6 +----
 24 files changed, 94 insertions(+), 222 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 00a92e8..f6d7248 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -53,6 +53,8 @@ Design tokens live in the runtime token CSS 
(`apps/ui/src/assets/styles/tokens.c
 
 `docs/` is now the **public website docs** tree (committed, flat layout, see 
`docs/menu.yml`). Do not put planning notes, research dumps, or design 
prototypes there — those stay out of git.
 
+**Docs are written for the end user (operator / dashboard author), not the 
contributor.** Document what a feature *does*, how to *configure* it (YAML, 
fields, recipes), and how to *operate / troubleshoot* it. Do **not** document 
the internal code workflow: no step-by-step algorithms ("1. service-bind 2. 
user search 3. …"), no source-file paths (`apps/bff/src/...`), no internal 
function / composable / route names, no "the BFF then fans out / chunks / 
probes …" implementation narration. If [...]
+
 ## Things that are non-negotiable
 
 - **TypeScript strict.** No `any` outside `.d.ts` shims.
@@ -66,7 +68,7 @@ Design tokens live in the runtime token CSS 
(`apps/ui/src/assets/styles/tokens.c
 - **Cascade-clear, then load.** When an upstream control changes (service / 
instance / endpoint pick, time-range change, layer / scope nav) and the 
downstream queries have to refire, the dependent area must visibly RESET first 
and show an explicit "Reading data…" (or equivalent) hint while the new query 
is in flight. Never leave the prior value sitting under a spinner — operators 
read it as the new state and trust broken data. Never let the page sit silently 
between the click and the res [...]
 - **MQE is a core capability**, not a config-screen afterthought. 
User-editable, syntax-highlighted, debuggable.
 - **Admin views use the same look.** LDAP/RBAC/admin are dark, dense, 
design-tokens — not a separate "settings" UI. Alarms are read-only on the UI 
side; recovery is backend-automatic (no acknowledge/close/silence actions).
-- **Comments earn their keep.** Only write a comment when it does one of two 
things: (1) introduces an API — what a module / component / exported function 
is for and how callers should think about it; (2) highlights a non-obvious 
gotcha — a hidden invariant, a workaround for a specific upstream quirk, a 
subtle scope/timing constraint. Do **not** write comments that paraphrase the 
code (`// loop over layers`, `// click handler — toggles open state`, `// 
returns true when active`). Do **no [...]
+- **Comments earn their keep.** A comment should carry only what is **not 
obvious from reading the core code** — a highlight, a special case, or an edge 
case worth flagging for a future reviewer. Two valid uses: (1) introduces an 
API — what a module / component / exported function is for and how callers 
should think about it; (2) highlights a non-obvious gotcha — a hidden 
invariant, a workaround for a specific upstream quirk, a subtle scope/timing 
constraint, an edge case the reader woul [...]
 - **Layering — BFF.** `apps/bff/src/` is grouped by role, and the flow is 
one-directional: `http → logic → client → OAP`.
   - `http/{query,config,admin}/` — Fastify route handlers only. Thin: parse, 
dispatch to logic, shape the reply. No OAP I/O directly.
   - `logic/<domain>/` — domain orchestration + background timers (alarms 
store, layer/overview/setup loaders, preflight status check, inspect parsers, 
dashboard defaults). No HTTP framework, no OAP fetch detail.
diff --git a/docs/README.md b/docs/README.md
index c8b4aff..f098e10 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -16,16 +16,7 @@ The sidebar on the left of this site is the canonical entry 
point — every sect
 
 ## Quick orientation
 
-The UI is a pnpm monorepo:
-
-| App / package | Purpose |
-|---|---|
-| `apps/ui/` | Vue 3 + Vite single-page app. |
-| `apps/bff/` | Fastify-based Backend For Frontend. The single place that 
talks to OAP — query GraphQL + admin REST + Zipkin. |
-| `packages/api-client/` | TypeScript types shared between BFF and UI. |
-| `packages/design-tokens/` | CSS custom properties shipped to both apps (the 
5 bundled themes live here). |
-
-The UI **only** talks to the BFF; the BFF is the single place that talks to 
OAP. Every OAP-side requirement is enforced once, in the BFF, not scattered 
through the UI.
+Horizon runs as two pieces: a Vue single-page app (the UI) and a Backend For 
Frontend (the BFF). The UI **only** talks to the BFF; the BFF is the single 
place that talks to OAP — query GraphQL, admin REST, and Zipkin. Every OAP-side 
requirement is enforced once, in the BFF, not scattered through the UI.
 
 ## Where to start, by role
 
diff --git a/docs/access-control/admin-pages.md 
b/docs/access-control/admin-pages.md
index 448e8d4..f54c799 100644
--- a/docs/access-control/admin-pages.md
+++ b/docs/access-control/admin-pages.md
@@ -5,7 +5,6 @@ Three pages under `/admin/` surface authentication, user, and 
RBAC state for liv
 ## Login page
 
 **Path:** `/login`
-**File:** `apps/ui/src/features/auth/LoginView.vue`
 
 The redesigned login page:
 
@@ -14,23 +13,18 @@ The redesigned login page:
 - **Inline auth-status pill** that adapts to the active backend:
   - Green: local backend, **or** LDAP backend with the directory reachable.
   - Red: LDAP backend with directory unreachable (warns that break-glass may 
be armed).
-- Backend health is polled every 5 seconds via `GET /api/auth/health`.
+- Backend health is polled continuously so the pill reflects directory outages 
in near real time.
 - Form fields: username, password. Submit is disabled while in flight.
 - Apache copyright footer with auto-current year.
 
-After successful login, the UI redirects to:
-
-1. `?redirect=<path>` if the user was bounced from a protected route, **or**
-2. `landingByRole[<first role>]` (see [RBAC](rbac.md)).
+After successful login, the UI redirects to the page the user was bounced from 
(if they hit login from a protected route), otherwise to the landing route for 
their role (see [RBAC](rbac.md)).
 
 ## Auth Status
 
 **Path:** `/admin/auth-status`
 **Verb:** `auth:read` (maintainer, admin)
-**File:** `apps/ui/src/features/admin/auth-status/AuthStatusView.vue`
-**Endpoints:** `GET /api/admin/auth-status` (30 s auto-refresh), `POST 
/api/admin/auth-status/probe`
 
-The single pane for "is my auth wiring correct?" Shows:
+Auto-refreshes every 30 seconds. The single pane for "is my auth wiring 
correct?" Shows:
 
 | Section | Content |
 |---|---|
@@ -39,13 +33,13 @@ The single pane for "is my auth wiring correct?" Shows:
 | Local users | Count of `auth.local.users` entries (zero in LDAP mode). |
 | LDAP probe | Reachability, service-bind success, user-search success, 
latency, last error. |
 | Group-to-role mappings | The full `groupMappings` table from `horizon.yaml`. 
|
-| Active sessions | Count from the in-memory session map. |
+| Active sessions | Count of currently open sessions. |
 | Break-glass | Configured? Armed (LDAP unhealthy)? Username (hash not shown). 
|
 | RBAC policy snapshot | All role names, all known verbs. |
 
 ### Live probe
 
-A manual **Probe now** button fires `POST /api/admin/auth-status/probe` for an 
immediate refresh (does not wait for the 30 s tick).
+A manual **Probe now** button triggers an immediate refresh (does not wait for 
the 30 s tick).
 
 ### Username resolver
 
@@ -60,14 +54,12 @@ No login required for the resolution — useful for "if Alice 
tried to log in ri
 
 **Path:** `/admin/users`
 **Verb:** `user:read` (admin)
-**File:** `apps/ui/src/features/admin/users/UsersAdminView.vue`
-**Endpoint:** `GET /api/admin/users` (15 s auto-refresh)
 
-Lists users known to this BFF instance. Three sources merged:
+Auto-refreshes every 15 seconds. Lists users known to this BFF instance, drawn 
from three sources:
 
-1. **LDAP users**: from the in-memory seen-cache (anyone who successfully 
logged in via LDAP on this BFF since startup).
-2. **Local users**: static entries from `auth.local.users` in `horizon.yaml`.
-3. **Break-glass logins**: seen-cache entries marked `source: break-glass`.
+- **LDAP users**: anyone who successfully logged in via LDAP on this BFF since 
startup.
+- **Local users**: static entries from `auth.local.users` in `horizon.yaml`.
+- **Break-glass logins**: prior break-glass sessions seen on this BFF.
 
 Per-row:
 
@@ -89,14 +81,12 @@ Per-row:
 
 ### Operations
 
-The Users page is **read-only**. To add a local user, edit `horizon.yaml`. To 
remove an LDAP user, do so in the directory; the seen-cache entry persists 
until BFF restart but is informational only.
+The Users page is **read-only**. To add a local user, edit `horizon.yaml`. To 
remove an LDAP user, do so in the directory; the row persists until BFF restart 
but is informational only.
 
 ## Roles & Permissions
 
 **Path:** `/admin/roles`
 **Verb:** `role:read` (admin)
-**File:** `apps/ui/src/features/admin/roles/RolesView.vue`
-**Endpoint:** `GET /api/admin/auth-status` (shared with the Auth Status page; 
reads the `rbac` block)
 
 Renders a read-only board of roles × verbs as a check-mark grid. The intent is 
to answer "what can role X do?" without having to re-derive it from 
`horizon.yaml`.
 
diff --git a/docs/access-control/audit-log.md b/docs/access-control/audit-log.md
index ce7cf4b..6a008ec 100644
--- a/docs/access-control/audit-log.md
+++ b/docs/access-control/audit-log.md
@@ -4,7 +4,7 @@ The audit log records sensitive operations as JSON Lines, one 
event per line, ap
 
 ## Event schema
 
-Source: `apps/bff/src/audit/logger.ts`.
+Each event has these fields:
 
 ```ts
 interface AuditEvent {
@@ -107,7 +107,7 @@ The recorded set evolves with the codebase. As of the 
current build:
 ## File format
 
 - **JSON Lines.** One JSON object per line, `\n`-terminated.
-- **Append-only.** The BFF opens the file in append mode and never truncates / 
rotates.
+- **Append-only.** The file is only ever appended to — never truncated or 
rotated.
 - **No rotation built in.** Pair with `logrotate`, `vector`, `fluent-bit`, or 
a sidecar shipper.
 
 ## Storage placement
@@ -124,11 +124,11 @@ The recorded set evolves with the codebase. As of the 
current build:
 
 ## In-memory "seen cache"
 
-In addition to the on-disk audit log, the BFF maintains an in-memory 
`UserSeenCache` of successful logins:
+In addition to the on-disk audit log, the BFF keeps an in-memory record of 
recent successful logins:
 
 - Records: username, source (`local` / `ldap` / `break-glass`), roles, 
last-seen timestamp, last IP.
 - Reset on BFF restart.
-- Exposed via `GET /api/admin/users` and visible on the Users admin page.
+- Visible on the Users admin page.
 
 This is a UX convenience — it lets the Users page show "who has logged in to 
this BFF instance recently" without parsing the audit log. For historical / 
cluster-wide analysis, parse the JSONL file directly.
 
diff --git a/docs/access-control/break-glass.md 
b/docs/access-control/break-glass.md
index 97896a7..dea6313 100644
--- a/docs/access-control/break-glass.md
+++ b/docs/access-control/break-glass.md
@@ -30,7 +30,7 @@ The block is **optional** — leave it out (or commented) to 
disable break-glass
 Break-glass is honored at login **only when both** are true:
 
 1. `auth.backend: ldap` (the block is unused in local mode — a startup warning 
is logged if both are present).
-2. `ldapHealth.isUnhealthy()` returns true at the moment of login. The probe 
runs continuously; "unhealthy" means the last probe (TCP / bind / search) 
failed.
+2. The directory is unreachable at the moment of login. Horizon probes the 
directory continuously; "unhealthy" means the most recent probe (connect / bind 
/ search) failed.
 
 When activated:
 
@@ -43,10 +43,8 @@ When LDAP is healthy again, the break-glass username is 
rejected at login — ev
 
 ## Verification
 
-`apps/bff/src/user/break-glass.ts`:
-
-- Same Argon2id verifier as the local backend.
-- **Timing-safe** — a wrong username still incurs the argon2 cost (verify 
against a dummy hash) to prevent leaking which break-glass username is 
configured via timing.
+- The password is checked with the same Argon2id verification as the local 
backend.
+- **Timing-safe** — a wrong username still incurs the full argon2 cost, so an 
attacker cannot learn the configured break-glass username from response timing.
 
 ## Audit
 
diff --git a/docs/access-control/ldap-backend.md 
b/docs/access-control/ldap-backend.md
index 7e22288..0694586 100644
--- a/docs/access-control/ldap-backend.md
+++ b/docs/access-control/ldap-backend.md
@@ -28,20 +28,16 @@ auth:
 
 Bootstrap rule: `ldap.groupMappings` must be non-empty before LDAP users can 
sign in. The BFF boots and surfaces the setup-required state on the login page, 
but no LDAP login succeeds until at least one mapping is configured.
 
-## Login flow
+## How sign-in works
 
-`apps/bff/src/user/ldap.ts`:
+Horizon never stores or reads stored passwords. A sign-in attempt 
authenticates **as the user** against the directory with the typed password, so 
the directory itself decides whether the password is valid. On success, Horizon 
reads the user's group memberships and maps them to roles via `groupMappings`.
 
-1. **Service bind** (if `bindDn` is set) or anonymous bind. Used to search for 
the user's DN.
-2. **User search** — apply `userFilter` against `userBaseDn`, substituting 
`{username}` (RFC 4515 escaped). Expect exactly one result; multiple matches 
abort with `null`.
-3. **User bind** — bind directly as the discovered DN with the typed password. 
A successful bind proves the password.
-4. **Group resolution** — per `groupStrategy`:
-   - `memberOf`: read the `memberOf` attribute from the user entry (AD-style).
-   - `search`: search `groupBaseDn` for groups whose `memberAttr` contains the 
user's DN (OpenLDAP-style).
-5. **Group → role mapping** — walk `groupMappings` in order. **First match 
wins per mapping** (a user matching multiple mappings gets the union of their 
roles).
-6. Return `{ username, roles }` on success, `null` on any failure.
+What this means when you configure LDAP:
 
-A failure at any step returns `null` — the UI shows a generic "Invalid 
credentials" message. No information leak about which step failed.
+- **Group membership is read with the service account** (`bindDn`), not the 
user's own credentials. Many directories deny ordinary users read access to the 
group subtree, so the **service account must be able to see groups** — 
otherwise every user falls back to the `*` role.
+- **`userFilter` must resolve to a single user.** If it matches more than one 
entry, the first match is used.
+- **Roles are the union of all matching `groupMappings`** — a user in two 
mapped groups gets both roles' permissions.
+- **Failures are deliberately indistinguishable.** A wrong password, a missing 
user, or no matching group all surface the same "Invalid credentials" message; 
the specific cause is never revealed to the browser.
 
 ## Field reference
 
@@ -64,7 +60,7 @@ See [`auth`](../setup/auth.md) for the field table.
 | Strategy | When to use |
 |---|---|
 | `memberOf` | Active Directory and most modern OpenLDAP deployments. User 
entries carry a `memberOf` multi-valued attribute. Faster (single read, no 
second search). |
-| `search` | OpenLDAP deployments where users do not carry `memberOf`. 
Requires `groupBaseDn` and uses `memberAttr` (usually `member` or 
`uniqueMember`). |
+| `search` | OpenLDAP deployments where users do not carry `memberOf`. 
Requires `groupBaseDn` and uses `memberAttr` (usually `member` or 
`uniqueMember`). The **service account** (`bindDn`) — not the logging-in user — 
performs this search, so it must have read access to the group subtree. |
 
 When unsure, try `memberOf` first; if a successful user bind returns no 
groups, switch to `search`.
 
@@ -83,21 +79,11 @@ groupMappings:
 - A user matching multiple groups gets the **union** of all matching roles. 
E.g., a user in both `cn=sre` and `cn=platform` ends up with `operator` and 
`maintainer` roles (effective verbs are the union of both role's grants).
 - Order matters only in the sense of being listed; all matching entries 
contribute.
 
-## Health probing
+## Health and directory reachability
 
-The BFF exposes `GET /api/auth/health` (polled by the login page every 5 
seconds):
+The login page continuously reflects whether the directory is reachable, so 
operators see an outage before a user reports a failed login. When the 
directory is unreachable, that state is also what arms [Break-Glass 
Access](break-glass.md).
 
-- `local`: backend is local — returns `reachable: true` unconditionally.
-- `ldap reachable`: last LDAP probe succeeded.
-- `ldap unreachable`: last probe failed.
-
-The health probe runs:
-
-1. TCP / TLS connect to `ldap.url`.
-2. Service bind (or anonymous bind).
-3. (Optional username resolver) A test search for a known username when 
invoked from the admin Auth Status page.
-
-Probe failure is the trigger condition for [Break-Glass 
Access](break-glass.md).
+The admin **Auth Status** page lets you confirm the connection (and the 
service bind) and test a username against the live directory — it shows the 
groups returned and the roles those groups resolve to, without the user needing 
to sign in. Use it to debug `groupMappings` and to verify the service account 
can read the group subtree.
 
 ## TLS
 
@@ -124,5 +110,6 @@ OAP does **not** see Horizon's LDAP credentials. The user 
authenticates against
 
 - **Service bind fails silently.** Wrong `bindDn` or `bindPassword` causes all 
logins to fail with a generic message. Verify by looking at LDAP server logs.
 - **`groupStrategy: memberOf` on a directory that doesn't populate it.** 
Logins succeed but every user gets only the `"*"` fallback role. Switch to 
`search`.
+- **`search` strategy with a locked-down group subtree.** Group resolution 
runs on the service account (`bindDn`), so grant *that* account read access to 
`groupBaseDn`. (The logging-in user does not need it — Horizon never uses the 
user's own bind for group lookup.)
 - **Forgetting the `"*"` fallback.** A user who authenticates but matches no 
group mapping is rejected — change to `null` and the UI shows "Invalid 
credentials". Add `"*" → viewer` for graceful degradation.
 - **`tlsInsecure: true` in production.** A man-in-the-middle on the LDAP 
connection can capture every typed password. Use proper certificates instead.
diff --git a/docs/access-control/local-backend.md 
b/docs/access-control/local-backend.md
index f1f1cb8..dc01ce7 100644
--- a/docs/access-control/local-backend.md
+++ b/docs/access-control/local-backend.md
@@ -38,16 +38,14 @@ Paste into `passwordHash`. The CLI does not store anything; 
copy the hash, lose
 
 Algorithm: Argon2id with the `node-argon2` defaults (m=65536, t=3, p=4). 
Hashes generated by other tools are accepted as long as they validate via the 
node-argon2 verifier.
 
-## How verification works
+## How sign-in works
 
-`apps/bff/src/user/local.ts`:
+A sign-in matches the typed username against `local.users` and verifies the 
password against the stored Argon2id hash. On a match the session is created 
with that user's roles; otherwise the login is rejected.
 
-1. Lookup user by `username`. If not found, fall through to a **dummy** 
Argon2id verification against a sentinel hash. This makes "user does not exist" 
indistinguishable from "wrong password" via timing — preventing username 
enumeration.
-2. If found, `argon2.verify(passwordHash, typedPassword)`.
-3. On match, return `{ username, roles }`.
-4. On mismatch, return `null`.
+What this means in practice:
 
-The BFF logs success / failure through the audit log (see [Audit 
Log](audit-log.md)) but never logs the typed password.
+- **Username enumeration is prevented.** A wrong username and a wrong password 
are indistinguishable — an unknown username still incurs the full 
password-verification cost, so response timing never reveals whether a username 
exists.
+- **Successes and failures are recorded** in the audit log (see [Audit 
Log](audit-log.md)), but the typed password is never logged.
 
 ## Operations
 
diff --git a/docs/access-control/rbac.md b/docs/access-control/rbac.md
index 3766315..8a410b0 100644
--- a/docs/access-control/rbac.md
+++ b/docs/access-control/rbac.md
@@ -5,15 +5,15 @@ Horizon enforces access at the BFF on every HTTP request. The 
UI hides controls
 ## Model
 
 - **Subject**: an authenticated session (`username + roles`).
-- **Object**: an HTTP route.
-- **Action (verb)**: a dot-namespaced string declared by the route policy 
table.
-- **Decision**: granted iff any of the user's roles has a grant that matches 
the route's required verb.
+- **Object**: a protected request.
+- **Action (verb)**: a dot-namespaced string each protected request requires.
+- **Decision**: granted if any of the user's roles holds a grant that matches 
the required verb.
 
-Sessions capture the **role list** at login time. Verbs are computed per 
request from `session.roles → rbac.roles → grants`. Hot-reloading role 
definitions takes effect on the next route check; hot-reloading group mappings 
or local user roles requires the user to re-login (since sessions hold their 
original role list).
+Sessions capture the **role list** at login time, and the verbs they grant are 
resolved from the current `rbac.roles` definitions on each request. 
Hot-reloading role definitions takes effect immediately; hot-reloading group 
mappings or local user roles requires the user to re-login (since sessions hold 
their original role list).
 
 ## Verb vocabulary
 
-Source: `apps/bff/src/rbac/verbs.ts`. Twenty-eight verbs grouped into areas:
+Twenty-eight verbs grouped into areas:
 
 ### Data reads (the public catalog)
 
@@ -70,7 +70,7 @@ Source: `apps/bff/src/rbac/verbs.ts`. Twenty-eight verbs 
grouped into areas:
 
 ## Grant matching
 
-A user's grant string is matched against a required verb using these rules 
(`verbs.ts`):
+A user's grant string is matched against a required verb using these rules:
 
 | Grant pattern | Matches |
 |---|---|
@@ -132,7 +132,7 @@ A user with no role gets no verbs. The session is created 
(login succeeds) but e
 
 ## Landing route per role
 
-After login, the BFF returns a `landingRoute` from `rbac.landingByRole`. The 
UI router uses it as the post-login destination unless `?redirect=` overrides 
(set when the user was bounced to login from a protected route — they return to 
where they came from).
+After login, the user lands on the route configured for their role in 
`rbac.landingByRole` — unless they were bounced to login from a protected 
route, in which case they return to where they came from.
 
 Default mapping:
 
@@ -148,35 +148,9 @@ When a user has multiple roles, the **first role on the 
user** wins. Order matte
 
 ## Enforcement
 
-Source: `apps/bff/src/rbac/route-policy.ts`, `apps/bff/src/rbac/policy.ts`, 
`apps/bff/src/user/middleware.ts`.
+Access is enforced server-side, not in the browser. Every protected request is 
checked for a valid session (an unauthenticated request is rejected with `401`) 
and then for the verb that request requires (a session lacking the verb is 
rejected with `403`). The UI hides controls a session cannot use, but a forged 
UI cannot bypass these checks.
 
-The BFF builds a route → required-verb table at startup. Every Fastify route 
is gated by:
-
-1. `requireAuth()` — looks up the session cookie, returns 401 on missing / 
expired.
-2. `checkVerb(verb)` — looks up the session's effective verbs, returns 403 on 
mismatch.
-
-Routes without an explicit policy entry default to `'auth'` (session required, 
no specific verb) **with a warning at startup**. This is fail-safe: a forgotten 
route does not become accidentally public.
-
-### Policy values
-
-| Policy | Meaning |
-|---|---|
-| `'public'` | No auth required. Login, logout, health-check endpoints. |
-| `'auth'` | Session required; no verb check. Identity-only routes. |
-| `'<verb>'` | Session required + verb check. Most application routes. |
-
-### Example policy entries
-
-```ts
-'POST /api/auth/login':              'public',
-'POST /api/auth/logout':             'public',
-'GET /api/auth/me':                  'auth',
-'GET /api/oap/info':                 'auth',
-'POST /api/layer/:key/dashboard':    'metrics:read',
-'GET /api/rule':                     'rule:read',
-'POST /api/rule/addOrUpdate':        'rule:write',
-'POST /api/admin/auth-status/probe': 'auth:read',
-```
+Enforcement is fail-safe: a request with no explicit verb still requires a 
valid session, so a misconfiguration cannot accidentally expose a protected 
endpoint to anonymous callers.
 
 ## Disabling RBAC for dev
 
diff --git a/docs/compatibility/cluster-status.md 
b/docs/compatibility/cluster-status.md
index 8331c6f..f54a8af 100644
--- a/docs/compatibility/cluster-status.md
+++ b/docs/compatibility/cluster-status.md
@@ -6,8 +6,6 @@ This page is intentionally two-pane: a healthy `:12800` with 
broken `:17128` is
 
 ## Pane A — Query / GraphQL port (`:12800`)
 
-**Source:** `apps/bff/src/http/query/info.ts`, UI composable 
`apps/ui/src/shell/useOapInfo.ts`.
-
 **Single GraphQL call** fired every 30 seconds:
 
 ```graphql
@@ -41,8 +39,6 @@ query {
 
 ## Pane B — Admin host (`:17128`)
 
-**Source:** `apps/bff/src/http/query/preflight.ts`, UI composable 
`apps/ui/src/shell/useAdminFeatures.ts`.
-
 **Single admin REST call** fired every 60 seconds:
 
 ```
@@ -86,7 +82,7 @@ The sequence is fail-fast: once `admin-server` itself is off, 
the dump is empty
 
 In addition to the two health panes, the page lists OAP cluster members:
 
-- **Source**: `GET <queryUrl>/status/cluster/nodes` (status client, 
`packages/api-client/src/status.ts`).
+- **Source**: `GET <queryUrl>/status/cluster/nodes`.
 - **Returns**: per-node host, port, role, heartbeat.
 - **Use**: confirm cluster size matches expectations (e.g., 3-node OAP behind 
one DNS name should show three rows).
 
diff --git a/docs/compatibility/required-modules.md 
b/docs/compatibility/required-modules.md
index 1a0c420..8d625d3 100644
--- a/docs/compatibility/required-modules.md
+++ b/docs/compatibility/required-modules.md
@@ -22,9 +22,7 @@ The entire admin-port surface (all four modules) is **OAP 
11.x only**. On OAP 10
 
 ## How Horizon detects module state
 
-Source: `apps/bff/src/logic/preflight/preflight.ts`.
-
-1. BFF fires `GET <adminUrl>/debugging/config/dump` (polled every 60 seconds 
from the UI).
+1. Horizon fires `GET <adminUrl>/debugging/config/dump` (polled every 60 
seconds from the UI).
 2. OAP returns a flat key/value map in `module.provider.property` form, e.g.:
 
    ```
diff --git a/docs/components/charts.md b/docs/components/charts.md
index 496c360..6403c6c 100644
--- a/docs/components/charts.md
+++ b/docs/components/charts.md
@@ -1,15 +1,12 @@
 # Charts
 
-Horizon wraps all chart rendering in dedicated components. The widget 
primitives (overview + dashboard) delegate to these wrappers. Per project rule, 
**no view ever instantiates ECharts directly** — the wrappers own the 
lifecycle, handle theming, sync crosshairs, and tear down on unmount.
+Horizon renders metrics through a small set of chart kinds. This page 
describes each kind, the inputs it accepts, and how it behaves. For the 
dashboard/overview widget types that select these charts, see [Dashboard 
Widgets](dashboard-widgets.md).
 
-This page is for developers extending or troubleshooting the chart layer. 
End-users of the templating system do not need it; reach for [Dashboard 
Widgets](dashboard-widgets.md) instead.
+## Time chart
 
-## `TimeChart`
-
-**Path:** `apps/ui/src/components/charts/TimeChart.vue`
 **Used by:** `line` dashboard widget; ad-hoc embeds in feature pages.
 
-**Renders:** Multi-series line chart via ECharts.
+**Renders:** Multi-series line chart.
 
 ### Props
 
@@ -37,17 +34,12 @@ interface Series {
 - Dual y-axis appears when any series has `yAxisIndex: 1`.
 - Legend visible iff `series.length > 1`.
 - Smooth lines with circle point markers.
-- Tooltip positioned via callback (appendToBody) so it does not clip near grid 
edges.
-- **Synced crosshairs**: hover events broadcast to peer `TimeChart` instances 
on the same page via the shared chart hover bus.
-- Fingerprinting: data-only updates animate smoothly; structure changes 
(series count, label set) do a full replace.
-
-### Adding a new chart kind
+- Tooltip is positioned so it does not clip near grid edges.
+- **Synced crosshairs**: hovering broadcasts to peer time charts on the same 
page so they highlight the same time.
+- Data-only updates animate smoothly; structure changes (series count, label 
set) do a full replace.
 
-Any new ECharts-backed visualization should land as a sibling component, not 
as a fork of `TimeChart`. Share the hover-bus subscription if it should 
participate in synced crosshairs.
+## Top list
 
-## `TopList`
-
-**Path:** `apps/ui/src/components/charts/TopList.vue`
 **Used by:** `top` dashboard widget.
 
 **Renders:** Sorted list with optional tab switcher.
@@ -87,9 +79,8 @@ interface DashboardTopItem {
 - Background fill bar normalized to the maximum value (per tab in multi-list 
mode).
 - Tabs shown when `groups.length > 1`.
 
-## `AlarmsTimeline`
+## Alarms timeline
 
-**Path:** `apps/ui/src/components/charts/AlarmsTimeline.vue`
 **Used by:** Alarms page (full timeline above the alarm table).
 
 **Renders:** Per-minute stacked bar chart of firing + recovered alarms, with 
brush selection.
@@ -118,12 +109,11 @@ interface DashboardTopItem {
 - Brush (`lineX`) for range selection. Snaps to minute boundaries.
 - Click on non-zero point → selects that single minute. Click on zero → clears 
selection.
 
-## `Sparkline`
+## Sparkline
 
-**Path:** `apps/ui/src/components/charts/Sparkline.vue`
 **Used by:** Inline tiles, sidebar mini-charts, layer service-list picker 
(when a column carries a trend).
 
-**Renders:** Tiny inline SVG. No ECharts, no animation — lightweight enough to 
render dozens per page.
+**Renders:** Tiny inline trend line — lightweight enough to render dozens per 
page.
 
 ### Props
 
@@ -150,20 +140,6 @@ interface DashboardTopItem {
 - Gap bridging on `null` entries (line breaks).
 - No interactivity beyond hover broadcasting.
 
-## D3 components
-
-Where ECharts is wrong (custom interactions, non-cartesian layouts), Horizon 
uses D3 wrappers under `apps/ui/src/components/charts/` named `Native*`. The 
same lifecycle rule applies — the composable owns mount, render, and tear-down; 
no view manipulates the DOM directly.
-
 ## Theming
 
-All chart colors derive from the design token CSS 
(`apps/ui/src/assets/styles/tokens.css`). Per-chart accents take a CSS variable 
string (`var(--sw-accent)`) by default — the chart resolves to the token's 
current value at render time, which means a theme switch updates colors live 
without remount.
-
-Hex strings are accepted for one-off cases (e.g. severity colors); prefer 
tokens for anything that should follow theming.
-
-## Adding a new chart wrapper
-
-1. Place under `apps/ui/src/components/charts/` (shared) or 
`apps/ui/src/features/<feature>/` (feature-scoped) per the layering rule.
-2. Own the lifecycle: instantiate in `onMounted`, dispose in 
`onBeforeUnmount`. Never let the chart outlive its component.
-3. Resolve theme tokens via `getComputedStyle(document.documentElement)` if 
you need numeric values; or pass CSS variable strings through directly when the 
chart supports them.
-4. If the chart is time-series with hover semantics, subscribe to the shared 
hover bus so it joins synced crosshairs.
-5. Add a license header (`.ts` and `.vue` files require one — see 
`.licenserc.yaml`).
+Chart colors follow the active design theme. Per-chart accents default to the 
theme accent and update live when the theme is switched — no reload needed. Hex 
color strings are accepted for one-off cases (e.g. severity colors); prefer the 
theme accent for anything that should follow theming.
diff --git a/docs/components/dashboard-widgets.md 
b/docs/components/dashboard-widgets.md
index 3e462bf..18930b5 100644
--- a/docs/components/dashboard-widgets.md
+++ b/docs/components/dashboard-widgets.md
@@ -1,6 +1,6 @@
 # Dashboard Widgets
 
-Four widget types render on per-layer dashboards. The renderer 
(`apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue`) branches on 
`widget.type` and delegates to a small component per kind.
+Four widget types render on per-layer dashboards. Each `widget.type` you set 
in a template selects one of them.
 
 ## Grid context
 
@@ -70,7 +70,7 @@ A `line` widget with a scalar-shaped MQE renders a one-point 
chart, which is mis
 
 ## `line`
 
-**Renders:** Multi-series line chart via the `TimeChart` component (ECharts 
wrapper).
+**Renders:** Multi-series line chart.
 
 ### Multi-series
 
@@ -115,9 +115,9 @@ When any series has `yAxisIndex: 1`, the right axis 
appears. Use for mixed-unit
 
 - Smooth lines with circle markers.
 - Legend visible when more than one series; hidden for single series.
-- Tooltip positioned via callback (appendToBody) so it does not clip near grid 
edges.
+- Tooltip is positioned so it does not clip near grid edges.
 - **Synced crosshairs**: pointing at a time on this chart highlights the same 
time on every other `line` chart on the page.
-- Fingerprinting: data-only updates (same structure, new values) animate 
smoothly. Structure changes do a full replace.
+- Data-only updates (same structure, new values) animate smoothly. Structure 
changes do a full replace.
 
 ### When `line` is wrong
 
@@ -188,7 +188,7 @@ The data source returns a record set (rows × typed columns) 
rather than a numer
 ### Behavior
 
 - Renders as a dense table with column headers from the record's typed fields.
-- Sort, filter, pagination handled in the component.
+- Supports sort, filter, and pagination.
 
 ## Visibility predicates
 
@@ -229,6 +229,6 @@ The widget editor (planned) will warn on type / MQE 
mismatches. The schema does
 
 ## Per-scope widget sets
 
-The `dashboards.<scope>` map on a layer template lets you define different 
widget grids for service / instance / endpoint / topology / trace / logs / 
profiling pages. Scope resolution falls back to `service` if a specific scope 
is unset (`apps/bff/src/logic/layers/loader.ts:widgetsForScope()`).
+The `dashboards.<scope>` map on a layer template lets you define different 
widget grids for service / instance / endpoint / topology / trace / logs / 
profiling pages. If a specific scope is unset, it falls back to the `service` 
scope.
 
 See [Customization → Layer Dashboard 
Templates](../customization/layer-templates.md) for the per-scope structure.
diff --git a/docs/components/overview-widgets.md 
b/docs/components/overview-widgets.md
index d2c50e1..5e6f1e7 100644
--- a/docs/components/overview-widgets.md
+++ b/docs/components/overview-widgets.md
@@ -1,6 +1,6 @@
 # Overview Widgets
 
-Six widget types render on overview pages. Each is a single Vue component with 
a tightly scoped prop interface; the renderer 
(`apps/ui/src/render/overview/OverviewDashboardView.vue`) branches on 
`widget.type` and passes the relevant fields.
+Six widget types render on overview pages. Each `widget.type` you set in a 
template selects one of them, and reads its own set of fields.
 
 ## Grid context (recap)
 
@@ -11,7 +11,6 @@ Six widget types render on overview pages. Each is a single 
Vue component with a
 
 ## `metric`
 
-**Component:** `MetricWidget.vue`
 **Renders:** Single scalar with optional unit. Used for headline KPIs on 
overviews.
 
 ### Fields
@@ -30,7 +29,7 @@ Six widget types render on overview pages. Each is a single 
Vue component with a
 
 ### Behavior
 
-Value formatting via `formatValue()` (`render/widgets/ValueFormat.ts`):
+Values are formatted compactly:
 
 - M / k suffixes for large numbers (1.2M, 3.4k).
 - Two decimal places for fractional values.
@@ -54,7 +53,6 @@ Value formatting via `formatValue()` 
(`render/widgets/ValueFormat.ts`):
 
 ## `kpi-tile`
 
-**Component:** `KpiTileWidget.vue`
 **Renders:** Compound tile — optional service-count header row plus N KPI 
rows. Each KPI row is either a number readout or a progress bar.
 
 ### Fields
@@ -79,7 +77,7 @@ Value formatting via `formatValue()` 
(`render/widgets/ValueFormat.ts`):
 
 ### Behavior
 
-- `style: number` — value formatted via `formatValue()`, right-aligned.
+- `style: number` — value formatted compactly, right-aligned.
 - `style: progress-bar` — fill ratio = `value / max`. Color follows the layer 
accent.
 - `showCount` row clickable; KPI rows are not (the whole tile is the unit of 
action).
 
@@ -103,7 +101,6 @@ Value formatting via `formatValue()` 
(`render/widgets/ValueFormat.ts`):
 
 ## `metric-composite`
 
-**Component:** `MetricCompositeWidget.vue`
 **Renders:** Mixed KPI layout — number-style KPIs go into auto-fit count 
tiles; progress-bar-style (or `unit: '%'`) KPIs go into the bar grid. One 
widget can carry both shapes.
 
 This is the unified replacement for the old per-feature widgets 
(`k8s-service-count`, `pilot`, `service-count`). Anything compound now goes 
through `metric-composite`.
@@ -113,7 +110,7 @@ This is the unified replacement for the old per-feature 
widgets (`k8s-service-co
 | Field | Type | Notes |
 |---|---|---|
 | `id`, `title`, `tip`, `layer`, `span`, `rowSpan` | — | Common. |
-| `kpis` | `OverviewKpi[]` | Auto-split by the renderer. |
+| `kpis` | `OverviewKpi[]` | Auto-split between count tiles and the bar grid 
(see below). |
 
 ### Layout
 
@@ -152,7 +149,6 @@ Otherwise it lands in the count tiles. This lets you author 
a Kubernetes-style s
 
 ## `alarms`
 
-**Component:** `AlarmsWidget.vue`
 **Renders:** Active-incident rail. Top-N rows of the most recent firing alarms 
in the last 60 minutes, plus a total count chip.
 
 ### Fields
@@ -165,7 +161,7 @@ Otherwise it lands in the count tiles. This lets you author 
a Kubernetes-style s
 
 ### Behavior
 
-- Fetches via `bff.alarms.list()` (60-minute window, server-resolved).
+- Fetches the most recent firing alarms over a 60-minute, server-resolved 
window.
 - **Dual-mode fetch:**
   - **Modern** (`queryAlarms` capability present): server-side layer filter, 
server-side time window.
   - **Legacy** (`getAlarm` only): all-layers fetch, client-side layer filter.
@@ -188,7 +184,6 @@ Otherwise it lands in the count tiles. This lets you author 
a Kubernetes-style s
 
 ## `topology`
 
-**Component:** (renders a topology snapshot via the shared topology component)
 **Renders:** Service-map for the configured layer. Static snapshot of the 
current window — the full Topology tab on a per-layer page is interactive; the 
overview widget is a glanceable view.
 
 ### Fields
@@ -214,7 +209,6 @@ No MQE — uses the layer's topology metric from the layer 
template (`topology.m
 
 ## `section-break`
 
-**Component:** `SectionBreak.vue`
 **Renders:** Visual row header with horizontal rules. No data fetch.
 
 ### Fields
diff --git a/docs/customization/adding-a-new-layer.md 
b/docs/customization/adding-a-new-layer.md
index 74f13ab..aa6200a 100644
--- a/docs/customization/adding-a-new-layer.md
+++ b/docs/customization/adding-a-new-layer.md
@@ -26,7 +26,7 @@ curl -s -X POST <queryUrl>/graphql \
   -d '{"query":"{ listServices(layer:\"<KEY>\") { id name normal } }"}' | jq
 ```
 
-A layer with zero services is hidden from the sidebar (it appears in the BFF's 
menu response but is filtered out by `availableLayers`). Ingest some data 
first, then proceed.
+A layer with zero services is hidden from the sidebar (it appears in the menu 
response but is filtered out of the visible list). Ingest some data first, then 
proceed.
 
 ### 3. Identify the layer's metric prefix
 
diff --git a/docs/customization/layer-templates.md 
b/docs/customization/layer-templates.md
index 0cad864..c5bcdca 100644
--- a/docs/customization/layer-templates.md
+++ b/docs/customization/layer-templates.md
@@ -210,7 +210,7 @@ The bulk of the template. A map from scope to an ordered 
widget array.
 
 ### Scope resolution
 
-`apps/bff/src/logic/layers/loader.ts:widgetsForScope()` resolves in this order:
+Widgets for a scope resolve in this order:
 
 ```
 dashboards[scope] → dashboards.service → template.widgets (legacy)
diff --git a/docs/customization/menu-structure.md 
b/docs/customization/menu-structure.md
index baeca00..9d606bf 100644
--- a/docs/customization/menu-structure.md
+++ b/docs/customization/menu-structure.md
@@ -10,25 +10,13 @@ This page documents how those three combine into the live 
sidebar.
 
 ## Data flow
 
-```
-OAP                       BFF                              UI
-─────────────────────     ──────────────────────────────   ────────────────────
-listLayers                                                 
-listServices(layer)       /api/menu                        useLayers()
-listLayerLevels       →   merge with                  →    useLandingOrder()
-getMenuItems              bundled_templates/layers/        AppSidebar.vue
-                          <key>.json
-```
-
-1. **OAP discovery.** The BFF calls the four GraphQL queries on every 
`/api/menu` hit. Layers reported by `listLayers` are "active".
-2. **Template merge.** For each active layer, the BFF loads 
`bundled_templates/layers/<key>.json` (or applies defaults if absent) and 
merges OAP-provided data with template-provided cosmetics: `alias`, `color`, 
`group`, `visibility`, `caps`, `slots`, `header`, `overview`, `log`, `traces`, 
`naming`, `documentLink`.
-3. **Counts.** For each layer, `listServices(layer)` is called to get the 
service count. The count is `-1` if OAP is unreachable.
-4. **UI hydration.** The UI receives a `MenuResponse` with the layer list and 
renders the sidebar via `useLayers` (which layers exist) and `useLandingOrder` 
(in what order).
+1. **OAP discovery.** Layers reported by `listLayers` are "active".
+2. **Template merge.** For each active layer, the bundled 
`bundled_templates/layers/<key>.json` (or defaults if absent) is merged with 
OAP-provided data, contributing template cosmetics: `alias`, `color`, `group`, 
`visibility`, `caps`, `slots`, `header`, `overview`, `log`, `traces`, `naming`, 
`documentLink`.
+3. **Counts.** Each layer carries a service count from `listServices(layer)`. 
The count is `-1` if OAP is unreachable.
+4. **Sidebar render.** The sidebar shows the layer list, ordered per the 
user's landing-order preference.
 
 ## The `MenuResponse` shape
 
-`packages/api-client/src/menu.ts`:
-
 ```ts
 interface MenuResponse {
   layers: LayerDef[];
@@ -65,7 +53,7 @@ The sidebar has two main sections + the static Operate group:
 
 Active, public layers (`visibility: 'public'`, `serviceCount > 0`). Sorted by:
 
-1. `useLandingOrder` — per-user `landing.priority` from the setup store.
+1. Per-user `landing.priority` from the setup store.
 2. Falls back to `level` from `listLayerLevels` when no user priority is set.
 
 A layer with `serviceCount === 0` is hidden from the Layers section but still 
available for the admin's setup screen ("enable this layer when services 
appear").
@@ -95,7 +83,7 @@ These are not layer-derived; they are first-class Horizon 
features.
 
 ## Per-layer composition
 
-When a user clicks a layer in the sidebar, `firstLayerTab()` picks the first 
enabled sub-route from this priority order:
+When a user clicks a layer in the sidebar, the first enabled sub-route is 
picked from this priority order:
 
 ```
 service → instance → endpoint → topology → trace → logs → profiling
diff --git a/docs/customization/overview-templates.md 
b/docs/customization/overview-templates.md
index b96eb22..1fdadd5 100644
--- a/docs/customization/overview-templates.md
+++ b/docs/customization/overview-templates.md
@@ -36,7 +36,7 @@ Bundled templates: 
`apps/bff/src/bundled_templates/overviews/<id>.json`. Example
 | `title` | string | **required** | Display title in the sidebar and page 
header. |
 | `description` | string | — | One-line description shown under the title. |
 | `visibility` | `public` \| `operate` | `public` | Sidebar placement. 
`operate` puts the overview under the Operate group (admin-only by convention). 
|
-| `icon` | string | — | Sidebar icon name (from the icon set in 
`apps/ui/src/assets/icons/`). |
+| `icon` | string | — | Sidebar icon name (from Horizon's icon set). |
 | `order` | number | — | Sort order within the visibility bucket (lower = 
earlier). |
 | `layers` | string[] | — | Layer enums this overview aggregates. Optional — 
used as a hint by the sidebar and by widgets that want a default layer for MQE 
evaluation. |
 | `widgets` | array | **required** | Ordered widget list. The renderer 
iterates and lays out per the grid model. |
@@ -58,7 +58,7 @@ See [Components → Overview 
Widgets](../components/overview-widgets.md) for the
 
 ## Grid model
 
-The renderer (`apps/ui/src/render/overview/OverviewDashboardView.vue`) uses a 
CSS grid:
+The overview renders on a CSS grid:
 
 - Per-section column count, default 12, set by the most recent 
`section-break.cols`.
 - Fixed row height 72 px.
diff --git a/docs/design-target.md b/docs/design-target.md
index 4b43fc9..48be514 100644
--- a/docs/design-target.md
+++ b/docs/design-target.md
@@ -32,13 +32,13 @@ See [Components → Overview 
Widgets](components/overview-widgets.md) and [Dashb
 
 ### War-room overview support
 
-The overview perspective is a first-class concept. An overview template 
(`apps/bff/src/bundled_templates/overviews/*.json`) is an ordered list of 
widgets laid out on a 12-column grid with per-section column overrides. Bundled 
examples include `services.json` (cross-layer service health + Kubernetes 
capacity) and `mesh.json` (Istio data-plane services + pilot activity).
+The overview perspective is a first-class concept. An overview template is an 
ordered list of widgets laid out on a 12-column grid with per-section column 
overrides. Bundled examples include `services.json` (cross-layer service health 
+ Kubernetes capacity) and `mesh.json` (Istio data-plane services + pilot 
activity).
 
 Overviews are scoped per-layer (each widget declares its `layer` key, allowing 
MQE evaluation against the right entity scope), or layer-agnostic for 
cross-cutting summaries.
 
 ### Integrated trace, log, metric, profiling
 
-The per-layer drill-down (`layer/<key>/` route family) presents a single 
service through every data type SkyWalking captures:
+The per-layer drill-down presents a single service through every data type 
SkyWalking captures:
 
 | Tab | Scope | Data source |
 |---|---|---|
@@ -54,7 +54,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** — `useLandingOrder` reads per-user priority from the 
setup store.
+- **Sidebar layer order** — per-user layer priority, persisted in the setup 
state file.
 - **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/cluster-metadata.md b/docs/operate/cluster-metadata.md
index 781578e..edffd08 100644
--- a/docs/operate/cluster-metadata.md
+++ b/docs/operate/cluster-metadata.md
@@ -54,7 +54,7 @@ Two **independent** panes, refreshed in parallel.
 
 ## Cluster members
 
-- **Source:** `GET <queryUrl>/status/cluster/nodes` (status client, 
`packages/api-client/src/status.ts`).
+- **Source:** `GET <queryUrl>/status/cluster/nodes`.
 - **Returns:** per-node host, port, role, last heartbeat.
 - **Refresh:** Same cadence as the Query pane.
 
diff --git a/docs/operate/inspect.md b/docs/operate/inspect.md
index 670092c..0f843f1 100644
--- a/docs/operate/inspect.md
+++ b/docs/operate/inspect.md
@@ -88,22 +88,6 @@ Horizon converts the page's chosen time range into the 
correct format automatica
 
 OAP does not expose a direct "metrics in layer X" filter. Workaround: most 
layers have a metric-name prefix (`service_*` for GENERAL, `mesh_service_*` for 
MESH, `k8s_*` for K8S). Filter the regex by that prefix.
 
-## Data path
-
-```
-Browser
-  ↓ GET /api/inspect/metrics?regex=...
-BFF (apps/bff/src/http/admin/inspect.ts)
-  ↓ GET <adminUrl>/inspect/metrics?regex=...
-OAP :17128
-  ↓ inspect module returns catalog rows
-BFF wraps as typed response
-  ↓
-Browser renders virtualized list
-```
-
-The BFF is a thin proxy — caching is per-request, not cross-request. The 
Inspect API is fast enough that this is rarely a bottleneck.
-
 ## Limits and caveats
 
 - **404 from OAP** means the `inspect` module is off. Set `SW_INSPECT=default` 
on OAP and restart it. The Cluster Status page will then show the module green.
diff --git a/docs/setup/files.md b/docs/setup/files.md
index 5977eed..b6374fa 100644
--- a/docs/setup/files.md
+++ b/docs/setup/files.md
@@ -22,7 +22,7 @@ Holds:
 - Layer-level setup state (which layers the user has marked as enabled / 
disabled in their sidebar).
 - Other persistent UI preferences that survive sessions.
 
-Read and written by `apps/bff/src/logic/setup/store.ts`. The UI writes via 
`POST /api/setup`; the file is updated atomically (write-temp-then-rename).
+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`
 
@@ -30,13 +30,13 @@ Read and written by `apps/bff/src/logic/setup/store.ts`. 
The UI writes via `POST
 |---|---|---|---|---|
 | `alarms.file` | string | `./horizon-alarms.json` | no | Filesystem path to 
the alarm rules state. |
 
-Holds user-created alarm rules (in addition to whatever the OAP cluster ships 
bundled). Read and written by `apps/bff/src/logic/alarms/store.ts`. The Alarm 
Rule Editor (Operate → Alarm Rules) writes here.
+Holds user-created alarm rules (in addition to whatever the OAP cluster ships 
bundled). The Alarm Rule Editor (Operate → Alarm Rules) writes here.
 
 ## Env-var fallbacks
 
-When `horizon.yaml` does not supply a `setup.file` or `alarms.file` (or 
`audit.file` / `debugLog.file`), the config schema seeds its default from an 
env var:
+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:
 
-| YAML key | Env-var fallback | Schema baseline |
+| YAML key | Env-var fallback | Default |
 |---|---|---|
 | `setup.file` | `HORIZON_SETUP_FILE` | `./horizon-setup.json` |
 | `alarms.file` | `HORIZON_ALARMS_FILE` | `./horizon-alarms.json` |
diff --git a/docs/setup/horizon-yaml.md b/docs/setup/horizon-yaml.md
index 1b72e3f..c2f8840 100644
--- a/docs/setup/horizon-yaml.md
+++ b/docs/setup/horizon-yaml.md
@@ -1,6 +1,6 @@
 # horizon.yaml Reference
 
-`horizon.yaml` is the single configuration file for the Horizon BFF. The 
schema is enforced by Zod (`apps/bff/src/config/schema.ts`); validation runs at 
startup and again on every hot reload. A file that fails validation is 
**rejected**; the BFF keeps the previously valid config rather than serving 
with broken settings.
+`horizon.yaml` is the single configuration file for the Horizon BFF. 
Validation runs at startup and again on every hot reload. A file that fails 
validation is **rejected**; the BFF keeps the previously valid config rather 
than serving with broken settings.
 
 This page is the top-level map. Each subsection has its own detail page:
 
@@ -81,7 +81,7 @@ There is no "default admin/admin" fallback.
 
 ## Hot reload behavior
 
-The watcher (`apps/bff/src/config/loader.ts`) re-parses on file change. 
Listeners registered via `config.onChange()` get the new values:
+The config is re-read on file change and the new values take effect without a 
restart:
 
 - Auth backend selection (re-evaluated on next login).
 - RBAC roles and policy (re-evaluated on next route call).
diff --git a/docs/setup/oap.md b/docs/setup/oap.md
index c0a605d..c12c500 100644
--- a/docs/setup/oap.md
+++ b/docs/setup/oap.md
@@ -17,7 +17,7 @@ oap:
 
 | Field | Type | Default | Required | Notes |
 |---|---|---|---|---|
-| `queryUrl` | URL string | `http://127.0.0.1:12800` | no | OAP GraphQL query 
endpoint. Load-balanceable — any OAP node answers. Used by all read pages. 
Validated via Zod `.url()`. |
+| `queryUrl` | URL string | `http://127.0.0.1:12800` | no | OAP GraphQL query 
endpoint. Load-balanceable — any OAP node answers. Used by all read pages. Must 
be a valid URL. |
 | `adminUrl` | URL string | `http://127.0.0.1:17128` | no | OAP admin REST 
endpoint. Hosts runtime-rule, dsl-debugging, inspect, status, debugging/config 
endpoints. Single URL; OAP handles cluster-internal fan-out. |
 | `zipkinUrl` | URL string | `http://127.0.0.1:9412/zipkin` | no | Zipkin v2 
REST endpoint. Used when a layer's `traces.source` is `zipkin` or `both`. 
Defaults assume the standalone Armeria binding; for Docker / shared-port 
deployments use `<queryUrl>/zipkin`. |
 | `timeoutMs` | number | `15000` | no | Per-request HTTP timeout 
(milliseconds) for all OAP calls. Applies to query, admin, Zipkin. Must be 
positive integer. |
@@ -67,7 +67,7 @@ The cache is per-process. After a BFF restart, the next 
request re-probes.
 
 ## Hot reload
 
-Changes to any `oap.*` field are picked up by listeners on the config watcher. 
The next outbound call uses the new value. **Exception**: capability cache is 
process-lifetime — flipping a feature on OAP that requires re-introspection 
needs a BFF restart.
+Changes to any `oap.*` field are picked up on file change. The next outbound 
call uses the new value. **Exception**: capability cache is process-lifetime — 
flipping a feature on OAP that requires re-introspection needs a BFF restart.
 
 ## Common mistakes
 
diff --git a/docs/setup/server.md b/docs/setup/server.md
index 6781fd9..5023aa6 100644
--- a/docs/setup/server.md
+++ b/docs/setup/server.md
@@ -15,7 +15,7 @@ server:
 |---|---|---|---|---|
 | `host` | string | `127.0.0.1` | no | Interface to bind. Set `0.0.0.0` to 
listen on all interfaces (production behind TLS terminator). |
 | `port` | number | `8081` | no | TCP port. Must be a positive integer. |
-| `staticDir` | string | — | no | Filesystem path to a directory of pre-built 
UI assets (typically `apps/ui/dist`). When set and the directory exists, the 
BFF serves files from this directory with SPA-style fallback: any 404 returns 
`index.html` so client-side routing works. When unset, the BFF only serves API 
routes (`/api/*`) — useful for running the UI dev server separately. |
+| `staticDir` | string | — | no | Filesystem path to a directory of pre-built 
UI assets. When set and the directory exists, the BFF serves files from this 
directory with SPA-style fallback: any 404 returns `index.html` so client-side 
routing works. When unset, the BFF only serves API routes (`/api/*`) — useful 
for running the UI dev server separately. |
 
 ## Common shapes
 
@@ -45,7 +45,3 @@ Browser hits a TLS terminator → BFF on port 8081. The BFF 
serves UI bundles an
 ### Behind a path prefix
 
 There is currently **no built-in base-path / prefix support**. If you need 
Horizon under `/horizon/` rather than `/`, terminate the prefix at your reverse 
proxy and rewrite paths there. The UI assumes it is served from the root.
-
-## Consumers
-
-- `apps/bff/src/server.ts` — binds Fastify, mounts static serving when 
`staticDir` is set.

Reply via email to