diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
index 6dd6cc66e..220b01b76 100644
--- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
+++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
@@ -160,7 +160,11 @@ off it. The same boundary covers the server updater's deployment adapters
behind `GET /api/updates/plan` in `internal/api/updates.go`: they are plan
providers only, with the real apply in the `internal/updates/manager.go`
pipeline, and lifecycle surfaces must not read them as an agent-side apply,
-update, or rollback transport. Workflow starter counts on that endpoint,
+update, or rollback transport. The server updater's own downgrade guard and
+`POST /api/updates/rollback` backup-restore endpoint are equally server
+self-update plumbing: they roll the Pulse server binary and its local
+backups, never agent binaries, and agent lifecycle surfaces must not key
+enrollment, update liveness, or fleet-control semantics off them. Workflow starter counts on that endpoint,
contextual Assistant/external-agent collaboration counts inside the Assistant
step, the content-free Patrol control starter split, and Patrol control
completed-loop, resolved-loop, or `patrolControlValueState` proof mirrored to
diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md
index 3abb1421f..749a67ba5 100644
--- a/docs/release-control/v6/internal/subsystems/ai-runtime.md
+++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md
@@ -4319,6 +4319,11 @@ The retired monitored-system capacity banner follows the same shell rule:
volume warnings just because settings or support surfaces still expose
monitored-system grouping data. Assistant state and shell notices stay
independent from retired infrastructure-volume commerce.
+The global update progress watcher in `frontend-modern/src/App.tsx` is
+likewise server-updater shell chrome, not assistant surface: its in-progress
+stage vocabulary mirrors the backend updater pipeline (including the
+`restoring` stage emitted by update rollback), and assistant state, drawer
+ownership, and AI runtime surfaces must not key off those update stages.
That same shared shell boundary must respect blocking modal ownership.
`frontend-modern/src/App.tsx` and `frontend-modern/src/AppLayout.tsx` may use
the shared dialog runtime to hide the closed assistant launcher and close the
diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md
index 16625d55a..9f2af0092 100644
--- a/docs/release-control/v6/internal/subsystems/api-contracts.md
+++ b/docs/release-control/v6/internal/subsystems/api-contracts.md
@@ -1589,8 +1589,19 @@ payload shape change when the portal presents compact client rows.
The updater registry behind `GET /api/updates/plan` is a plan-provider
seam only (`SupportsApply`, `PrepareUpdate`, `GetDeploymentType`); apply
and rollback semantics ride the manager pipeline behind
- `POST /api/updates/apply`, and the updates API exposes no per-deployment
- adapter execute or rollback transport.
+ `POST /api/updates/apply` and `POST /api/updates/rollback`, and the
+ updates API exposes no per-deployment adapter execute or rollback
+ transport.
+ `POST /api/updates/apply` rejects target versions at or below the running
+ version with a conflict response unless the request body sets the
+ explicit `allowDowngrade` flag, so a stale-but-valid release asset URL is
+ a refused downgrade rather than a silent install.
+ `POST /api/updates/rollback` takes the `eventId` of an update history
+ entry, is gated admin plus `settings:write` exactly like apply, restores
+ the retained backup recorded on that entry through the manager pipeline,
+ and answers with the same started-acknowledgement shape as apply;
+ conflict responses cover pruned or missing backups and Docker
+ deployments, and not-found covers unknown history entries.
85. `pkg/aicontracts/fix_execution.go` shared with `ai-runtime`: the public approved-fix execution contract is both an AI runtime approved-action boundary and a canonical API dependency contract for Patrol and enterprise auto-fix binders.
86. `pkg/aicontracts/investigation.go` shared with `ai-runtime`: the public Patrol investigation record and finding contract is both an AI runtime handoff boundary and a canonical API payload contract for Patrol, Assistant, unified findings, persistence, and audit surfaces.
87. `pkg/aicontracts/orchestrator_deps.go` shared with `ai-runtime`: the public investigation orchestrator dependency contract is both an AI runtime handoff boundary and a canonical API payload contract for Assistant and Patrol tool-call history.
diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md
index 921f49743..b41503f9e 100644
--- a/docs/release-control/v6/internal/subsystems/cloud-paid.md
+++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md
@@ -2081,6 +2081,12 @@ theme synchronization, and authenticated runtime startup, and
as org switching and kiosk-safe navigation. Future hosted browser bootstrap
work must extend that split rather than pulling org bootstrap and app chrome
back into one monolithic route component.
+The global update progress watcher inside that entry shell is self-hosted
+server-update chrome: its stage vocabulary tracks the backend updater
+pipeline (downloading through restarting, including the `restoring` stage
+emitted by update rollback) and it carries no hosted entitlement, org, or
+paid-gating semantics; hosted and paid surfaces must not key tenant or
+billing behavior off update progress stages.
That same authenticated shell split must also respect shared blocking dialogs:
hosted chrome may not leave the Pulse Assistant launcher or an already-open
assistant drawer interactive behind a modal that currently owns the viewport.
diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md
index c5458d4b6..72574849b 100644
--- a/docs/release-control/v6/internal/subsystems/deployment-installability.md
+++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md
@@ -2278,3 +2278,37 @@ for that volume before launching the wrapper, recover the same state during
uninstall, and keep the persisted boot copy aligned with updater-owned runtime
binary replacements instead of assuming `/usr/local/bin` survives reboot on
QTS/QuTS hero.
+
+The in-app updater's apply pipeline now owns a downgrade guard on the normal
+apply path. A syntactically valid release asset URL can name a release older
+than the running binary, so `internal/updates/manager.go` `ApplyUpdate` must
+reject any resolved target version at or below the running version, on both
+the community release-asset path and the Pro broker path, before any history
+entry is written or byte is downloaded. Sanctioned downgrades are an explicit
+opt-in through the `AllowDowngrade` request flag carried by
+`POST /api/updates/apply`, never a silent side effect of a stale download
+URL. The guard fails open only for versions that do not parse as semver
+(development builds), which stay covered by the existing URL and channel
+validation. `internal/updates/manager_rollback_test.go` and the apply handler
+tests in `internal/api/updates_test.go` are the direct proof surface for this
+rule.
+
+That same updater boundary now also owns the sanctioned rollback path from
+retained update backups. `RollbackToBackup` in `internal/updates/manager.go`
+restores the backup directory recorded on an update history entry after
+re-validating that the path still names a managed update backup on disk,
+shares the single update-in-flight slot with `ApplyUpdate`, records the
+rollback as its own history entry with `Action` `rollback` and a
+`RelatedEventID` back to the rolled-back update, marks that source entry
+`rolled_back`, streams progress through the existing update status and SSE
+machinery as the `restoring` stage, and restarts through the same
+exit-for-systemd path as a normal update. The transport surface is
+`POST /api/updates/rollback`, admin plus `settings:write` gated exactly like
+apply, and the Settings update history table in
+`frontend-modern/src/components/Settings/UpdateHistorySection.tsx` is the
+user-facing rollback surface. Rollback is a purely local restore: it must not
+touch the Pro download broker or any edition gate, so it behaves identically
+on community and Pro binaries. The rollback tests in
+`internal/updates/manager_rollback_test.go`, the rollback handler tests in
+`internal/api/updates_test.go`, and the route inventory pin for
+`/api/updates/rollback` are the proof surface for this path.
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index c313ef40b..e22fef67e 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -3873,7 +3873,13 @@ top-level settings shell, while
`frontend-modern/src/components/Settings/updatesSettingsModel.ts` plus
`frontend-modern/src/utils/updatesPresentation.ts` own the
deployment-specific install guide, copy-command block, and update-channel/install
-model data plus customer-facing update status/action copy. The panel shell must
+model data plus customer-facing update status/action copy.
+`frontend-modern/src/components/Settings/UpdateHistorySection.tsx` joins that
+split as the presentation owner for the update history table and the
+rollback confirmation dialog: the panel shell mounts it as a section and must
+not inline history rows, rollback gating, or rollback confirmation copy
+itself, and the section starts rollbacks through the shared
+`updateStore.rollbackUpdate` action rather than its own POST path. The panel shell must
not rebuild copy-to-clipboard command cards, deployment instruction trees, or
update-surface wording inline. `CopyCommandBlock` must use the shared
`copyToClipboard` helper so install/update/agent snippets keep the same
diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md
index bbb477230..064c99826 100644
--- a/docs/release-control/v6/internal/subsystems/storage-recovery.md
+++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md
@@ -548,7 +548,12 @@ recovery scope, or a storage/recovery-owned secret source.
so recovery semantics never fork by edition. That machinery lives solely
in the `internal/updates/manager.go` apply pipeline; the deployment
adapters behind the update-plan endpoint are plan providers only and own
- no download, restore, or rollback path.
+ no download, restore, or rollback path. The sanctioned rollback behind
+ `POST /api/updates/rollback` restores the updater's own retained
+ pre-update backup (binary, data, config, env, VERSION) through that same
+ manager pipeline; it is server self-update recovery, purely local with no
+ broker or edition fork, and storage/recovery surfaces must not read it as
+ guest backup, datastore restore, or recovery-point coverage.
Proxmox-side LXC Docker inventory wiring may also pass through
`internal/api/router.go` and Proxmox agent install-command generation, but
storage and recovery may consume the resulting app-container/resource
diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx
index 7c3ccf22f..7780c316d 100644
--- a/frontend-modern/src/App.tsx
+++ b/frontend-modern/src/App.tsx
@@ -140,9 +140,19 @@ async function preloadAppShellRoutes() {
}
// Helper to detect if an update is actively in progress (not just checking for updates)
+// Mirrors the stage names emitted by internal/updates/manager.go updateStatus:
+// the apply pipeline plus 'restoring' from the rollback path.
function isUpdateInProgress(status: string | undefined): boolean {
if (!status) return false;
- const inProgressStates = ['downloading', 'verifying', 'extracting', 'installing', 'restarting'];
+ const inProgressStates = [
+ 'downloading',
+ 'verifying',
+ 'extracting',
+ 'backing-up',
+ 'applying',
+ 'restoring',
+ 'restarting',
+ ];
return inProgressStates.includes(status);
}
diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts
index 6430193fd..5636b1fa5 100644
--- a/frontend-modern/src/__tests__/App.architecture.test.ts
+++ b/frontend-modern/src/__tests__/App.architecture.test.ts
@@ -212,6 +212,23 @@ describe('App architecture', () => {
expect(appSource).not.toContain('monitoredSystemLimitWarningBanner');
});
+ it('keeps the update progress watcher aligned with the backend updater stages', () => {
+ // The in-progress stage list must mirror internal/updates/manager.go
+ // updateStatus emissions, including the rollback path's restoring stage,
+ // so the progress modal auto-opens for every real update or rollback.
+ expect(appSource).toContain("'downloading',");
+ expect(appSource).toContain("'verifying',");
+ expect(appSource).toContain("'extracting',");
+ expect(appSource).toContain("'backing-up',");
+ expect(appSource).toContain("'applying',");
+ expect(appSource).toContain("'restoring',");
+ expect(appSource).toContain("'restarting',");
+ // 'checking' is a probe, not an apply: it must never pop the modal, and
+ // the never-emitted legacy 'installing' stage must not return.
+ expect(appSource).not.toContain("'checking',");
+ expect(appSource).not.toContain("'installing'");
+ });
+
it('keeps integration browser proofs off the retired AI route', () => {
const routesSource = readFileSync(join(integrationTestsDir, 'routes.ts'), 'utf8');
const retiredRouteNavigations = readIntegrationTestSources(integrationTestsDir).flatMap(
diff --git a/frontend-modern/src/api/__tests__/updates.test.ts b/frontend-modern/src/api/__tests__/updates.test.ts
index 2f9a7135f..c377590f7 100644
--- a/frontend-modern/src/api/__tests__/updates.test.ts
+++ b/frontend-modern/src/api/__tests__/updates.test.ts
@@ -61,6 +61,28 @@ describe('UpdatesAPI', () => {
});
});
+ it('rejects empty rollback event ID before making a request', async () => {
+ await expect(UpdatesAPI.rollbackUpdate(' ')).rejects.toThrow('Event ID is required');
+ expect(apiFetchJSONMock).not.toHaveBeenCalled();
+ });
+
+ it('trims event ID before rollback request', async () => {
+ apiFetchJSONMock.mockResolvedValueOnce({ status: 'started', message: 'ok' } as any);
+ await UpdatesAPI.rollbackUpdate(' 01JZEXAMPLE ');
+
+ expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/updates/rollback', {
+ method: 'POST',
+ body: JSON.stringify({ eventId: '01JZEXAMPLE' }),
+ });
+ });
+
+ it('encodes the update-history limit', async () => {
+ apiFetchJSONMock.mockResolvedValueOnce([] as any);
+ await UpdatesAPI.listUpdateHistory(5);
+
+ expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/updates/history?limit=5');
+ });
+
it('encodes update-plan version and channel safely', async () => {
apiFetchJSONMock.mockResolvedValueOnce({ canAutoUpdate: true } as any);
await UpdatesAPI.getUpdatePlan('v1.2.3-rc.1+build', 'rc');
diff --git a/frontend-modern/src/api/updates.ts b/frontend-modern/src/api/updates.ts
index a1094c1dd..a4503dfaa 100644
--- a/frontend-modern/src/api/updates.ts
+++ b/frontend-modern/src/api/updates.ts
@@ -61,6 +61,32 @@ export interface UpdatePlan {
readiness?: UpdateReadiness;
}
+export interface UpdateHistoryEntryError {
+ message: string;
+ code?: string;
+ details?: string;
+}
+
+export interface UpdateHistoryEntry {
+ event_id: string;
+ timestamp: string;
+ action: string;
+ channel: string;
+ version_from: string;
+ version_to: string;
+ deployment_type: string;
+ initiated_by: string;
+ initiated_via: string;
+ status: string;
+ duration_ms: number;
+ backup_path?: string;
+ log_path?: string;
+ error?: UpdateHistoryEntryError;
+ download_bytes?: number;
+ related_event_id?: string;
+ notes?: string;
+}
+
const requireNonEmpty = (value: string, fieldName: string): string => {
const trimmed = value.trim();
if (!trimmed) {
@@ -89,6 +115,19 @@ export class UpdatesAPI {
});
}
+ static async rollbackUpdate(eventId: string): Promise<{ status: string; message: string }> {
+ const normalizedEventId = requireNonEmpty(eventId, 'Event ID');
+ return apiFetchJSON('/api/updates/rollback', {
+ method: 'POST',
+ body: JSON.stringify({ eventId: normalizedEventId }),
+ });
+ }
+
+ static async listUpdateHistory(limit = 20): Promise {
+ const search = new URLSearchParams({ limit: String(limit) });
+ return apiFetchJSON(`/api/updates/history?${search.toString()}`);
+ }
+
static async getUpdateStatus(): Promise {
return apiFetchJSON('/api/updates/status');
}
diff --git a/frontend-modern/src/components/Settings/UpdateHistorySection.tsx b/frontend-modern/src/components/Settings/UpdateHistorySection.tsx
new file mode 100644
index 000000000..d17fa825b
--- /dev/null
+++ b/frontend-modern/src/components/Settings/UpdateHistorySection.tsx
@@ -0,0 +1,232 @@
+import { Component, For, Show, createResource, createSignal } from 'solid-js';
+import HistoryIcon from 'lucide-solid/icons/history';
+import { UpdatesAPI, type UpdateHistoryEntry } from '@/api/updates';
+import { updateStore } from '@/stores/updates';
+import { Button } from '@/components/shared/Button';
+import { Dialog } from '@/components/shared/Dialog';
+import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
+
+const HISTORY_LIMIT = 20;
+
+const formatVersionLabel = (version: string): string => {
+ const trimmed = version.trim();
+ if (!trimmed) return 'unknown';
+ return trimmed.startsWith('v') ? trimmed : `v${trimmed}`;
+};
+
+const formatTimestamp = (timestamp: string): string => {
+ const date = new Date(timestamp);
+ return Number.isNaN(date.getTime()) ? timestamp : date.toLocaleString();
+};
+
+const actionLabel = (entry: UpdateHistoryEntry): string => {
+ switch (entry.action) {
+ case 'rollback':
+ return 'Rollback';
+ default:
+ return entry.initiated_by === 'auto' ? 'Automatic update' : 'Update';
+ }
+};
+
+const statusPresentation = (status: string): { label: string; className: string } => {
+ switch (status) {
+ case 'success':
+ return { label: 'Succeeded', className: 'text-emerald-600 dark:text-emerald-400' };
+ case 'failed':
+ return { label: 'Failed', className: 'text-red-600 dark:text-red-400' };
+ case 'in_progress':
+ return { label: 'In progress', className: 'text-blue-600 dark:text-blue-400' };
+ case 'rolled_back':
+ return { label: 'Rolled back', className: 'text-amber-600 dark:text-amber-400' };
+ case 'cancelled':
+ return { label: 'Cancelled', className: 'text-muted' };
+ default:
+ return { label: status, className: 'text-muted' };
+ }
+};
+
+// A rollback restores the backup taken before this update was applied, so it
+// is only offered where that backup is still retained on disk (the backend
+// clears backup_path when retention prunes it) and the update actually landed.
+const canRollBack = (entry: UpdateHistoryEntry): boolean =>
+ entry.action === 'update' && entry.status === 'success' && Boolean(entry.backup_path);
+
+export const UpdateHistorySection: Component = () => {
+ const [history, { refetch }] = createResource(() => UpdatesAPI.listUpdateHistory(HISTORY_LIMIT));
+ const [confirmEntry, setConfirmEntry] = createSignal(null);
+ const [isStartingRollback, setIsStartingRollback] = createSignal(false);
+
+ const startRollback = async () => {
+ const entry = confirmEntry();
+ if (!entry || isStartingRollback()) return;
+
+ setIsStartingRollback(true);
+ try {
+ const accepted = await updateStore.rollbackUpdate({
+ eventId: entry.event_id,
+ fromVersion: updateStore.versionInfo()?.version || entry.version_to,
+ toVersion: entry.version_from,
+ });
+ if (accepted) {
+ // The global update progress watcher picks the rollback up from the
+ // status stream and owns the restart/reload flow from here.
+ setConfirmEntry(null);
+ } else {
+ // The store already surfaced the error toast; refresh in case the
+ // entry state changed underneath us (e.g. backup pruned).
+ void refetch();
+ }
+ } finally {
+ setIsStartingRollback(false);
+ }
+ };
+
+ return (
+
+
+
+
+ Update History
+
+
+ Updates applied through Pulse, with rollback to the backup taken before each one.
+
+
+
+
+
+ Loading update history...
+
+ }
+ >
+ Update history is unavailable right now.