diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md
index 12452c53b1..0fd38347b5 100644
--- a/docs/release-control/v6/internal/subsystems/api-contracts.md
+++ b/docs/release-control/v6/internal/subsystems/api-contracts.md
@@ -3187,6 +3187,11 @@ a new API state machine, queue contract, or verification-accounting field.
proof. The default API-backed presentation must stay on the selected control
level and its plain summary; it must not render a separate Limits disclosure,
hard-limit matrix, or always-on explanatory matrix beside the mode picker.
+ and the Patrol container update risk lock, so `/api/settings/ai`
+ `alert_triggered_analysis` stays the licence-masked API value while the
+ runtime-capabilities `ai_alerts` grant or `paid_runtime_required` block
+ decides which lock explanation the Patrol settings card renders, without a
+ new API field
and the Patrol run-record copy, so `finding_ids` remains the API-owned
fail-closed scoping input while the frontend presents selected history as a
Patrol run record instead of a generic findings filter or snapshot workflow
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index e077067776..b2eb84d4c2 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -4738,6 +4738,11 @@ stable accessible label and route unconfigured installs into the canonical
provider setup modal instead of falling back to generic "first pressed toggle"
selectors, provider-model-load heuristics, legacy hosted-model enablement, or
in-app trial acquisition.
+A settings toggle disabled by a plan or runtime lock must explain the lock in
+the card that owns it, not through the shared `Toggle` `locked` and
+`lockedMessage` props, which the primitive does not render. `AISettings.tsx`
+renders the Container update risk lock from the canonical Patrol availability
+presentation and links that explanation to the switch with `ariaDescribedBy`.
That same route-owned presentation rule also governs Patrol findings empty
states: shared section shells under `frontend-modern/src/features/patrol/`
must not render a green healthy empty state from `0 active findings` alone
diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md
index dfbf50ed95..e04084427b 100644
--- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md
+++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md
@@ -725,6 +725,16 @@ attention`, `approval needed`, `outcome verified`, `no active work`) instead
sentence across the header and mode control, repeatedly restate
infrastructure-unchanged caveats in the default view, or imply Patrol's main
value is a manual review chore.
+ Plan-gated Patrol capabilities share that one availability presentation:
+ `patrolAutonomyAvailability.ts` owns the precedence (runtime lock, then
+ hidden commercial surfaces, then plan lock) and the only action labels
+ (`Open Pro downloads`, `Plans & Billing`), while each capability supplies
+ its lock state and copy. The Patrol settings Container update risk trigger
+ (`ai_alerts`) uses `getPatrolAlertAnalysisAvailabilityPresentation`, so a
+ locked install sees why the trigger is unavailable beside the disabled
+ switch: a higher license plan, a missing Pulse Pro runtime, or, when
+ commercial surfaces are hidden, a neutral not-available line with no plan
+ or Pro wording. Actions appear only when upgrade prompts are allowed.
5. Add or change Patrol header schedule and runtime presentation through `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx`, `frontend-modern/src/utils/aiPatrolSchedulePresentation.ts`, and `frontend-modern/src/utils/patrolRuntimePresentation.ts`.
Patrol must not surface retired hosted-model credit badges or trial-like activation prompts in the normal self-hosted GA app, even when legacy transport fields are still present.
6. Keep Patrol and chat identifier-label presentation aligned through the shared `frontend-modern/src/utils/textPresentation.ts`
diff --git a/frontend-modern/browser-verification/b254486b940deb2e.json b/frontend-modern/browser-verification/b254486b940deb2e.json
new file mode 100644
index 0000000000..f4f8ebe47f
--- /dev/null
+++ b/frontend-modern/browser-verification/b254486b940deb2e.json
@@ -0,0 +1,42 @@
+{
+ "version": 1,
+ "base_sha": "1dcf9418aae87f9427dbfc6bcae67dbde114bf8a",
+ "verified_at": "2026-10-05T21:16:11Z",
+ "result": "passed",
+ "changed_paths": [
+ "frontend-modern/src/components/Settings/AISettings.tsx",
+ "frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts"
+ ],
+ "content_sha256": {
+ "frontend-modern/src/components/Settings/AISettings.tsx": "d3966224e497b9f8f3cd701bea1661212c5f32c9cc15ff795c4001f851fae6ca",
+ "frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts": "240fe0f1988d881da4344811aa3fdcc8e452182da24414ca6c941ea72e8e545f"
+ },
+ "routes": [
+ "/settings/pulse-intelligence/patrol",
+ "/settings/pulse-intelligence/billing/plan"
+ ],
+ "viewports": [
+ {
+ "width": 1280,
+ "height": 900
+ },
+ {
+ "width": 390,
+ "height": 844
+ }
+ ],
+ "states": [
+ "Private stacks built from this tree: community runtime (7782/5282), community DEMO_MODE runtime (7783/5283) and a Pro runtime built from pulse-enterprise against this tree (7784/5284), mock data, dev-signed Pro and Enterprise licences. Playwright Chromium at 1280x900 dark and 390x844 light, plus the in-app browser pane.",
+ "Free community install (no licence, hideUpgrade true, hideCommercial false, ai_alerts absent, /api/settings/ai alert_triggered_analysis false via a real persisted save): switch disabled and off, card reads 'Higher license plan required. This install's plan does not include container update risk.' with no link; the switch's aria-describedby points at that line.",
+ "Unlicensed Pro runtime (prompts allowed, ai_alerts not granted, backend licence checker masks the setting to false): same plan-lock line plus a Plans & Billing link to /settings/pulse-intelligence/billing/plan.",
+ "Pro licence on the community runtime (ai_alerts blocked as paid_runtime_required): 'Pulse Pro runtime required. This install is running Pulse Community runtime. Install the Pulse Pro runtime to use container update risk.' plus Open Pro downloads (https://pulserelay.pro/download.html, new tab, noopener).",
+ "Hidden commercial surfaces via DEMO_MODE (hideCommercial and hideUpgrade true; read-only demo, so GET /api/settings/ai intercepted to alert_triggered_analysis false): 'Not available. This install does not include container update risk.' with no plan or Pro wording and no link.",
+ "Enterprise licence on the community runtime (white-label, hideCommercial true, ai_alerts runtime-blocked): runtime lock copy without the download link, which is the canonical runtime-before-commercial precedence shared with Patrol mode.",
+ "Pro licence on the Pro runtime: switch enabled, no lock line, no aria-describedby. No horizontal overflow and no page errors in any state; the disabled track styling is the pre-#2535 shared Toggle and is unchanged here."
+ ],
+ "interactions": [
+ "Clicked the Plans & Billing link from the locked card in the browser pane; it routed in-app to /settings/pulse-intelligence/billing/plan.",
+ "On the licensed Pro runtime, turned the container update risk switch on and saved Patrol settings; GET /api/settings/ai then returned alert_triggered_analysis true.",
+ "Switched licence state live (activate Pro, activate Enterprise, clear) against the same backend and reloaded the page for each state."
+ ]
+}
diff --git a/frontend-modern/src/components/Settings/AISettings.tsx b/frontend-modern/src/components/Settings/AISettings.tsx
index 931653fdec..a6f5c75a1e 100644
--- a/frontend-modern/src/components/Settings/AISettings.tsx
+++ b/frontend-modern/src/components/Settings/AISettings.tsx
@@ -1,4 +1,4 @@
-import { Component, For, createMemo, Show } from 'solid-js';
+import { Component, For, createMemo, createUniqueId, Show } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { AIChatMaintenanceSection } from '@/components/Settings/AIChatMaintenanceSection';
import { AISettingsDialogs } from '@/components/Settings/AISettingsDialogs';
@@ -18,8 +18,13 @@ import { FormSelect } from '@/components/shared/FormSelect';
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
import SettingsPanel from '@/components/shared/SettingsPanel';
import { Toggle } from '@/components/shared/Toggle';
+import { UpgradeLink } from '@/components/shared/UpgradeLink';
import { PATROL_PATH } from '@/routing/resourceLinks';
-import { getPatrolAutonomyAvailabilityPresentation } from '@/features/patrol/patrolAutonomyAvailability';
+import {
+ PATROL_ALERT_ANALYSIS_FEATURE_KEY,
+ getPatrolAlertAnalysisAvailabilityPresentation,
+ getPatrolAutonomyAvailabilityPresentation,
+} from '@/features/patrol/patrolAutonomyAvailability';
import { getRuntimeCapabilityBlock, hasFeature, runtimeCapabilities } from '@/stores/license';
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
import {
@@ -115,6 +120,17 @@ const PatrolSettingsContent: Component<{ state: ReturnType
+
+ {alertAnalysisAvailability().title}.
+ {' '}
+ {alertAnalysisAvailability().body}
+
', + ); + expect(aiSettingsSource).toContain( + 'alertAnalysisAvailability().locked ? alertAnalysisLockId : undefined', + ); + // The shared Toggle declares locked/lockedMessage but never renders them, + // so the card must carry the explanation itself. + expect(aiSettingsSource).not.toContain('lockedMessage='); + expect(aiSettingsSource).not.toContain('Requires Pulse Pro'); + }); + it('keeps system AI save feedback tied to provider and Patrol readiness context', () => { expect(aiSettingsSource).toContain('Choose a Patrol mode on the Patrol page.'); expect(aiSettingsSource).toContain('Open Patrol'); diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts index d3e17b1700..c46e349e8f 100644 --- a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts @@ -8,6 +8,7 @@ import { getPatrolConfigurationFailureInlineDetails, } from '../PatrolIntelligenceHeader'; import { + getPatrolAlertAnalysisAvailabilityPresentation, getPatrolAutonomyAvailabilityPresentation, PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON, } from '../patrolAutonomyAvailability'; @@ -312,6 +313,43 @@ describe('PatrolIntelligenceHeader', () => { }); }); + it('explains the container update risk lock through the same presentation policy', () => { + const planUpgradeDestination = { + href: '/settings/pulse-intelligence/billing/plan', + external: false, + }; + expect( + getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: true, + planUpgradeDestination, + }), + ).toMatchObject({ + kind: 'plan_locked', + title: 'Higher license plan required', + actionLabel: 'Plans & Billing', + destination: planUpgradeDestination, + }); + expect( + getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: true, + upgradePromptsHidden: true, + planUpgradeDestination, + }), + ).not.toHaveProperty('actionLabel'); + expect( + getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: true, + commercialSurfacesHidden: true, + planUpgradeDestination, + }), + ).toEqual({ + kind: 'plan_locked', + locked: true, + title: 'Not available', + body: 'This install does not include container update risk.', + }); + }); + it('keeps trust counters out of the page header chrome', () => { // Needs Attention and deliberate history review own trust // state. The header should stay focused on title, recency, and controls. diff --git a/frontend-modern/src/features/patrol/__tests__/patrolAutonomyAvailability.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolAutonomyAvailability.test.ts index 100d81e519..c8a6f090ed 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolAutonomyAvailability.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolAutonomyAvailability.test.ts @@ -3,8 +3,10 @@ import { describe, expect, it } from 'vitest'; import type { LicenseRuntimeCapabilityBlock, LicenseRuntimeIdentity } from '@/api/license'; import { + PATROL_ALERT_ANALYSIS_FEATURE_KEY, PATROL_AUTONOMY_FEATURE_KEY, PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON, + getPatrolAlertAnalysisAvailabilityPresentation, getPatrolAutonomyAvailabilityPresentation, } from '../patrolAutonomyAvailability'; import type { UpgradeDestination } from '@/utils/upgradeNavigation'; @@ -53,6 +55,10 @@ describe('patrolAutonomyAvailability', () => { expect(PATROL_AUTONOMY_FEATURE_KEY).toBe('ai_autofix'); }); + it('exposes the ai_alerts feature key', () => { + expect(PATROL_ALERT_ANALYSIS_FEATURE_KEY).toBe('ai_alerts'); + }); + it('exposes the paid_runtime_required reason', () => { expect(PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON).toBe('paid_runtime_required'); }); @@ -319,4 +325,118 @@ describe('patrolAutonomyAvailability', () => { }); }); }); + + describe('getPatrolAlertAnalysisAvailabilityPresentation', () => { + const alertRuntimeBlock = runtimeBlock({ key: PATROL_ALERT_ANALYSIS_FEATURE_KEY }); + + it('is available when alert analysis is not locked, ignoring every other field', () => { + expect( + getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: false, + runtimeCapabilityBlock: alertRuntimeBlock, + commercialSurfacesHidden: true, + planUpgradeDestination: planDestination, + }), + ).toEqual({ + kind: 'available', + locked: false, + title: 'Container update risk available', + body: 'Assess risk when container-update alerts fire.', + }); + }); + + it('explains a plan lock and offers Plans & Billing when surfaces are visible', () => { + expect( + getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: true, + planUpgradeDestination: planDestination, + }), + ).toEqual({ + kind: 'plan_locked', + locked: true, + title: 'Higher license plan required', + body: "This install's plan does not include container update risk.", + actionLabel: 'Plans & Billing', + destination: planDestination, + }); + }); + + it('keeps the plan explanation but drops the action when upgrade prompts are hidden', () => { + const result = getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: true, + upgradePromptsHidden: true, + planUpgradeDestination: planDestination, + }); + + expect(result).toEqual({ + kind: 'plan_locked', + locked: true, + title: 'Higher license plan required', + body: "This install's plan does not include container update risk.", + }); + expect(result).not.toHaveProperty('actionLabel'); + expect(result).not.toHaveProperty('destination'); + }); + + it('uses neutral copy with no action when commercial surfaces are hidden', () => { + const result = getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: true, + commercialSurfacesHidden: true, + planUpgradeDestination: planDestination, + }); + + expect(result).toEqual({ + kind: 'plan_locked', + locked: true, + title: 'Not available', + body: 'This install does not include container update risk.', + }); + expect(`${result.title} ${result.body}`).not.toMatch(/plan|Pro\b/i); + }); + + it('shares the canonical runtime lock with Patrol mode', () => { + const input = { + runtimeCapabilityBlock: runtimeBlock({ + key: PATROL_ALERT_ANALYSIS_FEATURE_KEY, + action_url: 'https://pro.example/downloads', + }), + runtime: runtime({ label: 'Pulse Community runtime' }), + planUpgradeDestination: planDestination, + }; + const alertAnalysis = getPatrolAlertAnalysisAvailabilityPresentation({ + ...input, + alertAnalysisLocked: true, + }); + const patrolMode = getPatrolAutonomyAvailabilityPresentation({ + ...input, + autoFixLocked: true, + }); + + expect(alertAnalysis).toEqual({ + kind: 'runtime_locked', + locked: true, + title: 'Pulse Pro runtime required', + body: 'This install is running Pulse Community runtime. Install the Pulse Pro runtime to use container update risk.', + actionLabel: 'Open Pro downloads', + destination: externalDestination('https://pro.example/downloads'), + }); + expect(alertAnalysis.title).toBe(patrolMode.title); + expect(alertAnalysis.actionLabel).toBe(patrolMode.actionLabel); + expect(alertAnalysis.destination).toEqual(patrolMode.destination); + }); + + it('omits the runtime download action when upgrade prompts are hidden', () => { + const result = getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: true, + upgradePromptsHidden: true, + runtimeCapabilityBlock: alertRuntimeBlock, + runtime: runtime(), + planUpgradeDestination: planDestination, + }); + + expect(result.kind).toBe('runtime_locked'); + expect(result).not.toHaveProperty('actionLabel'); + expect(result).not.toHaveProperty('destination'); + }); + }); }); diff --git a/frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts b/frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts index f23791bd33..c683fc9720 100644 --- a/frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts +++ b/frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts @@ -3,12 +3,16 @@ import type { UpgradeDestination } from '@/utils/upgradeNavigation'; import { resolveUpgradeDestination } from '@/utils/upgradeNavigation'; export const PATROL_AUTONOMY_FEATURE_KEY = 'ai_autofix'; +export const PATROL_ALERT_ANALYSIS_FEATURE_KEY = 'ai_alerts'; export const PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON = 'paid_runtime_required'; -export type PatrolAutonomyAvailabilityKind = 'available' | 'plan_locked' | 'runtime_locked'; +export type PatrolFeatureAvailabilityKind = 'available' | 'plan_locked' | 'runtime_locked'; -export interface PatrolAutonomyAvailabilityInput { - autoFixLocked: boolean; +// Presentation policy shared by every plan-gated Patrol capability. Each +// capability supplies only its lock state and its own copy; the precedence +// (runtime lock, then hidden commercial surfaces, then plan lock) and the +// action labels stay canonical here. +interface PatrolFeatureAvailabilityPolicy { upgradePromptsHidden?: boolean; commercialSurfacesHidden?: boolean; runtimeCapabilityBlock?: LicenseRuntimeCapabilityBlock; @@ -16,8 +20,16 @@ export interface PatrolAutonomyAvailabilityInput { planUpgradeDestination: UpgradeDestination; } -export interface PatrolAutonomyAvailabilityPresentation { - kind: PatrolAutonomyAvailabilityKind; +export interface PatrolAutonomyAvailabilityInput extends PatrolFeatureAvailabilityPolicy { + autoFixLocked: boolean; +} + +export interface PatrolAlertAnalysisAvailabilityInput extends PatrolFeatureAvailabilityPolicy { + alertAnalysisLocked: boolean; +} + +export interface PatrolFeatureAvailabilityPresentation { + kind: PatrolFeatureAvailabilityKind; locked: boolean; title: string; body: string; @@ -25,6 +37,44 @@ export interface PatrolAutonomyAvailabilityPresentation { destination?: UpgradeDestination; } +interface PatrolFeatureAvailabilityCopy { + available: { title: string; body: string }; + planLocked: { title: string; body: string }; + // Plan-locked copy for sessions that hide commercial surfaces. Defaults to + // planLocked, so it is only needed when that copy mentions plans. + planLockedCommercialHidden?: { title: string; body: string }; + // Completes "Install the Pulse Pro runtime to use ...". + runtimeFeatureLabel: string; +} + +const PATROL_AUTONOMY_COPY: PatrolFeatureAvailabilityCopy = { + available: { + title: 'Patrol mode available', + body: 'Choose the mode for this install.', + }, + planLocked: { + title: 'Watch only', + body: 'This install watches infrastructure and shows issues.', + }, + runtimeFeatureLabel: 'Patrol modes', +}; + +const PATROL_ALERT_ANALYSIS_COPY: PatrolFeatureAvailabilityCopy = { + available: { + title: 'Container update risk available', + body: 'Assess risk when container-update alerts fire.', + }, + planLocked: { + title: 'Higher license plan required', + body: "This install's plan does not include container update risk.", + }, + planLockedCommercialHidden: { + title: 'Not available', + body: 'This install does not include container update risk.', + }, + runtimeFeatureLabel: 'container update risk', +}; + function getRuntimeDownloadDestination( block: LicenseRuntimeCapabilityBlock | undefined, runtime: LicenseRuntimeIdentity | undefined, @@ -37,15 +87,16 @@ function getRuntimeLabel(runtime: LicenseRuntimeIdentity | undefined): string { return runtime?.label?.trim() || 'this runtime'; } -export function getPatrolAutonomyAvailabilityPresentation( - input: PatrolAutonomyAvailabilityInput, -): PatrolAutonomyAvailabilityPresentation { - if (!input.autoFixLocked) { +function getPatrolFeatureAvailabilityPresentation( + locked: boolean, + input: PatrolFeatureAvailabilityPolicy, + copy: PatrolFeatureAvailabilityCopy, +): PatrolFeatureAvailabilityPresentation { + if (!locked) { return { kind: 'available', locked: false, - title: 'Patrol mode available', - body: 'Choose the mode for this install.', + ...copy.available, }; } @@ -54,7 +105,7 @@ export function getPatrolAutonomyAvailabilityPresentation( kind: 'runtime_locked', locked: true, title: 'Pulse Pro runtime required', - body: `This install is running ${getRuntimeLabel(input.runtime)}. Install the Pulse Pro runtime to use Patrol modes.`, + body: `This install is running ${getRuntimeLabel(input.runtime)}. Install the Pulse Pro runtime to use ${copy.runtimeFeatureLabel}.`, ...(input.upgradePromptsHidden ? {} : { @@ -70,16 +121,14 @@ export function getPatrolAutonomyAvailabilityPresentation( return { kind: 'plan_locked', locked: true, - title: 'Watch only', - body: 'This install watches infrastructure and shows issues.', + ...(copy.planLockedCommercialHidden ?? copy.planLocked), }; } return { kind: 'plan_locked', locked: true, - title: 'Watch only', - body: 'This install watches infrastructure and shows issues.', + ...copy.planLocked, ...(input.upgradePromptsHidden ? {} : { @@ -88,3 +137,19 @@ export function getPatrolAutonomyAvailabilityPresentation( }), }; } + +export function getPatrolAutonomyAvailabilityPresentation( + input: PatrolAutonomyAvailabilityInput, +): PatrolFeatureAvailabilityPresentation { + return getPatrolFeatureAvailabilityPresentation(input.autoFixLocked, input, PATROL_AUTONOMY_COPY); +} + +export function getPatrolAlertAnalysisAvailabilityPresentation( + input: PatrolAlertAnalysisAvailabilityInput, +): PatrolFeatureAvailabilityPresentation { + return getPatrolFeatureAvailabilityPresentation( + input.alertAnalysisLocked, + input, + PATROL_ALERT_ANALYSIS_COPY, + ); +}