From a0170d325205f5bc7fc52cdb6fedaa04176da309 Mon Sep 17 00:00:00 2001
From: rcourtman
Date: Fri, 10 Jul 2026 01:46:08 +0100
Subject: [PATCH] Block silent downgrades on apply and give the updater a
sanctioned rollback path
The in-app updater validated download URL and channel but never compared
the target against the running version, so any valid older release asset
URL installed silently while the UI presented it as an update. ApplyUpdate
now rejects targets at or below the running version on both the community
and Pro broker paths before any history entry or download, with an explicit
allowDowngrade opt-in on POST /api/updates/apply for sanctioned cases.
The rollback half already existed but nothing reached it: createBackup
retains three backups, restoreBackup works, and history records BackupPath,
yet no endpoint or UI called restoreBackup. RollbackToBackup restores the
retained backup recorded on a history entry after re-validating the path
against the managed backup roots, shares the update-in-flight slot with
ApplyUpdate, records an Action rollback history entry linked to the source
update, marks that update rolled_back, streams a restoring stage through
the existing status/SSE machinery, and restarts via the exit-for-systemd
path. POST /api/updates/rollback carries it with the same RequireAdmin plus
settings:write gating as apply. Rollback is purely local, so the Pro
edition gate never applies to it.
Settings now has the update history surface that was missing entirely:
nothing called /api/updates/history before. The Updates panel lists recent
updates with a Roll back action on successful entries whose backup is still
retained, behind a confirmation dialog naming the restore version, and the
rollback rides updateStore's shared pending-apply marker for the
post-restart toast. restoreBackup also honors PULSE_INSTALL_DIR now instead
of hardcoding /opt/pulse, matching createBackup.
Contract deltas ride along: api-contracts picks up the rollback transport
and downgrade-conflict semantics, agent-lifecycle and storage-recovery pin
rollback as server self-update plumbing, ai-runtime and cloud-paid pin the
update watcher stage vocabulary as non-assistant non-paid shell chrome, and
frontend-primitives adds UpdateHistorySection as the history/rollback
presentation owner with matching architecture proofs.
---
.../v6/internal/subsystems/agent-lifecycle.md | 6 +-
.../v6/internal/subsystems/ai-runtime.md | 5 +
.../v6/internal/subsystems/api-contracts.md | 15 +-
.../v6/internal/subsystems/cloud-paid.md | 6 +
.../subsystems/deployment-installability.md | 34 ++
.../subsystems/frontend-primitives.md | 8 +-
.../internal/subsystems/storage-recovery.md | 7 +-
frontend-modern/src/App.tsx | 12 +-
.../src/__tests__/App.architecture.test.ts | 17 +
.../src/api/__tests__/updates.test.ts | 22 ++
frontend-modern/src/api/updates.ts | 39 ++
.../Settings/UpdateHistorySection.tsx | 232 ++++++++++++
.../Settings/UpdatesSettingsPanel.tsx | 5 +
.../__tests__/UpdateHistorySection.test.tsx | 117 ++++++
.../__tests__/settingsArchitecture.test.ts | 21 ++
.../src/stores/__tests__/updates.test.ts | 70 ++++
frontend-modern/src/stores/updates.ts | 49 ++-
internal/api/route_inventory_test.go | 1 +
internal/api/router_routes_registration.go | 1 +
internal/api/updates.go | 99 ++++-
internal/api/updates_test.go | 148 ++++++++
internal/updates/manager.go | 196 +++++++++-
internal/updates/manager_rollback_test.go | 341 ++++++++++++++++++
23 files changed, 1434 insertions(+), 17 deletions(-)
create mode 100644 frontend-modern/src/components/Settings/UpdateHistorySection.tsx
create mode 100644 frontend-modern/src/components/Settings/__tests__/UpdateHistorySection.test.tsx
create mode 100644 internal/updates/manager_rollback_test.go
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.