diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md
index 83a8a073de..975c62f408 100644
--- a/docs/release-control/v6/internal/subsystems/unified-resources.md
+++ b/docs/release-control/v6/internal/subsystems/unified-resources.md
@@ -23,6 +23,26 @@ and sort the complete canonical change table while startup and ingestion wait.
## Purpose
+### Resource evidence fallback access boundaries
+
+When the existing resource-facet reader receives a final HTTP 401/403, its
+drawer must not substitute embedded snapshot events, counts, capabilities or
+relationships for the withdrawn response. A denied filtered read must not
+substitute the unfiltered bundle. Denial remains visible during that request's
+retry, and fresh success restores its evidence. Independently successful
+unfiltered reads retain their own ownership; this is not a global revocation
+broadcast. Transient failures still retain applicable snapshots, and compact
+snapshot-only rows still make no remote history reads.
+
+Failed change/action reads are unavailable, not “loaded” or empty. The Manage
+tab keeps a failed action read's retry visible even without retained rows,
+without changing the action client's existing unavailable-plan responses.
+The hook access tests and mounted ResourceDetailDrawer.history tests protect
+these boundaries; browser-tests/resource-evidence-access.cjs exercises the
+production drawer/client/CSS on desktop Chromium and dark touch WebKit. Its
+synthetic HTTP responses do not establish native authorisation, a reporter's
+cause, installed recovery or release availability.
+
### Detail snapshot and selection ownership
ResourceDetailDrawer reads the current resource through a reactive getter in
diff --git a/frontend-modern/browser-tests/resource-evidence-access.cjs b/frontend-modern/browser-tests/resource-evidence-access.cjs
new file mode 100644
index 0000000000..55a01c1fdc
--- /dev/null
+++ b/frontend-modern/browser-tests/resource-evidence-access.cjs
@@ -0,0 +1,100 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium, webkit } = require('playwright');
+const parent = false;
+(async () => {
+ const root = parent ? '/workspace/tmp/resource-access-parent/frontend-modern' : '/workspace/frontend-modern';
+ const artifacts = `/workspace/tmp/resource-evidence-${parent ? 'parent' : 'final'}-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'), cacheDir: fs.mkdtempSync(path.join(artifacts, 'vite-')), server: { host: '127.0.0.1', port: 5267, strictPort: true, fs: { allow: ['/workspace'] } } });
+ const playwright = require('playwright/package.json').version;
+ assert.equal(playwright, JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages['node_modules/@playwright/test'].version);
+ const observations = [];
+ let browser;
+ try {
+ await server.listen();
+ for (const [name, engine, phone] of [['chromium-desktop', chromium, false], ['webkit-phone', webkit, true]]) {
+ browser = await engine.launch(engine === chromium ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } : { headless: true });
+ const page = await browser.newPage({ viewport: phone ? { width: 390, height: 844 } : { width: 1365, height: 900 }, hasTouch: phone, isMobile: phone });
+ page.setDefaultTimeout(60000);
+ let facetMode = 'success', actionMode = 'success', actor = 'Authorised response actor';
+ const requests = [], errors = [], offOrigin = [], checks = [], held = [];
+ page.on('pageerror', error => errors.push(error.message));
+ await page.route('**/*', async route => {
+ const url = new URL(route.request().url());
+ if (url.origin !== 'http://127.0.0.1:5267') { offOrigin.push(url.origin); return route.abort(); }
+ if (!url.pathname.startsWith('/api/')) return route.continue();
+ if (url.pathname.endsWith('/facets')) {
+ const mode = facetMode;
+ requests.push({ path: url.pathname, query: Object.fromEntries(url.searchParams), mode });
+ if (mode === 'hold') await new Promise(resolve => held.push(resolve));
+ if (mode === '403' || mode === '503') return route.fulfill({ status: Number(mode), json: { error: 'Fixture service unavailable' } });
+ return route.fulfill({ json: { capabilities: [], relationships: [], counts: { recentChanges: 1 }, recentChanges: [{ id: 'remote-event', resourceId: 'pbs-a', observedAt: '2026-10-03T03:00:00Z', kind: 'restart', sourceType: 'platform_event', confidence: 'high', actor }] } });
+ }
+ if (url.pathname === '/api/audit/actions') {
+ requests.push({ path: url.pathname, mode: actionMode });
+ return actionMode === '503' ? route.fulfill({ status: 503, json: { error: 'Fixture actions unavailable' } }) : route.fulfill({ json: { audits: [], count: 0 } });
+ }
+ return route.fulfill({ json: url.pathname === '/api/license/runtime-capabilities' ? { capabilities: [], limits: [], max_history_days: 7, hosted_mode: false, runtime: { build: 'community' }, blocked_capabilities: [] } : { capabilities: [], relationships: [], recentChanges: [], audits: [], count: 0, data: [], enabled: false } });
+ });
+ const record = (name, pass) => { checks.push({ name, pass }); };
+ const screenshot = state => page.screenshot({ path: path.join(artifacts, `${name}-${state}.png`), fullPage: true });
+ const changes = page.getByTestId('resource-change-history-section');
+ const actions = page.getByTestId('resource-action-history-section');
+ await page.goto('http://127.0.0.1:5267/browser-tests/resource-evidence-access.html', { waitUntil: 'domcontentloaded', timeout: 120000 });
+ if (phone) await page.evaluate(() => document.documentElement.classList.add('dark'));
+ await changes.getByText('Authorised response actor', { exact: true }).waitFor();
+ record('successful remote evidence replaces snapshot', !(await changes.getByText('Embedded snapshot actor').count()));
+ await screenshot('loaded');
+ facetMode = '403'; actionMode = '503';
+ await page.getByRole('button', { name: 'Remount details' }).click();
+ await changes.getByRole('button', { name: 'Retry', exact: true }).waitFor();
+ await page.waitForTimeout(500);
+ record('denial withdraws embedded evidence', !(await changes.getByText('Embedded snapshot actor').count()) && !(await changes.getByText('Authorised response actor').count()));
+ record('bounded denial guidance, not empty history', (await changes.getByText('Access denied. Check your permissions and license plan.', { exact: true }).count()) === 1 && !(await changes.getByText('No events yet.', { exact: true }).count()));
+ record('denial is labelled unavailable', (await changes.getByText('Changes unavailable', { exact: true }).count()) === 1);
+ await page.getByRole('tab', { name: 'Manage', exact: true }).click();
+ record('failed first action read has visible retry, not empty history', (await actions.count()) === 1 && (await actions.getByRole('button', { name: 'Retry', exact: true }).count()) === 1 && !(await actions.getByText('No actions yet.', { exact: true }).count()));
+ await screenshot('action-failed');
+ await page.getByRole('tab', { name: 'Overview', exact: true }).click();
+ await screenshot('denied');
+ facetMode = 'hold';
+ await changes.getByRole('button', { name: 'Retry', exact: true }).focus();
+ await changes.getByRole('button', { name: 'Retry', exact: true }).press('Enter');
+ await changes.getByText('Refreshing changes...', { exact: true }).waitFor();
+ record('retry keeps denied evidence withdrawn', !(await changes.getByText('Embedded snapshot actor').count()) && !(await changes.getByText('Authorised response actor').count()));
+ await screenshot('retry-pending');
+ actor = 'Fresh response actor'; facetMode = 'success';
+ held.splice(0).forEach(resolve => resolve());
+ await changes.getByText('Fresh response actor', { exact: true }).waitFor();
+ record('fresh successful retry restores evidence and clears guidance', !(await changes.getByText('Access denied. Check your permissions and license plan.').count()));
+ await screenshot('recovered');
+ await changes.getByRole('button', { name: 'Filter history', exact: true }).click();
+ facetMode = '403';
+ await changes.getByLabel('Change kind', { exact: true }).selectOption('restart');
+ await changes.getByRole('button', { name: 'Retry', exact: true }).waitFor();
+ record('filtered denial cannot substitute authorised unfiltered events', !(await changes.getByText('Fresh response actor').count()) && (await changes.getByText('Filtered changes unavailable', { exact: true }).count()) === 1);
+ await screenshot('filtered-denied');
+ await changes.getByRole('button', { name: 'Clear filters', exact: true }).click();
+ await changes.getByText('Fresh response actor', { exact: true }).waitFor();
+ record('clearing filter restores independently authorised base request', true);
+ facetMode = '503';
+ await page.getByRole('button', { name: 'Remount details' }).click();
+ await changes.getByRole('button', { name: 'Retry', exact: true }).waitFor();
+ record('transient failure preserves embedded snapshot evidence', (await changes.getByText('Embedded snapshot actor', { exact: true }).count()) === 1);
+ await screenshot('transient');
+ record('no page errors or off-origin requests', errors.length === 0 && offOrigin.length === 0);
+ observations.push({ name, browser: browser.version(), viewport: page.viewportSize(), requests, errors, offOrigin, checks });
+ await browser.close(); browser = null;
+ }
+ } finally {
+ if (browser) await browser.close();
+ await server.close();
+ fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify({ parent, playwright, observations }, null, 2));
+ }
+ console.log(JSON.stringify({ parent, checks: observations.flatMap(o => o.checks).length, failures: observations.flatMap(o => o.checks).filter(c => !c.pass) }));
+ if (!parent) assert.ok(observations.flatMap(o => o.checks).every(c => c.pass));
+})().catch(error => { console.error(error.stack); process.exitCode = 1; });
diff --git a/frontend-modern/browser-tests/resource-evidence-access.html b/frontend-modern/browser-tests/resource-evidence-access.html
new file mode 100644
index 0000000000..6198edc8f8
--- /dev/null
+++ b/frontend-modern/browser-tests/resource-evidence-access.html
@@ -0,0 +1,2 @@
+
+
Resource evidence access verification
diff --git a/frontend-modern/browser-tests/resource-evidence-access.tsx b/frontend-modern/browser-tests/resource-evidence-access.tsx
new file mode 100644
index 0000000000..e0f39cb4bd
--- /dev/null
+++ b/frontend-modern/browser-tests/resource-evidence-access.tsx
@@ -0,0 +1,62 @@
+import { createSignal, Show } from 'solid-js';
+import { render } from 'solid-js/web';
+import { ResourceDetailDrawer } from '../src/components/Infrastructure/ResourceDetailDrawer';
+import { DarkModeContext, WebSocketContext } from '../src/contexts/appRuntime';
+import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
+import type { Resource } from '../src/types/resource';
+import '../src/index.css';
+
+// Synthetic snapshots and HTTP responses; production drawer, query/client and CSS.
+syncAIRuntimeSettings({ discovery_enabled: false });
+const resource: Resource = {
+ id: 'pbs-a',
+ name: 'PBS A',
+ displayName: 'PBS A',
+ type: 'pbs',
+ platformId: 'pbs-a',
+ platformType: 'proxmox-pbs',
+ sourceType: 'api',
+ status: 'online',
+ lastSeen: Date.now(),
+ cpu: { current: 12 },
+ recentChanges: [
+ {
+ id: 'embedded-event',
+ resourceId: 'pbs-a',
+ observedAt: '2026-10-03T03:00:00Z',
+ kind: 'restart',
+ sourceType: 'platform_event',
+ confidence: 'high',
+ actor: 'Embedded snapshot actor',
+ },
+ ],
+ facetCounts: { recentChanges: 7 },
+};
+const [mounted, setMounted] = createSignal(true);
+render(
+ () => (
+ true } as any}>
+ document.documentElement.classList.contains('dark')}>
+
+ Resource evidence access verification
+
+ Synthetic PBS evidence; no appliance, release or installed authorisation acceptance.
+
+
+
+
+
+
+
+
+ ),
+ document.getElementById('root')!,
+);
diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json
index 353d9f0ce2..bd2176689c 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -1,25 +1,23 @@
{
"version": 1,
"result": "passed",
- "base_sha": "a9533eb82a29700ede2d4ad609812ddd01e5f287",
- "verified_at": "2026-10-03T01:44:27.336168Z",
+ "base_sha": "227657b1b0d6924e98da9382f812bb0cb1874f57",
+ "verified_at": "2026-10-03T04:13:25.499572Z",
"changed_paths": [
- "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx",
- "frontend-modern/src/components/shared/HistoryChartOverlay.tsx",
- "frontend-modern/src/components/shared/useHistoryChartState.ts",
- "frontend-modern/src/hooks/createNonSuspendingQuery.ts",
- "frontend-modern/src/utils/apiAccessError.ts"
+ "frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx",
+ "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx",
+ "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
+ "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts"
],
"content_sha256": {
- "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "c4370a69495c8ccea01a0d92076e4a051e4d06abec0e675e2fb3547d46e87bcb",
- "frontend-modern/src/components/shared/HistoryChartOverlay.tsx": "14c0699b827ec6eb4d85cf9d8fc76d61a0af3dd6565d2396a3f22a03429af104",
- "frontend-modern/src/components/shared/useHistoryChartState.ts": "5a9b022656ac7316710675a0742e9b2908c9114ffbed67d6f6962d361bb65bbf",
- "frontend-modern/src/hooks/createNonSuspendingQuery.ts": "df293d0484612a6d31dd9f3fa6af2a1160e1e98d66965730a45829b7048a4ba7",
- "frontend-modern/src/utils/apiAccessError.ts": "3b34a498959a77014af3c842677fc6646b5e7e6a8b9b332ca8acf91254c01730"
+ "frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx": "44f939bd8accdb5662f2f99a2c4d6324d965ca520414d0a0db8daba790af04d9",
+ "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "41dadd17f930115374facc8f7a9dc46f3e4eab69e41e4375c90f9a5f57022fe8",
+ "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "ee9482a2076b86e62c85bffe0ce816a6ccd542ec3b07a85e829a1cc07dd8b1de",
+ "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts": "a39e66fc3ca772f0394ad0b5a74feee67a80daac3fdc604d014cadb52cda02b6"
},
"routes": [
- "Existing PBS/Machine/resource drawer History batch reader",
- "Existing Storage physical disk shared History reader"
+ "Existing PBS/resource detail Overview change history",
+ "Existing resource detail Manage action history"
],
"viewports": [
{
@@ -32,25 +30,23 @@
}
],
"states": [
- "Real same-origin 503 responses retain stored samples with explicit warnings in both production readers",
- "Real final HTTP 403 responses withdraw samples, tooltip, keyboard announcement, sliders and formerly displayed current-only legends",
- "Access-denied guidance fits light desktop Chromium and dark touch WebKit without horizontal overflow",
- "The existing focusable scoped retry remains mounted and ignores duplicate activation while busy",
- "Earlier cached ranges and remounts cannot resurrect denied stored readings while a replacement is pending",
- "Only a new successful read restores History and clears denial; old values do not return",
- "Every final screenshot inspected; fixed Playwright 1.56.1 equals integration lock, no page errors or off-origin data requests"
+ "Final facet HTTP 403 withdraws embedded snapshot events/counts instead of reintroducing denied evidence",
+ "Denied filtered facets do not fall back to the unfiltered bundle",
+ "Retry retains denial while pending; fresh successful response restores events and clears guidance",
+ "First action-history HTTP 503 stays visible in Manage with retry and no false empty state",
+ "Initial facet HTTP 503 retains applicable embedded evidence; snapshot-only table rows still make no remote reads",
+ "All fourteen final screenshots inspected in desktop Chromium and dark touch phone WebKit; Playwright 1.56.1 matches the integration lock"
],
"interactions": [
- "Inspect by desktop keyboard and phone tap",
- "Poll through 503 then 403",
- "Retry twice while busy",
- "Change 24h/1h range",
- "Unmount and remount batch History",
- "Resume with newly successful observations"
+ "Remount full production resource drawer with denied/transient reads",
+ "Activate scoped Retry with keyboard Enter",
+ "Apply and clear Change kind filter",
+ "Switch between Overview and Manage"
],
"limitations": [
- "Direct production readers/client with synthetic HTTP responses, not a complete native PBS/NAS drawer, session revocation, appliance collection or release acceptance.",
- "401 is covered by runtime regressions; the real API client redirects on 401, not bypassed for browser proof.",
- "Independently supplied current readings remain distinctly current during a new pending query; they are not stored History."
+ "Synthetic HTTP responses and PBS snapshots through real production drawer/client/CSS, not appliance authorisation, reporter cause, release or installed recovery acceptance.",
+ "401 withdrawal is unit-covered; real client login redirection is not overridden or browser-proved.",
+ "Action client existing 401/402/403 unavailable-plan responses are unchanged. Only thrown read failures retain actionable error presentation.",
+ "Independent reader authorisation remains independent; this is not a global session revocation broadcast."
]
}
diff --git a/frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx b/frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx
index a14fc47b69..f69839590b 100644
--- a/frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx
+++ b/frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx
@@ -215,7 +215,10 @@ export const ResourceActionHistory: Component = (pro
-
+
{props.error}