diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md
index 8675e93723..00a037e557 100644
--- a/docs/release-control/v6/internal/subsystems/api-contracts.md
+++ b/docs/release-control/v6/internal/subsystems/api-contracts.md
@@ -3187,6 +3187,14 @@ 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. A `paid_runtime_required` block is presentation input only:
+ when the security-status presentation policy hides commercial surfaces,
+ Patrol mode and container update risk both render their neutral lock and
+ the browser must not surface the block's Pro download URL
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 38a433deaf..44938d4c99 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -4738,6 +4738,16 @@ 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`.
+The shared Patrol feature shells (`PatrolIntelligenceHeader.tsx` and
+`PatrolIntelligenceBanners.tsx`) and that card pass the session's real
+commercial and upgrade presentation policy into the presentation, never a
+constant, so a session that hides commercial surfaces gets the neutral lock
+line and never Pulse Pro runtime wording or a download link.
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..383727f107 100644
--- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md
+++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md
@@ -725,6 +725,21 @@ 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 (hidden commercial
+ surfaces, then runtime lock, then plan lock) and the only action labels
+ (`Open Pro downloads`, `Plans & Billing`), while each capability supplies
+ its lock state and copy. Hidden commercial surfaces win over the runtime
+ lock, so demo and white-label sessions never see Pulse Pro runtime wording,
+ and every caller (the Patrol header, the Patrol banners and Patrol
+ settings) passes the session's real `presentationPolicyHidesCommercialSurfaces()`
+ and `presentationPolicyHidesUpgradePrompts()` values instead of a constant.
+ 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/abaabdb1cd2cfda1.json b/frontend-modern/browser-verification/abaabdb1cd2cfda1.json
new file mode 100644
index 0000000000..41be908ee6
--- /dev/null
+++ b/frontend-modern/browser-verification/abaabdb1cd2cfda1.json
@@ -0,0 +1,42 @@
+{
+ "version": 1,
+ "base_sha": "357b1a505e80bb1cafbfc9cb02f36f96182c7b57",
+ "verified_at": "2026-10-05T21:37:58Z",
+ "result": "passed",
+ "changed_paths": [
+ "frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx",
+ "frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx",
+ "frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts"
+ ],
+ "content_sha256": {
+ "frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx": "89bce4990f82104e8724e4e099a3ae06a275cf1a2d5ab875670f9bc3b4cefcff",
+ "frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx": "6b899aaebeba642fd5bb792146f27302d29542a4f4ced548f71168398101faf3",
+ "frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts": "3d334beb4135c9c53027b79debd869c403db627f207bfb54ab004d5d191f99b0"
+ },
+ "routes": [
+ "/settings/pulse-intelligence/patrol",
+ "/patrol"
+ ],
+ "viewports": [
+ {
+ "width": 1280,
+ "height": 900
+ },
+ {
+ "width": 390,
+ "height": 844
+ }
+ ],
+ "states": [
+ "Private community-runtime stack built from this tree (backend 7782, Vite 5282 serving this checkout), mock data, dev-signed licences switched live on the same backend. Playwright Chromium at 1280x900 dark and 390x844 light.",
+ "Enterprise licence on the community runtime (white-label: hideCommercial and hideUpgrade true; ai_alerts and ai_autofix blocked as paid_runtime_required): the Container update risk card reads 'Not available. This install does not include container update risk.' with no link, and the Patrol header shows the plain Watch only line with no Mode and automation disclosure, no paid-mode picker, no Pulse Pro runtime wording and no download link.",
+ "Same stack and licence with the previous patrolAutonomyAvailability.ts, PatrolIntelligenceHeader.tsx and PatrolIntelligenceBanners.tsx swapped back in under the running Vite: the card showed 'Pulse Pro runtime required...' and the expanded Mode and automation disclosure showed 'Install the Pulse Pro runtime to use Patrol modes' beside a disabled paid-mode picker. Restored afterwards.",
+ "Pro licence on the community runtime (commercial and upgrade prompts visible): unchanged. Card shows the runtime lock with Open Pro downloads; the Patrol page still shows the runtime prompt and the https://pulserelay.pro/download.html link with the mode picker.",
+ "Free community install (licence cleared; hideUpgrade true, hideCommercial false): unchanged. Card shows the higher-license-plan line with no link; Patrol header shows plain Watch only.",
+ "No horizontal overflow and no page errors in any state."
+ ],
+ "interactions": [
+ "Expanded the Patrol header 'Mode and automation' disclosure where present to read the runtime note and mode picker.",
+ "Activated an Enterprise licence, a Pro licence and cleared the licence on the same backend, reloading both routes for each state."
+ ]
+}
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/PatrolIntelligenceBanners.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx
index cacd6f23c0..059d311f05 100644
--- a/frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx
+++ b/frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx
@@ -6,7 +6,10 @@ import SettingsIcon from 'lucide-solid/icons/settings';
import { AnimatedNumber } from '@/components/shared/AnimatedNumber';
import { UpgradeButtonLink } from '@/components/shared/UpgradeLink';
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
-import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
+import {
+ presentationPolicyHidesCommercialSurfaces,
+ presentationPolicyHidesUpgradePrompts,
+} from '@/stores/sessionPresentationPolicy';
import { formatRelativeTime } from '@/utils/format';
import { getAIProviderDisplayName } from '@/utils/aiProviderPresentation';
import { getPatrolSetupAction } from '@/utils/patrolRuntimeActions';
@@ -35,6 +38,8 @@ export function PatrolIntelligenceBanners(props: { state: PatrolIntelligenceStat
const autonomyAvailability = createMemo(() =>
getPatrolAutonomyAvailabilityPresentation({
autoFixLocked: state.autoFixLocked(),
+ commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(),
+ upgradePromptsHidden: presentationPolicyHidesUpgradePrompts(),
runtimeCapabilityBlock: state.autoFixCapabilityBlock(),
runtime: state.licenseRuntimeIdentity(),
planUpgradeDestination: getUpgradeActionDestination('ai_autofix'),
diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx
index 7fc77c485e..490cdf1ab6 100644
--- a/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx
+++ b/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx
@@ -13,7 +13,10 @@ import { UpgradeButtonLink } from '@/components/shared/UpgradeLink';
import type { PatrolAutonomyLevel } from '@/api/patrol';
import { settingsTabPath } from '@/components/Settings/settingsNavigationModel';
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
-import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
+import {
+ presentationPolicyHidesCommercialSurfaces,
+ presentationPolicyHidesUpgradePrompts,
+} from '@/stores/sessionPresentationPolicy';
import { formatRelativeTime } from '@/utils/format';
import { getPatrolPageHeaderMeta } from '@/utils/patrolPagePresentation';
import { getPatrolTriggerStatusSummary } from '@/utils/patrolRunPresentation';
@@ -146,7 +149,7 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
getPatrolAutonomyAvailabilityPresentation({
autoFixLocked: state.autoFixLocked(),
upgradePromptsHidden: upgradePromptsHidden(),
- commercialSurfacesHidden: true,
+ commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(),
runtimeCapabilityBlock: state.autoFixCapabilityBlock(),
runtime: state.licenseRuntimeIdentity(),
planUpgradeDestination: getUpgradeActionDestination('ai_autofix'),
diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts
index d3e17b1700..81e50c2cc1 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';
@@ -250,7 +251,10 @@ describe('PatrolIntelligenceHeader', () => {
external: false,
},
});
- expect(headerSource).toContain('commercialSurfacesHidden: true');
+ expect(headerSource).toContain(
+ 'commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(),',
+ );
+ expect(headerSource).not.toContain('commercialSurfacesHidden: true');
expect(headerSource).not.toContain('showAutonomyPlanBillingAction');
expect(headerSource).not.toContain('CreditCardIcon');
expect(headerSource).not.toContain('Plans & Billing');
@@ -312,6 +316,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..7d7d99ddc9 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');
});
@@ -87,7 +93,7 @@ describe('patrolAutonomyAvailability', () => {
it.each([
[
- 'runtime_locked beats commercialSurfacesHidden',
+ 'commercialSurfacesHidden beats runtime_locked',
{
autoFixLocked: true,
runtimeCapabilityBlock: runtimeBlock(),
@@ -96,6 +102,17 @@ describe('patrolAutonomyAvailability', () => {
upgradePromptsHidden: true,
planUpgradeDestination: planDestination,
},
+ 'plan_locked',
+ ],
+ [
+ 'runtime_locked when commercial surfaces are visible',
+ {
+ autoFixLocked: true,
+ runtimeCapabilityBlock: runtimeBlock(),
+ runtime: runtime(),
+ upgradePromptsHidden: true,
+ planUpgradeDestination: planDestination,
+ },
'runtime_locked',
],
[
@@ -319,4 +336,152 @@ 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('keeps Pro runtime wording out of hidden-commercial sessions for both capabilities', () => {
+ const input = {
+ commercialSurfacesHidden: true,
+ upgradePromptsHidden: true,
+ runtimeCapabilityBlock: runtimeBlock({ 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: 'plan_locked',
+ locked: true,
+ title: 'Not available',
+ body: 'This install does not include container update risk.',
+ });
+ expect(patrolMode).toEqual({
+ kind: 'plan_locked',
+ locked: true,
+ title: 'Watch only',
+ body: 'This install watches infrastructure and shows issues.',
+ });
+ for (const result of [alertAnalysis, patrolMode]) {
+ expect(`${result.title} ${result.body}`).not.toMatch(/\bPro\b|runtime|plan/i);
+ }
+ });
+
+ 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/__tests__/patrolCommercialBoundary.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolCommercialBoundary.test.ts
index 7e56c8fcd9..3bf59b5561 100644
--- a/frontend-modern/src/features/patrol/__tests__/patrolCommercialBoundary.test.ts
+++ b/frontend-modern/src/features/patrol/__tests__/patrolCommercialBoundary.test.ts
@@ -16,7 +16,13 @@ describe('patrol commercial boundary', () => {
expect(patrolIntelligenceHeaderSource).toContain(
"autonomyAvailability().kind === 'runtime_locked'",
);
- expect(patrolIntelligenceHeaderSource).toContain('commercialSurfacesHidden: true');
+ expect(patrolIntelligenceHeaderSource).toContain(
+ 'commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(),',
+ );
+ expect(patrolIntelligenceHeaderSource).not.toContain('commercialSurfacesHidden: true');
+ expect(patrolIntelligenceBannersSource).toContain(
+ 'commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(),',
+ );
expect(patrolIntelligenceHeaderSource).not.toContain('showAutonomyPlanBillingAction');
expect(patrolAutonomyAvailabilitySource).toContain('Plans & Billing');
expect(patrolAutonomyAvailabilitySource).toContain('input.upgradePromptsHidden');
diff --git a/frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts b/frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts
index f23791bd33..36aa0ce346 100644
--- a/frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts
+++ b/frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts
@@ -3,12 +3,17 @@ 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
+// (hidden commercial surfaces, then runtime lock, then plan lock) and the
+// action labels stay canonical here. Hidden commercial surfaces win over the
+// runtime lock so demo and white-label sessions never see Pro wording.
+interface PatrolFeatureAvailabilityPolicy {
upgradePromptsHidden?: boolean;
commercialSurfacesHidden?: boolean;
runtimeCapabilityBlock?: LicenseRuntimeCapabilityBlock;
@@ -16,8 +21,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 +38,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 +88,24 @@ 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,
+ };
+ }
+
+ if (input.commercialSurfacesHidden) {
+ return {
+ kind: 'plan_locked',
+ locked: true,
+ ...(copy.planLockedCommercialHidden ?? copy.planLocked),
};
}
@@ -54,7 +114,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
? {}
: {
@@ -66,20 +126,10 @@ export function getPatrolAutonomyAvailabilityPresentation(
};
}
- if (input.commercialSurfacesHidden) {
- return {
- kind: 'plan_locked',
- locked: true,
- title: 'Watch only',
- body: 'This install watches infrastructure and shows issues.',
- };
- }
-
return {
kind: 'plan_locked',
locked: true,
- title: 'Watch only',
- body: 'This install watches infrastructure and shows issues.',
+ ...copy.planLocked,
...(input.upgradePromptsHidden
? {}
: {
@@ -88,3 +138,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,
+ );
+}
diff --git a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx
index 41c9269c4b..e98f75d56a 100644
--- a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx
+++ b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx
@@ -1259,6 +1259,64 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.queryByRole('button', { name: /start free trial/i })).not.toBeInTheDocument();
});
+ describe('Patrol mode runtime lock', () => {
+ const useLicensedCommunityRuntime = () => {
+ getRuntimeCapabilityBlockMock.mockImplementation((feature: string) =>
+ feature === 'ai_autofix'
+ ? {
+ key: 'ai_autofix',
+ reason: 'paid_runtime_required',
+ action_url: 'https://pulserelay.pro/download.html',
+ }
+ : undefined,
+ );
+ runtimeCapabilitiesMock.mockReturnValue({
+ runtime: { build: 'community', label: 'Pulse Community runtime' },
+ capabilities: ['ai_intelligence'],
+ blocked_capabilities: [
+ { key: 'ai_autofix', reason: 'paid_runtime_required' },
+ { key: 'ai_alerts', reason: 'paid_runtime_required' },
+ ],
+ });
+ getPatrolStatusMock.mockResolvedValue(defaultPatrolStatus({ license_required: true }));
+ };
+
+ it('points a licensed community runtime at the Pro runtime download', async () => {
+ useLicensedCommunityRuntime();
+
+ render(() =>