From ba298b29e264f671f917b49139cf9031678b87da Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:52:24 +0100 Subject: [PATCH 1/3] Guard Actions review against stale responses Keep route-selected action details and inbox tabs bound to their latest reads so delayed replies cannot replace a newer review or reopen one after close. Preserve server-authored identity and prove the overlap in focused tests and responsive browser navigation. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 7 + .../internal/subsystems/unified-resources.md | 7 + .../action-review-navigation.cjs | 182 ++++++++++++++++++ .../action-review-navigation.html | 11 ++ .../action-review-navigation.tsx | 14 ++ frontend-modern/browser-verification.json | 83 ++++++-- .../Actions.requestOwnership.test.tsx | 174 +++++++++++++++++ frontend-modern/src/pages/Actions.tsx | 46 ++++- 8 files changed, 496 insertions(+), 28 deletions(-) create mode 100644 frontend-modern/browser-tests/action-review-navigation.cjs create mode 100644 frontend-modern/browser-tests/action-review-navigation.html create mode 100644 frontend-modern/browser-tests/action-review-navigation.tsx create mode 100644 frontend-modern/src/features/actions/__tests__/Actions.requestOwnership.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 48bc844e21..a4c192aae8 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -6815,6 +6815,13 @@ the matching Open or History subtab from server-authored lifecycle state, and removes the query when the dialog closes. Feature pages may summarize action context, but they must not recreate approve, reject, run, progress, or outcome controls outside the shared Actions review. +The Actions route owns overlapping reads by request generation. A slower detail +response or late dialog refresh must not replace a newer URL-selected action or +reopen a closed review; a mismatched server action id is rejected. An older Open +or History list response must not overwrite the newer tab's results or error. +`Actions.requestOwnership.test.tsx` covers both response orders and close while +a receipt re-read is pending; the browser navigation proof checks the rendered +dialog and URL at desktop and phone widths without sending an action mutation. The Actions ledger is a peer top-level navigation destination. Patrol remains the primary detection and investigation home and may expose a route-backed Actions handoff, but Actions owns its pending-approval count and selected state. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 4701e6cbba..d34779661a 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -2951,6 +2951,13 @@ missing provenance remains an immediate fail-closed warning rather than hidden detail. Actions is the canonical browser hub for action review, execution progress, and recorded outcomes; contextual sources such as Patrol link into an exact action review instead of duplicating those mutations locally. +When a user selects a different action before a prior detail read completes, +only the detail matching the current `action` URL may open the review. Closing +the review invalidates pending detail and receipt reads, so a late response +cannot reopen it or substitute another resource's action. The page also keeps +Open and History list responses scoped to their latest request; an older tab +result or error cannot replace the current tab. This is a browser ownership +rule over server-authored action identity, not a new action or receipt state. APT review presents server-recorded policy provenance and distinguishes the elevated update posture from low-risk-eligible cache cleanup. Both typed actions diff --git a/frontend-modern/browser-tests/action-review-navigation.cjs b/frontend-modern/browser-tests/action-review-navigation.cjs new file mode 100644 index 0000000000..7e78e4dcbc --- /dev/null +++ b/frontend-modern/browser-tests/action-review-navigation.cjs @@ -0,0 +1,182 @@ +// Prove that late action reads cannot change the route-backed review or reopen it. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium } = require('playwright'); + +const action = (id) => ({ + id, + createdAt: '2026-09-29T18:00:00Z', + updatedAt: '2026-09-29T18:01:00Z', + state: 'executing', + decisionRevision: 1, + request: { + requestId: `request-${id}`, + resourceId: `docker:container:${id}`, + capabilityName: 'update', + reason: `Update ${id}`, + requestedBy: 'operator', + }, + plan: { + actionId: id, + requestId: `request-${id}`, + allowed: true, + requiresApproval: false, + approvalPolicy: 'none', + rollbackAvailable: false, + expiresAt: '2026-09-29T21:00:00Z', + planHash: `sha256:plan-${id}`, + }, + verificationOutcome: { status: 'unknown' }, +}); +const detail = (id) => ({ + audit: action(id), + events: [], + readiness: { + ready: false, + code: 'receipt_pending', + message: 'Awaiting receipt', + refreshable: false, + checkedAt: '2026-09-29T18:01:00Z', + }, + attempt: { + id: `attempt-${id}`, + actionId: id, + state: 'receipt_pending', + createdAt: '2026-09-29T18:00:00Z', + updatedAt: '2026-09-29T18:01:00Z', + dispatchCount: 1, + }, +}); + +(async () => { + const root = '/workspace/frontend-modern'; + const artifacts = path.join(root, 'node_modules', 'action-review-navigation-proof'); + fs.mkdirSync(artifacts, { recursive: true }); + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + server: { host: '127.0.0.1', port: 5204, strictPort: true }, + }); + let browser; + try { + await server.listen(); + browser = await chromium.launch({ + headless: true, + channel: 'chromium', + args: ['--no-sandbox'], + }); + const results = []; + for (const width of [1365, 390]) { + let releaseA; + let releaseReceipt; + let bReads = 0; + const requests = []; + const errors = []; + const page = await browser.newPage({ + viewport: { width, height: width === 390 ? 844 : 900 }, + }); + page.on('pageerror', (error) => errors.push(error.message)); + await page.route('**/*', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (url.origin !== 'http://127.0.0.1:5204') return route.abort(); + if (url.pathname.startsWith('/api/')) requests.push(`${request.method()} ${url.pathname}`); + if (url.pathname === '/api/actions') { + const view = url.searchParams.get('view'); + return route.fulfill({ + json: { + view, + actions: view === 'pending' ? [action('edge-a'), action('edge-b')] : [], + count: view === 'pending' ? 2 : 0, + readOnly: false, + }, + }); + } + if (url.pathname === '/api/actions/edge-a') { + await new Promise((resolve) => { + releaseA = resolve; + }); + return route.fulfill({ json: detail('edge-a') }); + } + if (url.pathname === '/api/actions/edge-b') { + bReads += 1; + if (bReads === 2) + await new Promise((resolve) => { + releaseReceipt = resolve; + }); + return route.fulfill({ json: detail('edge-b') }); + } + if (url.pathname === '/api/security/status') + return route.fulfill({ + json: { + hasAuthentication: true, + requiresAuth: true, + settingsCapabilities: { authenticationWrite: false }, + }, + }); + if (url.pathname.startsWith('/api/')) + return route.fulfill({ status: 503, json: { error: 'fixture unavailable' } }); + return route.continue(); + }); + await page.goto('http://127.0.0.1:5204/browser-tests/action-review-navigation.html', { + waitUntil: 'domcontentloaded', + timeout: 120_000, + }); + await page.getByRole('button', { name: /Review Update on docker:container:edge-a/ }).click(); + await page.waitForFunction(() => location.search === '?action=edge-a'); + await page.getByRole('button', { name: /Review Update on docker:container:edge-b/ }).click(); + await page.getByRole('dialog').getByText('docker:container:edge-b').first().waitFor(); + releaseA(); + await page.waitForTimeout(300); + assert.match(await page.getByRole('dialog').innerText(), /docker:container:edge-b/); + assert.doesNotMatch(await page.getByRole('dialog').innerText(), /docker:container:edge-a/); + assert.equal(new URL(page.url()).search, '?action=edge-b'); + await page.screenshot({ path: path.join(artifacts, `newest-review-${width}.png`) }); + + const receiptRead = page.waitForRequest( + (request) => + new URL(request.url()).pathname === '/api/actions/edge-b' && request.method() === 'GET', + ); + await page.getByRole('button', { name: 'Check for receipt' }).click(); + await receiptRead; + await page.waitForTimeout(50); + // The pending route is observable through the second GET, not a mutation. + assert.equal(bReads, 2); + await page.getByRole('button', { name: 'Close action review' }).click(); + await page.waitForFunction(() => location.search === ''); + releaseReceipt(); + await page.waitForTimeout(300); + assert.equal(await page.getByRole('dialog').count(), 0); + assert.equal(new URL(page.url()).search, ''); + assert.ok( + requests.every((item) => item.startsWith('GET ')), + JSON.stringify(requests), + ); + assert.deepEqual(errors, []); + const dimensions = await page.evaluate(() => ({ + scroll: document.documentElement.scrollWidth, + inner: innerWidth, + })); + assert.ok(dimensions.scroll <= dimensions.inner + 1, JSON.stringify(dimensions)); + results.push({ width, requests, errors, dimensions }); + await page.close(); + } + console.log( + JSON.stringify({ + result: 'passed', + browser: browser.version(), + playwright: '1.56.1', + results, + }), + ); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/action-review-navigation.html b/frontend-modern/browser-tests/action-review-navigation.html new file mode 100644 index 0000000000..ecd84c6757 --- /dev/null +++ b/frontend-modern/browser-tests/action-review-navigation.html @@ -0,0 +1,11 @@ + + + + + + + +
+ + + diff --git a/frontend-modern/browser-tests/action-review-navigation.tsx b/frontend-modern/browser-tests/action-review-navigation.tsx new file mode 100644 index 0000000000..40a4d6a600 --- /dev/null +++ b/frontend-modern/browser-tests/action-review-navigation.tsx @@ -0,0 +1,14 @@ +// Exercise the production Actions page with scripted, read-only action responses. +import { Route, Router } from '@solidjs/router'; +import { render } from 'solid-js/web'; +import Actions from '../src/pages/Actions'; +import '../src/index.css'; + +render( + () => ( + + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index dcc2cbd298..11d01597ee 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,16 +1,16 @@ { "version": 1, - "base_sha": "c2f7aa8b471d6cbb858e8ec71b4e664fded6549c", - "verified_at": "2026-09-29T19:09:52Z", + "base_sha": "fbf17b271abe70ad3e12042506b52f85b6cbc484", + "verified_at": "2026-09-29T19:50:45Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/actions/ActionReviewDialog.tsx" + "frontend-modern/src/pages/Actions.tsx" ], "content_sha256": { - "frontend-modern/src/features/actions/ActionReviewDialog.tsx": "9a4af522484b42e37b118e8bfde7c1673122037a54b8bfed249818ba644e6b81" + "frontend-modern/src/pages/Actions.tsx": "30e862ecc5125f07cf93f0c1e0b5f58efa023f5fdb56fe4b01721bf9a453e7ce" }, "routes": [ - "/browser-tests/action-receipt-wait.html (mock-backed production ActionReviewDialog)" + "/browser-tests/action-review-navigation.html (production Actions page with scripted action reads)" ], "viewports": [ { @@ -23,27 +23,70 @@ } ], "states": [ - "fresh executing/receipt_pending, including after a same-action re-read", - "failed status re-read with unknown outcome preserved", - "completed action after a recorded receipt" + "detail for edge-a held while edge-b is selected and rendered", + "late edge-a response cannot replace edge-b review or URL", + "pending receipt re-read finishes after edge-b review closes" ], "interactions": [ - "desktop pointer and phone keyboard Check for receipt", - "GET same action under pending, read error and completion; no POST", - "desktop and phone layout and horizontal-overflow checks" + "select edge-a then edge-b before the first detail response arrives", + "release older response and inspect current review at desktop and phone widths", + "start same-action receipt GET, close review, release response; no dialog reopens", + "assert all scripted API requests are GET and no horizontal overflow or page errors" ], - "command": "pulse-worker-browser frontend-modern/browser-tests/action-receipt-wait.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1", + "command": "pulse-worker-browser frontend-modern/browser-tests/action-review-navigation.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1", "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-browser.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-390.png" + "/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-browser.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-proof/newest-review-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-proof/newest-review-390.png" ], - "notes": "Production dialog mounted with synthetic recent action data and mocked same-action GET. Both desktop and phone showed explicit receipt-pending/unknown outcome guidance, a non-mutating status refresh, a safe read error, and recorded completion; screenshots inspected. This is not an installed action or reporter retest.", + "notes": "Mock-backed navigation proof of production Actions page; screenshots inspected. No update was executed, no installed or reporter result established.", "prior_verifications": [ + { + "version": 1, + "base_sha": "c2f7aa8b471d6cbb858e8ec71b4e664fded6549c", + "verified_at": "2026-09-29T19:09:52Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/actions/ActionReviewDialog.tsx" + ], + "content_sha256": { + "frontend-modern/src/features/actions/ActionReviewDialog.tsx": "9a4af522484b42e37b118e8bfde7c1673122037a54b8bfed249818ba644e6b81" + }, + "routes": [ + "/browser-tests/action-receipt-wait.html (mock-backed production ActionReviewDialog)" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "fresh executing/receipt_pending, including after a same-action re-read", + "failed status re-read with unknown outcome preserved", + "completed action after a recorded receipt" + ], + "interactions": [ + "desktop pointer and phone keyboard Check for receipt", + "GET same action under pending, read error and completion; no POST", + "desktop and phone layout and horizontal-overflow checks" + ], + "command": "pulse-worker-browser frontend-modern/browser-tests/action-receipt-wait.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1", + "artifacts": [ + "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-browser.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-390.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-390.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-390.png" + ], + "notes": "Production dialog mounted with synthetic recent action data and mocked same-action GET. Both desktop and phone showed explicit receipt-pending/unknown outcome guidance, a non-mutating status refresh, a safe read error, and recorded completion; screenshots inspected. This is not an installed action or reporter retest." + }, { "version": 1, "base_sha": "498e2b12a22510520f9df7a510105d6775ea0522", diff --git a/frontend-modern/src/features/actions/__tests__/Actions.requestOwnership.test.tsx b/frontend-modern/src/features/actions/__tests__/Actions.requestOwnership.test.tsx new file mode 100644 index 0000000000..bace802c09 --- /dev/null +++ b/frontend-modern/src/features/actions/__tests__/Actions.requestOwnership.test.tsx @@ -0,0 +1,174 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import { Route, Router } from '@solidjs/router'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ResourceActionsAPI } from '@/api/resourceActions'; +import { SecurityAPI } from '@/api/security'; +import type { ActionAuditRecord, ActionDetailResponse } from '@/types/actionAudit'; +import Actions from '../../../pages/Actions'; + +vi.mock('@/api/resourceActions', () => ({ + ResourceActionsAPI: { listActions: vi.fn(), getAction: vi.fn() }, +})); +vi.mock('@/api/security', () => ({ SecurityAPI: { getStatus: vi.fn() } })); +vi.mock('@/api/patrol', () => ({ getPatrolAutonomySettings: vi.fn().mockResolvedValue({}) })); + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +}; + +const audit = (id: string, state: ActionAuditRecord['state'] = 'executing'): ActionAuditRecord => ({ + id, + createdAt: '2026-09-29T18:00:00Z', + updatedAt: '2026-09-29T18:01:00Z', + state, + decisionRevision: 1, + request: { + requestId: `request-${id}`, + resourceId: `docker:container:${id}`, + capabilityName: 'update', + reason: `Update ${id}`, + requestedBy: 'operator', + }, + plan: { + actionId: id, + requestId: `request-${id}`, + allowed: true, + requiresApproval: false, + approvalPolicy: 'none', + rollbackAvailable: false, + expiresAt: '2026-09-29T21:00:00Z', + planHash: `sha256:plan-${id}`, + }, + verificationOutcome: { status: 'unknown' }, +}); + +const detail = (id: string): ActionDetailResponse => ({ + audit: audit(id), + events: [], + readiness: { + ready: false, + code: 'receipt_pending', + message: 'Awaiting receipt', + refreshable: false, + checkedAt: '2026-09-29T18:01:00Z', + }, + attempt: { + id: `attempt-${id}`, + actionId: id, + state: 'receipt_pending', + createdAt: '2026-09-29T18:00:00Z', + updatedAt: '2026-09-29T18:01:00Z', + dispatchCount: 1, + }, +}); + +const renderActions = () => + render(() => ( + + + + )); + +beforeEach(() => { + window.history.replaceState(null, '', '/'); + vi.mocked(ResourceActionsAPI.listActions).mockResolvedValue({ + actions: [audit('edge-a'), audit('edge-b')], + count: 2, + view: 'pending', + readOnly: false, + }); + vi.mocked(SecurityAPI.getStatus).mockResolvedValue({ + hasAuthentication: true, + requiresAuth: true, + settingsCapabilities: { authenticationWrite: false }, + } as Awaited>); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + window.history.replaceState(null, '', '/'); +}); + +describe('Actions request ownership', () => { + it('keeps the newest selected action when an older detail response arrives last', async () => { + const first = deferred(); + vi.mocked(ResourceActionsAPI.getAction).mockImplementation((id) => + id === 'edge-a' ? first.promise : Promise.resolve(detail('edge-b')), + ); + renderActions(); + fireEvent.click( + await screen.findByRole('button', { name: /Review Update on docker:container:edge-a/ }), + ); + fireEvent.click( + screen.getByRole('button', { name: /Review Update on docker:container:edge-b/ }), + ); + expect(await screen.findByText('docker:container:edge-b')).toBeVisible(); + + first.resolve(detail('edge-a')); + await waitFor(() => expect(ResourceActionsAPI.getAction).toHaveBeenCalledTimes(2)); + expect(screen.getByRole('dialog')).toHaveTextContent('docker:container:edge-b'); + expect(screen.getByRole('dialog')).not.toHaveTextContent('docker:container:edge-a'); + expect(window.location.search).toBe('?action=edge-b'); + }); + + it('does not reopen a closed review when a receipt re-read finishes late', async () => { + const read = deferred(); + vi.mocked(ResourceActionsAPI.getAction) + .mockResolvedValueOnce(detail('edge-a')) + .mockReturnValueOnce(read.promise); + renderActions(); + fireEvent.click( + await screen.findByRole('button', { name: /Review Update on docker:container:edge-a/ }), + ); + fireEvent.click(await screen.findByRole('button', { name: 'Check for receipt' })); + fireEvent.click(screen.getByRole('button', { name: 'Close action review' })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + await waitFor(() => expect(window.location.search).toBe('')); + + read.resolve({ ...detail('edge-a'), audit: audit('edge-a', 'completed') }); + await waitFor(() => expect(ResourceActionsAPI.getAction).toHaveBeenCalledTimes(2)); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(window.location.search).toBe(''); + }); + + it('refuses details returned for a different action id', async () => { + vi.mocked(ResourceActionsAPI.getAction).mockResolvedValue(detail('edge-b')); + renderActions(); + fireEvent.click( + await screen.findByRole('button', { name: /Review Update on docker:container:edge-a/ }), + ); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Action details did not match the selected action.', + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('does not show an old Open-list response under History', async () => { + const open = deferred>>(); + vi.mocked(ResourceActionsAPI.listActions).mockImplementation((requestedView) => + requestedView === 'pending' + ? open.promise + : Promise.resolve({ + actions: [audit('finished', 'completed')], + count: 1, + view: 'settled', + readOnly: false, + }), + ); + renderActions(); + fireEvent.click(screen.getByRole('tab', { name: 'History' })); + expect(await screen.findByRole('button', { name: /docker:container:finished/ })).toBeVisible(); + + open.resolve({ actions: [audit('edge-a')], count: 1, view: 'pending', readOnly: false }); + await waitFor(() => expect(ResourceActionsAPI.listActions).toHaveBeenCalledTimes(2)); + expect(screen.getByRole('button', { name: /docker:container:finished/ })).toBeVisible(); + expect( + screen.queryByRole('button', { name: /docker:container:edge-a/ }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend-modern/src/pages/Actions.tsx b/frontend-modern/src/pages/Actions.tsx index 7a85e93f92..068481a22a 100644 --- a/frontend-modern/src/pages/Actions.tsx +++ b/frontend-modern/src/pages/Actions.tsx @@ -55,6 +55,8 @@ export function Actions() { const [loadError, setLoadError] = createSignal(''); const [readOnly, setReadOnly] = createSignal(false); const [patrolWatchOnly, setPatrolWatchOnly] = createSignal(false); + let listRequestGeneration = 0; + let detailRequestGeneration = 0; // The Open tab is where users land wondering why nothing is queued. When // Patrol runs Watch only it never proposes fixes, so the calm state must say @@ -77,18 +79,22 @@ export function Actions() { ); const loadActions = async () => { + const generation = ++listRequestGeneration; + const requestedView = view(); setLoading(true); setLoadError(''); try { - const response = await ResourceActionsAPI.listActions(view()); + const response = await ResourceActionsAPI.listActions(requestedView); + if (generation !== listRequestGeneration) return; setActions(response.actions); setReadOnly(response.readOnly === true); } catch (cause) { + if (generation !== listRequestGeneration) return; setActions([]); setReadOnly(false); setLoadError(cause instanceof Error ? cause.message : 'The action store is unavailable.'); } finally { - setLoading(false); + if (generation === listRequestGeneration) setLoading(false); } }; @@ -108,14 +114,26 @@ export function Actions() { view() === 'pending' ? sortOpenActionsForReview(actions()) : actions(), ); - const openActionById = async (actionId: string) => { - setDetailError(''); + const openActionById = async (actionId: string, generation: number) => { try { const detail = await ResourceActionsAPI.getAction(actionId); + if ( + generation !== detailRequestGeneration || + parseActionReviewId(location.search) !== actionId + ) + return; + if (detail.audit.id !== actionId) { + setDetailError('Action details did not match the selected action. Open it again.'); + return; + } setSelected(detail); setView(getInboxViewForState(detail.audit.state)); } catch (cause) { - setSelected(null); + if ( + generation !== detailRequestGeneration || + parseActionReviewId(location.search) !== actionId + ) + return; setDetailError(cause instanceof Error ? cause.message : 'Action details are unavailable.'); } }; @@ -125,6 +143,7 @@ export function Actions() { }; const closeAction = () => { + ++detailRequestGeneration; setSelected(null); setDetailError(''); setSearchParams({ [ACTION_REVIEW_QUERY_PARAM]: null }, { replace: true }); @@ -137,7 +156,10 @@ export function Actions() { createEffect(() => { const actionId = parseActionReviewId(location.search); - if (actionId) void openActionById(actionId); + const generation = ++detailRequestGeneration; + setSelected(null); + setDetailError(''); + if (actionId) void openActionById(actionId, generation); }); return ( @@ -330,9 +352,17 @@ export function Actions() { detail={selected()} onClose={closeAction} onChanged={async (detail) => { + // A read or mutation started in an earlier review must not replace + // the action currently selected by the URL (or reopen a closed one). + if ( + selected()?.audit.id !== detail.audit.id || + parseActionReviewId(location.search) !== detail.audit.id + ) + return; setSelected(detail); - setView(getInboxViewForState(detail.audit.state)); - await loadActions(); + const nextView = getInboxViewForState(detail.audit.state); + if (view() === nextView) await loadActions(); + else setView(nextView); // the view effect loads the new list once }} /> From fd9e836ca458e34f5b6736b456375de51a625b18 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:18:19 +0100 Subject: [PATCH 2/3] Notify continuing symptoms after primary recovery A supported endpoint failure may outlive its primary host incident. When synthesis removes the supported cause, dispatch its first independent notification without reopening the occurrence or duplicating a symptom previously notified before grouping. Change-source: pulse-maintainer --- .../v6/internal/subsystems/alerts.md | 9 +- internal/alerts/incident_synthesis_test.go | 107 ++++++++++++++++++ internal/alerts/unified_incidents.go | 30 ++++- 3 files changed, 138 insertions(+), 8 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index da1a40fbfc..60d37911dd 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -2833,11 +2833,16 @@ notification delivery, with the typed `correlated_primary` reason and primary alert ID in the event log. The primary remains the one delivery owner. Supporting members also skip escalation levels and critical repeats while that supported cause is current; an already queued escalation is revalidated before delivery. +If the supported primary recovers or the causal link is lost while its symptom +still fails, the symptom regains independent delivery without a new occurrence. +It receives its first notification only if it was never dispatched before +grouping; an already-notified symptom does not duplicate on group dissolution. +Acknowledgement, rate limits and other ordinary delivery gates still apply. `observation-set` members continue to notify independently because Pulse has not established causality. `incident_synthesis_test.go` pins classification, contradiction downgrade, bounded evidence, duplicate-delivery suppression, -partial-recovery behavior, escalation suppression, and queued-callback -revalidation without muting observation sets. +partial-recovery delivery and no duplicate after prior dispatch, escalation +suppression, and queued-callback revalidation without muting observation sets. ### Alert hydration is not resource admission diff --git a/internal/alerts/incident_synthesis_test.go b/internal/alerts/incident_synthesis_test.go index 33cc09d830..e1db7511ea 100644 --- a/internal/alerts/incident_synthesis_test.go +++ b/internal/alerts/incident_synthesis_test.go @@ -235,6 +235,113 @@ func TestInfrastructureIncidentSynthesisShowsPartialRecovery(t *testing.T) { } } +func TestSupportedSymptomNotifiesWhenPrimaryRecoversButSymptomPersists(t *testing.T) { + manager := newTestManager(t) + configureUnifiedEvalManager(t, manager, unifiedEvalBaseConfig()) + startedAt := time.Date(2026, 9, 29, 18, 0, 0, 0, time.UTC) + hostID := "agent:edge-1" + endpointID := "availability:checkout" + host := unifiedresources.Resource{ + ID: hostID, Type: unifiedresources.ResourceTypeAgent, Name: "Edge host", Status: unifiedresources.StatusOffline, + Incidents: []unifiedresources.ResourceIncident{{ + Provider: "agent", NativeID: "edge-1", Code: "agent_offline", Severity: storagehealth.RiskCritical, + Source: "agent.heartbeat", Summary: "Edge host is offline", StartedAt: startedAt, + ConfirmationsRequired: 1, RecoveryConfirmationsRequired: 1, + }}, + } + endpoint := unifiedresources.Resource{ + ID: endpointID, Type: unifiedresources.ResourceTypeNetworkEndpoint, Name: "Checkout", Status: unifiedresources.StatusOffline, + Availability: &unifiedresources.AvailabilityData{AggregateState: "unavailable", TransportOutcome: "unreachable"}, + Relationships: []unifiedresources.ResourceRelationship{{ + SourceID: endpointID, TargetID: hostID, Type: unifiedresources.RelChecks, Confidence: 1, Active: true, + }}, + Incidents: []unifiedresources.ResourceIncident{{ + Provider: "availability", NativeID: "checkout", Code: "availability_unreachable", Severity: storagehealth.RiskCritical, + Source: "availability.probe", Summary: "Checkout is unreachable", StartedAt: startedAt.Add(time.Second), + ConfirmationsRequired: 1, RecoveryConfirmationsRequired: 1, + }}, + } + var delivered []string + manager.SetAlertCallback(func(alert *Alert) { delivered = append(delivered, alert.ResourceID) }) + + manager.SyncUnifiedResourceIncidents([]unifiedresources.Resource{host, endpoint}) + if len(delivered) != 1 || delivered[0] != hostID { + t.Fatalf("grouped delivery = %v, want only the primary host", delivered) + } + for _, alert := range manager.GetActiveAlerts() { + if alert.ResourceID == endpointID && (!isSupportedInfrastructureSymptom(&alert) || alert.LastNotified != nil) { + t.Fatalf("grouped symptom = %+v, want supported and not separately dispatched", alert) + } + } + + // The host is healthy again, but the endpoint still fails. It must regain + // its own notification path without requiring a new alert occurrence. + host.Status = unifiedresources.StatusOnline + host.Incidents = nil + manager.SyncUnifiedResourceIncidents([]unifiedresources.Resource{host, endpoint}) + if len(delivered) != 2 || delivered[1] != endpointID { + t.Fatalf("after primary recovery delivery = %v, want continuing symptom", delivered) + } + manager.SyncUnifiedResourceIncidents([]unifiedresources.Resource{host, endpoint}) + if len(delivered) != 2 { + t.Fatalf("unchanged independent symptom sent again: %v", delivered) + } +} + +func TestPreviouslyNotifiedSymptomDoesNotDuplicateWhenPrimaryRecovers(t *testing.T) { + manager := newTestManager(t) + configureUnifiedEvalManager(t, manager, unifiedEvalBaseConfig()) + startedAt := time.Date(2026, 9, 29, 18, 0, 0, 0, time.UTC) + hostID := "agent:edge-1" + endpointID := "availability:checkout" + host := unifiedresources.Resource{ + ID: hostID, Type: unifiedresources.ResourceTypeAgent, Status: unifiedresources.StatusOnline, + } + endpoint := unifiedresources.Resource{ + ID: endpointID, Type: unifiedresources.ResourceTypeNetworkEndpoint, Status: unifiedresources.StatusOffline, + Availability: &unifiedresources.AvailabilityData{AggregateState: "unavailable", TransportOutcome: "unreachable"}, + Relationships: []unifiedresources.ResourceRelationship{{ + SourceID: endpointID, TargetID: hostID, Type: unifiedresources.RelChecks, Confidence: 1, Active: true, + }}, + Incidents: []unifiedresources.ResourceIncident{{ + Provider: "availability", NativeID: "checkout", Code: "availability_unreachable", Severity: storagehealth.RiskCritical, + Source: "availability.probe", StartedAt: startedAt.Add(time.Second), ConfirmationsRequired: 1, RecoveryConfirmationsRequired: 1, + }}, + } + var delivered []string + manager.SetAlertCallback(func(alert *Alert) { delivered = append(delivered, alert.ResourceID) }) + + manager.SyncUnifiedResourceIncidents([]unifiedresources.Resource{host, endpoint}) + if len(delivered) != 1 || delivered[0] != endpointID { + t.Fatalf("standalone symptom delivery = %v, want endpoint", delivered) + } + host.Status = unifiedresources.StatusOffline + host.Incidents = []unifiedresources.ResourceIncident{{ + Provider: "agent", NativeID: "edge-1", Code: "agent_offline", Severity: storagehealth.RiskCritical, + Source: "agent.heartbeat", StartedAt: startedAt, ConfirmationsRequired: 1, RecoveryConfirmationsRequired: 1, + }} + manager.SyncUnifiedResourceIncidents([]unifiedresources.Resource{host, endpoint}) + if len(delivered) != 2 || delivered[1] != hostID { + t.Fatalf("new primary delivery = %v, want endpoint then host", delivered) + } + var grouped bool + for _, alert := range manager.GetActiveAlerts() { + if alert.ResourceID == endpointID { + grouped = isSupportedInfrastructureSymptom(&alert) + } + } + if !grouped { + t.Fatal("endpoint did not gain a supported primary") + } + + host.Status = unifiedresources.StatusOnline + host.Incidents = nil + manager.SyncUnifiedResourceIncidents([]unifiedresources.Resource{host, endpoint}) + if len(delivered) != 2 { + t.Fatalf("already-notified endpoint duplicated after primary recovery: %v", delivered) + } +} + func TestClassifyIncidentFailureSeparatesApplicationCertificateAndCoverage(t *testing.T) { applicationResource := unifiedresources.Resource{Availability: &unifiedresources.AvailabilityData{ AggregateState: "unavailable", TransportOutcome: "reachable", ApplicationOutcome: "failed", diff --git a/internal/alerts/unified_incidents.go b/internal/alerts/unified_incidents.go index da4fd8f9d3..b27011dffc 100644 --- a/internal/alerts/unified_incidents.go +++ b/internal/alerts/unified_incidents.go @@ -260,6 +260,17 @@ func (m *Manager) SyncUnifiedResourceIncidents(resources []unifiedresources.Reso m.clearAlertNoLock(storageKey) } + // Synthesis replaces its previous links in place. Remember which retained + // incidents had a primary delivery owner before that information is cleared. + // If the primary recovers while a symptom remains active, the symptom needs + // its own first notification even though its occurrence did not restart. + previouslySupported := make(map[string]bool) + for storageKey := range desired { + if existing, exists := m.getActiveAlertNoLock(storageKey); exists && isSupportedInfrastructureSymptom(existing) { + previouslySupported[storageKey] = true + } + } + // Synthesize over the post-recovery live set before updating or dispatching // any detector. Supporting symptoms retain their independent lifecycle but // can share one evidence-backed incident presentation and delivery decision. @@ -297,12 +308,19 @@ func (m *Manager) SyncUnifiedResourceIncidents(resources []unifiedresources.Reso if !m.setActiveAlertNoLock(storageKey, existing) { continue } - // Like metric alerts, an existing provider incident becoming critical - // must notify again. Keep its lifecycle and all delivery policy gates; - // unchanged severity and downgrades must not create notification noise. - if oldLevel != existing.Level && existing.Level == AlertLevelCritical && - !existing.Acknowledged && - m.allowNotificationByRateLimit(storageKey, existing, "critical-escalation") { + // A still-failing symptom which loses its supported primary needs its + // first notification. An alert already dispatched before grouping must + // not be duplicated when the group dissolves. As with metric alerts, a + // separate increase to critical may also notify through normal gates. + newlyIndependent := previouslySupported[storageKey] && + !isSupportedInfrastructureSymptom(existing) && existing.LastNotified == nil + becameCritical := oldLevel != existing.Level && existing.Level == AlertLevelCritical + notificationReason := "critical-escalation" + if newlyIndependent { + notificationReason = "supported-primary-ended" + } + if (newlyIndependent || becameCritical) && !existing.Acknowledged && + m.allowNotificationByRateLimit(storageKey, existing, notificationReason) { m.dispatchAlert(existing, false) } continue From 91d8822d76c5a7214995c0e370be637b6e6336e3 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:36:11 +0100 Subject: [PATCH 3/3] Pin hosted Go builders to refreshed 1.26.8 image Advance the provider control-plane source builder and integration mock builder on Pulse PR #2275's reviewed 1.26.8 Alpine digest. Keep their exact compiler image locked by an installability proof and record what this does not qualify. The published prebuilt runtime path is unchanged. Change-source: pulse-maintainer --- deploy/provider-msp/Dockerfile.control-plane | 2 +- .../subsystems/deployment-installability.md | 7 ++++++ .../installtests/provider_msp_deploy_test.go | 22 +++++++++++++++++++ .../integration/mock-github-server/Dockerfile | 2 +- 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/deploy/provider-msp/Dockerfile.control-plane b/deploy/provider-msp/Dockerfile.control-plane index cde348f419..3049e143e8 100644 --- a/deploy/provider-msp/Dockerfile.control-plane +++ b/deploy/provider-msp/Dockerfile.control-plane @@ -12,7 +12,7 @@ COPY SECURITY.md TERMS.md /app/ RUN --mount=type=cache,id=pulse-control-plane-npm-cache,target=/root/.npm \ npm run build -FROM --platform=$BUILDPLATFORM golang:1.26.8-alpine@sha256:51a7c389a5ddaf82f527191a1e9bff9928655130a44e4975dd1d7e0acf59f1ae AS builder +FROM --platform=$BUILDPLATFORM golang:1.26.8-alpine@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c AS builder ARG VERSION=dev ARG BUILD_TIME=unknown ARG GIT_COMMIT=unknown diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 96cff70a01..25b94cd236 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -3724,6 +3724,13 @@ vulnerabilities in the current patch level, the canonical fix is to advance the governed release toolchain and immutable Go builder digest together, not to suppress the scanner or produce release artifacts with an older patched-over runtime. +The hosted control-plane source builder and integration mock builder are both +on the reviewed `golang:1.26.8-alpine` digest +`sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c`. +`TestProviderMSPGoBuilderMatchesIntegrationMock` pins their exact agreement; +it does not pull the image, prove its registry contents, or qualify a deployed +control plane. The release publisher's prebuilt control-plane target is a +separate path and does not rebuild its binary from this stage. As of 2026-08-27, the governed release floor is Go `1.26.7`. It supersedes `1.26.5`, whose standard library is reachable through seven vulnerable Pulse call paths reported by `govulncheck`, including HTTP/TLS, URL parsing, SAML XML diff --git a/scripts/installtests/provider_msp_deploy_test.go b/scripts/installtests/provider_msp_deploy_test.go index 039e17b48f..54f0659c93 100644 --- a/scripts/installtests/provider_msp_deploy_test.go +++ b/scripts/installtests/provider_msp_deploy_test.go @@ -4,12 +4,34 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strings" "testing" "gopkg.in/yaml.v3" ) +func TestProviderMSPGoBuilderMatchesIntegrationMock(t *testing.T) { + // These builders use the same Go patch level and immutable image. The + // mock's build must not exercise a different compiler from the hosted + // control plane merely because Dependabot advanced one Dockerfile. + const expected = "golang:1.26.8-alpine@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c" + pattern := regexp.MustCompile(`(?m)^FROM (?:--platform=\$BUILDPLATFORM )?(golang:[^[:space:]]+) AS builder$`) + for _, path := range [][]string{ + {"deploy", "provider-msp", "Dockerfile.control-plane"}, + {"tests", "integration", "mock-github-server", "Dockerfile"}, + } { + content, err := os.ReadFile(repoFile(path...)) + if err != nil { + t.Fatalf("read %s: %v", filepath.Join(path...), err) + } + match := pattern.FindStringSubmatch(string(content)) + if len(match) != 2 || match[1] != expected { + t.Errorf("%s Go builder = %q, want %q", filepath.Join(path...), match, expected) + } + } +} + func TestProviderMSPControlPlaneImageConsumesExactCandidate(t *testing.T) { dockerfileBytes, err := os.ReadFile(repoFile("deploy", "provider-msp", "Dockerfile.control-plane")) if err != nil { diff --git a/tests/integration/mock-github-server/Dockerfile b/tests/integration/mock-github-server/Dockerfile index 93a38d3567..af4acf0871 100644 --- a/tests/integration/mock-github-server/Dockerfile +++ b/tests/integration/mock-github-server/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.8-alpine@sha256:51a7c389a5ddaf82f527191a1e9bff9928655130a44e4975dd1d7e0acf59f1ae AS builder +FROM golang:1.26.8-alpine@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c AS builder WORKDIR /build COPY go.mod ./