diff --git a/docs/index.md b/docs/index.md
index 92d84d41..076a174d 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -82,6 +82,7 @@ https://docs.autopilotmonitor.com (separate repository).
# Web Portal
* [Portal Navigation Prefetch & the SWA Runtime Bottleneck](web/portal-navigation-prefetch.md) - Why every navigation Link sets `prefetch={false}`: the App Router's viewport prefetch fans out one RSC request per visible link and the Static Web Apps SSR runtime queues them server-side into a multi-minute tail. Records the follow-up measurement (the Standard plan changed nothing; the prefetch storm was an accidental keep-alive), where browser telemetry actually lives, and why this curl build cannot be trusted for HTTP versions.
+* [Demo Presentation Mode](web/demo-presentation-mode.md) - Two levels of hiding operator internals for live demos: the Global-Admin view toggle now also collapses the GA extras inside tenant pages (Telegram provider, cert-device binding, retention escape hatch, the `/health-check` build and commit cards), and a hidden demo mode armed by a self-consuming `?demo=1` additionally removes the toggle, the platform role badge and access to `/admin`. Presentation only - every rule stays enforced server-side.
# Security & Identity
diff --git a/docs/log.md b/docs/log.md
index e5177fed..a40a1a58 100644
--- a/docs/log.md
+++ b/docs/log.md
@@ -1,5 +1,9 @@
# Log
+## 2026-09-01 (5)
+
+* **New**: `web/demo-presentation-mode.md` - the portal can now be shown live without leaking operator internals. The Global-Admin view toggle was only ever read by the nav and the scope hooks; every GA extra inside a tenant page was gated on the identity and stayed on screen when it was switched off - including the `/health-check` Backend/Portal Build cards with commit hashes hyperlinked into the private repo. New `useGlobalAdminUi()` (real GA **and** global view on) replaces the bare identity check at the four visible GA surfaces, so switching the toggle off yields a genuinely tenant-admin-looking view. On top of that a hidden demo mode, armed by a self-consuming `?demo=1` (persisted to localStorage, then stripped from the address bar via `history.replaceState`; `?demo=0` clears it), forces the global view off for every consumer without touching the stored value, hides the toggle itself and the platform role badges, and silently replaces `/admin/*` with `/dashboard` instead of rendering the Access Denied card - whose text would itself reveal the platform area. Admin Mode is deliberately untouched: a tenant admin has it too. `user.isGlobalAdmin` is never faked globally, because that would drag `canEditConfig` along and leave a GA without an own tenant-admin role unable to demonstrate anything. Both levels are presentation only - `TenantConfigValidation` and the `GlobalAdminOnly` 403s are unchanged.
+
## 2026-09-01 (3)
* **Update**: `agent/decision-engine.md` - agent_metrics_snapshot gains a signal-pipeline health block: monotonic SignalIngress totals (last ordinal, processed, pending, reentrant, queue peak) + decision_stage / decision_last_applied_ordinal / wg_sealing_level+score from the immutable DecisionState, wired via a read-only decisionStateProbe through IComponentFactory. Motivated by the WG-seal soak (project_wg_seal_postfix_soak_residual): a frozen engine and a deliberate Weak sealing verdict were previously indistinguishable in telemetry.
diff --git a/docs/web/demo-presentation-mode.md b/docs/web/demo-presentation-mode.md
new file mode 100644
index 00000000..ec1ed207
--- /dev/null
+++ b/docs/web/demo-presentation-mode.md
@@ -0,0 +1,65 @@
+---
+type: Concept
+title: Demo Presentation Mode — Two Levels of Hiding Operator Internals
+description: How the portal can be shown live without leaking operator-only detail. The existing Global-Admin view toggle now also collapses the GA extras inside tenant pages, and a hidden demo mode (armed by a self-consuming ?demo=1 URL parameter) additionally removes the toggle itself, the Global-Admin badge and access to the platform area. Presentation only — every real gate stays on the server.
+resource: src/Web/autopilot-monitor-web/lib/demoMode.ts
+tags:
+ - web
+ - presentation
+ - global-admin
+ - operator-tooling
+timestamp: 2026-09-01T20:30:00+02:00
+---
+
+# Problem
+
+The product is demonstrated live, and the operator doing the demonstration is a platform Global Admin. Two things leaked into those sessions.
+
+**The Global-Admin view toggle only covered navigation.** `globalAdminMode` is a localStorage view switch read by `lib/navVisibility.ts` and the scope hooks (`hooks/useAggregatedAdminScope.ts`), so switching it off collapsed the sidebar and the cross-tenant data scope. Every GA extra *inside* an otherwise tenant-scoped page was gated on the identity `user.isGlobalAdmin` instead and stayed on screen: the platform-bot Telegram provider, the cert-device-binding toggle, the retention escape hatch, and — most exposed — the Backend Build and Portal Build cards on `/health-check`, which carry version strings and commit hashes hyperlinked into the private repository.
+
+**Nothing existed for a stage demo.** The Global-Admin toggle and the purple "Global Admin" badge were always on screen, one click away from opening the platform view mid-presentation.
+
+# Model
+
+Two levels, both purely presentational.
+
+**Level 1 — the Global-Admin view toggle.** Switching it off now yields a view indistinguishable from a tenant admin's. New hook `useGlobalAdminUi()` = *real GA* **and** *global view on*; it replaces the bare identity check at every GA-exclusive **visible** surface. For quick product demos between other work.
+
+**Level 2 — demo mode.** A separate, hidden flag that forces the global view off for every consumer, hides the toggle itself and the platform role badges, and bounces the platform routes. For conference demos, where nothing may happen by accident.
+
+`useCanMutatePlatform()` deliberately does **not** change: it stays bound to the identity and gates the mutating controls in the `/admin` area, where a view toggle must never decide what may be written.
+
+`user.isGlobalAdmin` is never faked globally. That would be the shorter lever but it would drag `canEditConfig` (`app/settings/TenantConfigContext.tsx`) along, and a Global Admin without an own tenant-admin role would lose every write affordance — unable to demonstrate the product at all.
+
+# Activation
+
+`?demo=1` on any portal URL writes `demoMode=true` to localStorage and the parameter is then **consumed**: `history.replaceState` strips it from the address bar immediately, so it survives no screenshot and needs no devtools on stage. `?demo=0` clears it the same way. `1|true|on|` (bare) arm; `0|false|off` clear; anything unrecognised returns `null` and leaves the stored value alone, so a typo cannot drop the operator out mid-presentation.
+
+The stored `globalAdminMode` value is never overwritten — demo mode masks it at read time (`effectiveGlobalAdminMode`), so the operator's usual setting returns with `?demo=0`.
+
+# What each level hides
+
+| Surface | Level 1 (view toggle off) | Level 2 (demo mode) |
+| --- | --- | --- |
+| Purple platform section in the sidebar | hidden | hidden |
+| Cross-tenant data scope, platform notifications | collapsed to the tenant | collapsed to the tenant |
+| Telegram provider, cert-device-binding toggle, retention escape hatch | hidden | hidden |
+| `/health-check` build + commit cards | hidden | hidden |
+| "Global Admin" / "Global Reader" badge in the user menu | shows "Admin" instead | shows "Admin" instead |
+| The Global-Admin toggle itself | still there | hidden |
+| `/admin/*` reached by a typed URL or bookmark | Access Denied card | silent replace to `/dashboard` |
+| Admin Mode (delete sessions) | unchanged | unchanged — a tenant admin has it too, so it belongs in an honest demo |
+
+An already-configured Telegram channel stays visible and readable at both levels (`components/notifications/ChannelEditor.tsx`): it is the tenant's own channel, a tenant admin sees it too, and hiding its provider would make the next save destroy it.
+
+# Not a security boundary
+
+Both levels are presentation. Every rule they mirror is enforced server-side and unchanged: `TenantConfigValidation.ValidateTelegramChannelGate` rejects a non-GA Telegram write on both the PUT and the MCP field-patch path, the retention cap is validated in `ValidateModel`, and the platform endpoints answer `GlobalAdminOnly` with 403. Demo mode must never be described, documented or relied on as an access control.
+
+# Citations
+
+- `src/Web/autopilot-monitor-web/lib/demoMode.ts` — pure logic (parameter read/strip, effective view flag), covered by `lib/__tests__/demoMode.test.ts`
+- `src/Web/autopilot-monitor-web/hooks/useAdminMode.ts` — the three modes, URL consumption, effective `globalAdminMode`
+- `src/Web/autopilot-monitor-web/hooks/useGlobalAdminUi.ts` — the visible-GA-extras hook
+- `src/Web/autopilot-monitor-web/components/ProtectedRoute.tsx` — platform-route bounce while presenting
+- [portal-navigation-prefetch.md](portal-navigation-prefetch.md) — the other portal-wide behaviour switch
diff --git a/src/Web/autopilot-monitor-web/app/health-check/page.tsx b/src/Web/autopilot-monitor-web/app/health-check/page.tsx
index 4aefeda8..e382b8e6 100644
--- a/src/Web/autopilot-monitor-web/app/health-check/page.tsx
+++ b/src/Web/autopilot-monitor-web/app/health-check/page.tsx
@@ -4,6 +4,7 @@ import { useAuth } from '@/contexts/AuthContext';
import { useNotifications } from '@/contexts/NotificationContext';
import { useSignalR } from '@/contexts/SignalRContext';
import { useTenant } from '@/contexts/TenantContext';
+import { useGlobalAdminUi } from '@/hooks/useGlobalAdminUi';
import { useState, useEffect, useCallback } from 'react';
import * as signalR from '@microsoft/signalr';
import { api } from '@/lib/api';
@@ -12,6 +13,9 @@ import type { DetailedHealthCheckResponse, HealthCheck, McpHealthCheckResponse }
export default function HealthCheckPage() {
const { user, getAccessToken } = useAuth();
+ // Build stamps (version + commit hash, linking into the private repo) are operator-only detail.
+ // They follow the Global-Admin VIEW, so the page can be shown live without leaking internals.
+ const showBuildDetails = useGlobalAdminUi();
const { addNotification } = useNotifications();
const { connectionState, isConnected, joinedGroups, joinGroup } = useSignalR();
const { tenantId } = useTenant();
@@ -93,14 +97,14 @@ export default function HealthCheckPage() {
}, [getAccessToken]);
useEffect(() => {
- if (!user?.isGlobalAdmin) return;
+ if (!showBuildDetails) return;
let cancelled = false;
fetch("/version.json", { cache: "no-store" })
.then((r) => (r.ok ? r.json() : null))
.then((d) => { if (!cancelled && d?.commit) setWebBuild(d); })
.catch(() => { /* card simply stays hidden */ });
return () => { cancelled = true; };
- }, [user?.isGlobalAdmin]);
+ }, [showBuildDetails]);
// Auto-run health checks on page load. The two probes are fired independently so the
// fast backend checks render immediately while the MCP probe (possible cold start) catches up.
@@ -235,7 +239,7 @@ export default function HealthCheckPage() {
)}
{/* Backend Build (Global Admin only) */}
- {user?.isGlobalAdmin && healthResult?.version && (
+ {showBuildDetails && healthResult?.version && (
);
}
- // Cert-device binding stays Global-Admin-only: only the operator can turn the binding check
- // on while its enrollment-race behaviour is still being measured.
- const showIntuneDeviceBindingToggle = user?.isGlobalAdmin === true;
-
// Dual app-reg window: tenants homed on the previous app registration (homedAppClientId
// null/absent) keep working unchanged — this is a purely informational nudge, part of the
// incentive-driven re-consent campaign. Never a warning, never an action requirement.
diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionNotifications.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionNotifications.tsx
index bd02a329..beea836e 100644
--- a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionNotifications.tsx
+++ b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionNotifications.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useAuth } from "@/contexts/AuthContext";
+import { useGlobalAdminUi } from "@/hooks/useGlobalAdminUi";
import { useTenantConfig } from "../../TenantConfigContext";
import { TenantNotifications } from "../../TenantNotifications";
import NotificationsSection from "../../components/NotificationsSection";
@@ -16,9 +16,9 @@ export function SectionNotifications() {
// Telegram channels deliver through the platform-owned bot, so only a Global Admin may
// configure one. The server enforces the same rule (TenantConfigValidation) — hiding the
- // option here is convenience, not the control.
- const { user } = useAuth();
- const showTelegramProvider = user?.isGlobalAdmin === true;
+ // option here is convenience, not the control. Follows the Global-Admin VIEW, so switching it
+ // off (or presenting in demo mode) yields the real tenant-admin dropdown.
+ const showTelegramProvider = useGlobalAdminUi();
return (
<>
diff --git a/src/Web/autopilot-monitor-web/components/Navbar.tsx b/src/Web/autopilot-monitor-web/components/Navbar.tsx
index b05b3d02..aa295031 100644
--- a/src/Web/autopilot-monitor-web/components/Navbar.tsx
+++ b/src/Web/autopilot-monitor-web/components/Navbar.tsx
@@ -31,7 +31,7 @@ export default function Navbar() {
const [showHelp, setShowHelp] = useState(false);
const [showOverflow, setShowOverflow] = useState(false);
const [overflowSubmenu, setOverflowSubmenu] = useState<'help' | 'settings' | null>(null);
- const { adminMode, setAdminMode, globalAdminMode, setGlobalAdminMode } = useAdminMode();
+ const { adminMode, setAdminMode, globalAdminMode, setGlobalAdminMode, demoMode } = useAdminMode();
// Community-edition reminder under the wordmark; Pro (incl. trial) gets clean chrome.
// null until the feature-flags fetch confirms → no label flash for Pro tenants.
const editionInfo = useEditionInfo();
@@ -122,9 +122,21 @@ export default function Navbar() {
};
const isTenantAdmin = user?.isTenantAdmin ?? false;
+ // Whether to PRESENT the caller as a Global Admin. Follows the Global-Admin view toggle (which
+ // demo mode forces off), so switching it off shows the tenant-admin identity a live demo needs.
+ // Permission checks keep using user.isGlobalAdmin — this drives the badge only.
+ const showGlobalAdminIdentity = (user?.isGlobalAdmin ?? false) && globalAdminMode;
+ // Same rule for the read-only platform role — with the global view off it is just as much an
+ // internal as the Global Admin badge, and the toggle already reads "Global View" for them.
+ const showGlobalReaderIdentity =
+ (user?.isGlobalReader ?? false) && globalAdminMode && !showGlobalAdminIdentity;
const isOperator = user?.role === 'Operator';
const isViewer = user?.role === 'Viewer';
const isAdminOrOperator = isTenantAdmin || isOperator;
+ // Everything the settings menu can hold. With no toggle applicable — an Operator/Viewer, or a
+ // Global Admin presenting in demo mode without an own tenant-admin role — the menu would open
+ // onto an empty panel, so the gear (and the mobile "Administration" block) stays hidden.
+ const showAdminToggles = isTenantAdmin || (hasGlobalScope && !demoMode);
// Tenant notification dismiss is currently tenant-shared (clearing for one user clears for
// all). Only Admins / Global Admins are permitted to dismiss; Operators and Viewers see the
// bell as read-only. See TenantNotificationContext + EndpointAccessPolicyCatalog.
@@ -408,6 +420,7 @@ export default function Navbar() {