diff --git a/docs/AI_AUTONOMY.md b/docs/AI_AUTONOMY.md
index 9a87c2e084..9385abbd13 100644
--- a/docs/AI_AUTONOMY.md
+++ b/docs/AI_AUTONOMY.md
@@ -28,11 +28,13 @@ Patrol mode sets how far Pulse can go when Patrol finds something that needs att
| **Safe auto-fix** | `assisted` | Yes | Yes | Execute automatically | Approval required | Pro / legacy Pro+ / Cloud |
| **Autopilot** | `full` | Yes | Yes | Execute automatically | Execute automatically | Pro / legacy Pro+ / Cloud |
-- **Watch only** (default): Patrol creates findings but takes no action. This is the Community and Relay baseline. Suitable for learning what Patrol detects before enabling investigation or fix execution.
+- **Watch only**: Patrol creates findings but takes no action. This is the Community and Relay baseline and the default without Pro. Suitable for learning what Patrol detects before enabling investigation or fix execution.
- **Ask first** (Pro and above): Patrol investigates findings and proposes fixes. All fixes queue for manual approval before execution.
- **Safe auto-fix** (Pro and above): Warning-level safe fix plans can execute automatically. Critical findings still require approval. This is the recommended starting point for most Pro and legacy Pro+ users who enable fix execution.
- **Autopilot** (Pro and above): Safe fix plans can execute without approval. Requires an explicit toggle and a Pro, legacy Pro+, or Cloud license. Recommended only for environments with thorough alert coverage.
+On Pro, an install that has never chosen a mode runs **Ask first**, so new warning and critical findings are investigated without an extra step. Until you choose, the Patrol page explains this and offers to keep Ask first or switch to Watch only; either choice is saved and the notice goes away. Investigations use your AI provider and count toward the 30-day budget. A mode you have saved, including Watch only, is never changed.
+
### Configuration
**UI:** Patrol → Patrol mode
diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
index 811bbb6cec..db19ba3cd2 100644
--- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
+++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
@@ -15,6 +15,12 @@
## Purpose
+### Pro Patrol default adds no agent authority
+
+The Patrol autonomy handler change only reports whether the Pro Ask first
+default applies (`default_mode_applied`). It adds no agent route, command or
+execution authority; Ask first still requires approval before any change.
+
### TrueNAS SCALE removal retires every owned middleware boot task before recovery erasure
TrueNAS SCALE has an independent activation owner: its Init/Shutdown bootstrap
diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md
index cbe2db5a78..219bba7b90 100644
--- a/docs/release-control/v6/internal/subsystems/ai-runtime.md
+++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md
@@ -25,6 +25,28 @@ that same result. Successful reads retain their content and execution provenance
## Purpose
+### Pro Patrol defaults to Ask first until the operator chooses
+
+`AIConfig.PatrolAutonomyLevel` stays empty until an operator saves a Patrol
+mode, and only explicit saves write it. `AIConfig.PatrolAutonomyLevelChosen`
+reports that, and `AIConfig.ResolvedPatrolAutonomyLevel(entitled)` returns
+`approval` (Ask first) for a never-chosen install that is entitled to
+investigation, otherwise the stored or monitor level.
+`Service.GetEffectivePatrolAutonomyLevel` applies the same default, but only on
+a positive licence answer from `Service.PatrolInvestigationLicensed`, which
+treats a missing licence checker as unlicensed. `HasLicenseFeature` stays
+fail-open for its existing callers; the default must not, because a startup
+window without a checker would otherwise start spending provider budget on an
+install that may not be Pro. Unlicensed and unknown installs stay Watch only, an
+explicit choice always wins, and readiness reads the same resolved level. The
+default sends new and re-detected warning and critical findings to
+investigation through the existing dispatch path and budget checker; it adds no
+execution authority, because Ask first still waits for approval before any
+change. Proofs: `internal/ai/service_patrol_default_test.go`,
+`internal/config/ai_config_test.go`
+(`TestResolvedPatrolAutonomyLevelDefaultsOnlyNeverChosenLicensedInstalls`) and
+`internal/api/patrol_autopilot_test.go` (`TestPatrolAutonomyReportsTheProDefault`).
+
### Guest memory remains qualified through tools and local observers
The existing guest get/list/topology tools and Assistant inventory context carry
diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md
index b8f7c07e23..70a8356b52 100644
--- a/docs/release-control/v6/internal/subsystems/api-contracts.md
+++ b/docs/release-control/v6/internal/subsystems/api-contracts.md
@@ -20,6 +20,18 @@
## Purpose
+### Patrol autonomy reports the Pro default
+
+`GET /api/ai/patrol/autonomy` adds `default_mode_applied`. It is true only when
+the install never saved a Patrol mode and holds a positive investigation
+licence; then `requested_autonomy_level` and `effective_autonomy_level` both
+read `approval`. Any save, including the monitor-only save, writes the level and
+the flag reads false from then on. Older clients ignore the field, and no other
+autonomy payload field changes meaning, and `getPatrolAutonomySettings` returns
+the flag unchanged. Proofs: `internal/api/patrol_autopilot_test.go`
+(`TestPatrolAutonomyReportsTheProDefault`) and
+`frontend-modern/src/api/__tests__/patrol.test.ts`.
+
### AI guest memory projections preserve uncertainty
Existing AI get/list/topology responses carry selected guest memory evidence:
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index f2326298ba..fc28f1bedf 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -20,6 +20,13 @@
## Purpose
+### Patrol header default-mode notice uses existing primitives
+
+The Pro default notice in `PatrolIntelligenceHeader.tsx` is a `role="status"`
+block with two plain buttons that call the existing autonomy save. It adds no
+shared primitive, badge or banner variant, and it wraps on phone widths with
+44px targets.
+
### Canonical VM History respects native filesystem power state (12 October 2026)
The canonical resource drawer uses `getWorkloadGuestDiskRead`, the same VM
diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md
index e5f74462aa..60d512edcf 100644
--- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md
+++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md
@@ -15,6 +15,24 @@
## Purpose
+### Pro default notice and the not-running header
+
+A Pro install that never chose a Patrol mode runs Ask first.
+`PatrolIntelligenceHeader.tsx` says so once, in
+`patrol-default-mode-notice`: Patrol investigates new warning and critical
+findings, proposes a fix, changes nothing without approval, and spends the
+operator's AI provider budget. Its two actions save `Ask first` or `Watch only`,
+and either save clears the notice. The notice renders only when
+`default_mode_applied` is true and the install is not plan-locked, so the free
+Patrol surface stays free of paid-capability copy. When Patrol is enabled but
+cannot run yet (the setup-only state), the header reads `Patrol is not running
+yet` instead of `Watching your infrastructure`, so the header no longer claims
+monitoring that is not happening. `usePatrolIntelligenceState.ts` accepts only an
+explicit `default_mode_applied: true`, so an older server never shows the notice.
+Proofs:
+`frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.render.test.tsx`
+and `frontend-modern/src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts`.
+
### Manual suppression reversal — issue #2528
Patrol Activity exposes the existing suppression collection independently of
diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md
index 4beab7d95b..60d372f95c 100644
--- a/docs/release-control/v6/internal/subsystems/storage-recovery.md
+++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md
@@ -27,6 +27,12 @@ legacy command-approval selection. `TestAssistantProjectedModeEchoPreservesLegac
uses actual encrypted GET/PUT persistence with and without the entitlement,
then checks explicit read-only opt-out and later opt-in remain possible.
+### Pro Patrol default adds no recovery execution
+
+The Patrol autonomy handler now reports the Pro Ask first default for installs
+that never chose a mode. Investigations propose fixes for approval; the change
+adds no recovery endpoint, execution route or recovery-point access.
+
### Assistant planning mode grants no recovery execution
The AI settings control projection describes read/query or plans for review in
diff --git a/frontend-modern/browser-verification/855a760ea737cd57.json b/frontend-modern/browser-verification/855a760ea737cd57.json
new file mode 100644
index 0000000000..860b53ca4b
--- /dev/null
+++ b/frontend-modern/browser-verification/855a760ea737cd57.json
@@ -0,0 +1,45 @@
+{
+ "version": 1,
+ "base_sha": "3c22e108bb5fb0af02abd6235be039d00cb2a2db",
+ "verified_at": "2026-10-12T19:49:30Z",
+ "result": "passed",
+ "changed_paths": [
+ "frontend-modern/src/api/patrol.ts",
+ "frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx",
+ "frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts"
+ ],
+ "content_sha256": {
+ "frontend-modern/src/api/patrol.ts": "f0dde1dcfbc330505b679161443b7b375faa9351225abe7247146a351e9ea75c",
+ "frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx": "de3ca97e0f2a74ac0ab6784b9049d17f32654397bb0171f2d7ec622878353abd",
+ "frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "de2730eda398094d9fc4b3020aaa42ddfa02f0e4d10819de4f11fb9fc96bfa9c"
+ },
+ "routes": [
+ "/patrol"
+ ],
+ "viewports": [
+ {
+ "width": 1280,
+ "height": 900
+ },
+ {
+ "width": 375,
+ "height": 812
+ }
+ ],
+ "states": [
+ "Pro licence active, Patrol mode never saved: header chip Ask first, patrol-default-mode-notice shown with both actions; API default_mode_applied true",
+ "Patrol enabled but no AI provider configured: header reads 'Patrol is not running yet', not 'Watching your infrastructure'",
+ "Free licence (backend dev grant, frontend plan-locked): no notice, chip Watch only",
+ "Phone width: notice wraps, buttons 44px tall, no horizontal overflow",
+ "After a save and reload: notice stays gone"
+ ],
+ "interactions": [
+ "Keep Ask first saves approval: notice disappears, API requested/effective approval, default_mode_applied false",
+ "Switch to Watch only saves monitor at phone width: notice disappears, chip Watch only, API requested/effective monitor, default_mode_applied false"
+ ],
+ "browser": {
+ "client": "Claude desktop browser pane",
+ "engine": "Chromium"
+ },
+ "scope": "Private non-mock darwin enterprise build of this change on :7681 with a dev Pro licence; PULSE_DEV grants backend features, so the release-build unlicensed backend path is covered by unit tests, not this pass."
+}
diff --git a/frontend-modern/public/docs/AI_AUTONOMY.md b/frontend-modern/public/docs/AI_AUTONOMY.md
index 9a87c2e084..9385abbd13 100644
--- a/frontend-modern/public/docs/AI_AUTONOMY.md
+++ b/frontend-modern/public/docs/AI_AUTONOMY.md
@@ -28,11 +28,13 @@ Patrol mode sets how far Pulse can go when Patrol finds something that needs att
| **Safe auto-fix** | `assisted` | Yes | Yes | Execute automatically | Approval required | Pro / legacy Pro+ / Cloud |
| **Autopilot** | `full` | Yes | Yes | Execute automatically | Execute automatically | Pro / legacy Pro+ / Cloud |
-- **Watch only** (default): Patrol creates findings but takes no action. This is the Community and Relay baseline. Suitable for learning what Patrol detects before enabling investigation or fix execution.
+- **Watch only**: Patrol creates findings but takes no action. This is the Community and Relay baseline and the default without Pro. Suitable for learning what Patrol detects before enabling investigation or fix execution.
- **Ask first** (Pro and above): Patrol investigates findings and proposes fixes. All fixes queue for manual approval before execution.
- **Safe auto-fix** (Pro and above): Warning-level safe fix plans can execute automatically. Critical findings still require approval. This is the recommended starting point for most Pro and legacy Pro+ users who enable fix execution.
- **Autopilot** (Pro and above): Safe fix plans can execute without approval. Requires an explicit toggle and a Pro, legacy Pro+, or Cloud license. Recommended only for environments with thorough alert coverage.
+On Pro, an install that has never chosen a mode runs **Ask first**, so new warning and critical findings are investigated without an extra step. Until you choose, the Patrol page explains this and offers to keep Ask first or switch to Watch only; either choice is saved and the notice goes away. Investigations use your AI provider and count toward the 30-day budget. A mode you have saved, including Watch only, is never changed.
+
### Configuration
**UI:** Patrol → Patrol mode
diff --git a/frontend-modern/src/api/__tests__/patrol.test.ts b/frontend-modern/src/api/__tests__/patrol.test.ts
index 4cd780eb17..72d9caa2c2 100644
--- a/frontend-modern/src/api/__tests__/patrol.test.ts
+++ b/frontend-modern/src/api/__tests__/patrol.test.ts
@@ -21,6 +21,7 @@ import {
createPatrolAutopilotAcknowledgement,
revokePatrolAutopilotAcknowledgement,
updatePatrolAutonomySettings,
+ getPatrolAutonomySettings,
getPatrolObjectives,
createPatrolObjective,
updatePatrolObjective,
@@ -116,6 +117,19 @@ describe('patrol api', () => {
);
});
+ it('reads Patrol mode settings with the Pro default flag unchanged', async () => {
+ apiFetchJSONMock.mockResolvedValueOnce({
+ autonomy_level: 'approval',
+ requested_autonomy_level: 'approval',
+ effective_autonomy_level: 'approval',
+ default_mode_applied: true,
+ } as any);
+ const settings = await getPatrolAutonomySettings();
+ expect(apiFetchJSONMock).toHaveBeenLastCalledWith('/api/ai/patrol/autonomy');
+ expect(settings.default_mode_applied).toBe(true);
+ expect(settings.effective_autonomy_level).toBe('approval');
+ });
+
it('uses server acknowledgement and activation endpoints for Autopilot', async () => {
await createPatrolAutopilotAcknowledgement('ack/one');
expect(apiFetchJSONMock).toHaveBeenLastCalledWith('/api/ai/patrol/autonomy/acknowledgements', {
diff --git a/frontend-modern/src/api/patrol.ts b/frontend-modern/src/api/patrol.ts
index b74686d107..8da0230fdd 100644
--- a/frontend-modern/src/api/patrol.ts
+++ b/frontend-modern/src/api/patrol.ts
@@ -251,6 +251,8 @@ export interface PatrolAutonomySettings {
autopilot_acknowledgement: PatrolAutopilotStatus;
investigation_budget: number; // Max turns per investigation (5-30)
investigation_timeout_sec: number; // Max seconds per investigation (60-600)
+ /** True when the install never chose a mode and Pro runs Ask first by default. */
+ default_mode_applied?: boolean;
}
export interface PatrolAutonomyUpdate {
diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx
index ea5c797a5c..97ea375921 100644
--- a/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx
+++ b/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx
@@ -322,7 +322,11 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
- {state.patrolEnabledLocal() ? 'Watching your infrastructure' : 'Patrol is off'}
+ {state.patrolEnabledLocal()
+ ? state.shouldShowPatrolSetupOnly()
+ ? 'Patrol is not running yet'
+ : 'Watching your infrastructure'
+ : 'Patrol is off'}
+ Pro investigates new warning and critical findings by default.
+
+
+ Patrol works out the cause and proposes a fix. Nothing changes without your
+ approval. Investigations use your AI provider and count toward your 30-day budget.
+
+
+
+
+
+
+
+
);
diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.render.test.tsx b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.render.test.tsx
index 55e183641d..67cb135894 100644
--- a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.render.test.tsx
+++ b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.render.test.tsx
@@ -21,6 +21,7 @@ interface HeaderStateOptions {
autoFixLocked?: boolean | (() => boolean);
setupOnly?: boolean;
autopilotDialogOpen?: boolean;
+ defaultModeApplied?: boolean;
}
const createState = (options: HeaderStateOptions = {}) => {
@@ -30,6 +31,7 @@ const createState = (options: HeaderStateOptions = {}) => {
autopilotStatus: () => null,
autonomyLevel: () => autonomyLevel,
requestedAutonomyLevel: () => autonomyLevel,
+ defaultModeApplied: () => options.defaultModeApplied ?? false,
autoFixLocked: () => {
const locked = options.autoFixLocked ?? false;
return typeof locked === 'function' ? locked() : locked;
@@ -234,3 +236,42 @@ describe('PatrolIntelligenceHeader Patrol mode anchors', () => {
expect(scrolled).toEqual([]);
});
});
+
+describe('PatrolIntelligenceHeader Pro default mode', () => {
+ afterEach(cleanup);
+
+ it('tells a Pro install it investigates by default and lets it keep or leave Ask first', () => {
+ const { state, handleAutonomyChange } = createState({ defaultModeApplied: true });
+ renderHeader(state);
+
+ const notice = screen.getByTestId('patrol-default-mode-notice');
+ expect(notice).toHaveTextContent(
+ 'Pro investigates new warning and critical findings by default.',
+ );
+ expect(notice).toHaveTextContent('Nothing changes without your approval.');
+ expect(notice).toHaveTextContent('count toward your 30-day budget');
+
+ fireEvent.click(within(notice).getByRole('button', { name: 'Keep Ask first' }));
+ expect(handleAutonomyChange).toHaveBeenLastCalledWith('approval');
+ fireEvent.click(within(notice).getByRole('button', { name: 'Switch to Watch only' }));
+ expect(handleAutonomyChange).toHaveBeenLastCalledWith('monitor');
+ });
+
+ it('shows no default notice once a mode was chosen or while Pro features are locked', () => {
+ const chosen = createState({ defaultModeApplied: false });
+ renderHeader(chosen.state);
+ expect(screen.queryByTestId('patrol-default-mode-notice')).toBeNull();
+ cleanup();
+
+ const locked = createState({ defaultModeApplied: true, autoFixLocked: true });
+ renderHeader(locked.state);
+ expect(screen.queryByTestId('patrol-default-mode-notice')).toBeNull();
+ });
+
+ it('does not claim Patrol is watching while it still needs setup', () => {
+ const { state } = createState({ setupOnly: true });
+ renderHeader(state);
+ expect(screen.getByText('Patrol is not running yet')).toBeInTheDocument();
+ expect(screen.queryByText('Watching your infrastructure')).toBeNull();
+ });
+});
diff --git a/frontend-modern/src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts b/frontend-modern/src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts
index e04ee3c2e5..a65ed5c200 100644
--- a/frontend-modern/src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts
+++ b/frontend-modern/src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts
@@ -89,6 +89,15 @@ describe('usePatrolIntelligenceState', () => {
);
});
+ it('shows the Pro default only when the server says it applies', () => {
+ // A missing or non-boolean flag from an older server must not show the
+ // notice, so the state owner accepts only an explicit true.
+ expect(patrolIntelligenceStateSource).toContain(
+ 'setDefaultModeApplied(settings.default_mode_applied === true);',
+ );
+ expect(patrolIntelligenceStateSource).toContain('defaultModeApplied,');
+ });
+
it('keeps browser/network start failures distinct from backend rejections', () => {
expect(patrolStartFailureMessage(new TypeError('Failed to fetch'))).toBe(
'Could not reach Pulse to start Patrol: Failed to fetch',
diff --git a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts
index d1571d0d5b..fab1cc1aa5 100644
--- a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts
+++ b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts
@@ -124,6 +124,7 @@ export function usePatrolIntelligenceState() {
const [requestedAutonomyLevel, setRequestedAutonomyLevel] =
createSignal('monitor');
const [autopilotStatus, setAutopilotStatus] = createSignal(null);
+ const [defaultModeApplied, setDefaultModeApplied] = createSignal(false);
const [autopilotDialogOpen, setAutopilotDialogOpen] = createSignal(false);
const [isUpdatingAutonomy, setIsUpdatingAutonomy] = createSignal(false);
const [activityRefreshTrigger, setActivityRefreshTrigger] = createSignal(0);
@@ -466,6 +467,7 @@ export function usePatrolIntelligenceState() {
const settings = await getPatrolAutonomySettings();
if (!settings) return;
setRequestedAutonomyLevel(settings.requested_autonomy_level);
+ setDefaultModeApplied(settings.default_mode_applied === true);
setAutonomyLevel(settings.effective_autonomy_level);
setAutopilotStatus(settings.autopilot_acknowledgement);
setInvestigationBudget(settings.investigation_budget);
@@ -739,6 +741,7 @@ export function usePatrolIntelligenceState() {
activeTab,
autonomyLevel,
requestedAutonomyLevel,
+ defaultModeApplied,
autopilotStatus,
autopilotDialogOpen,
acknowledgeAndActivateAutopilot,
diff --git a/internal/ai/patrol_readiness.go b/internal/ai/patrol_readiness.go
index 20d44ddf0c..f2e8e5af9b 100644
--- a/internal/ai/patrol_readiness.go
+++ b/internal/ai/patrol_readiness.go
@@ -215,7 +215,7 @@ func (s *Service) PatrolRuntimeReadiness() PatrolConfigReadiness {
return base
}
- selectedMode := cfg.GetPatrolAutonomyLevel()
+ selectedMode := cfg.ResolvedPatrolAutonomyLevel(s.PatrolInvestigationLicensed())
var suitability PatrolModeSuitability
switch selectedMode {
case config.PatrolAutonomyApproval:
diff --git a/internal/ai/service.go b/internal/ai/service.go
index db4b961a33..3cb2d32b6d 100644
--- a/internal/ai/service.go
+++ b/internal/ai/service.go
@@ -840,13 +840,24 @@ func (s *Service) HasLicenseFeature(feature string) bool {
return checker.HasFeature(feature)
}
+// PatrolInvestigationLicensed reports a positive licence answer for Patrol
+// investigation. Unlike HasLicenseFeature it treats a missing checker as not
+// licensed, so the Pro default mode never applies before the licence is known.
+func (s *Service) PatrolInvestigationLicensed() bool {
+ s.mu.RLock()
+ checker := s.licenseChecker
+ s.mu.RUnlock()
+ return checker != nil && checker.HasFeature(FeatureAIAutoFix)
+}
+
const minCommunityPatrolInterval = 1 * time.Hour
// GetEffectivePatrolAutonomyLevel returns the autonomy level that should be enforced at runtime
// for the current license state.
//
// Community tier is locked to "monitor" (findings-only). Higher levels imply investigation and
-// are gated behind ai_autofix (Pro/Cloud).
+// are gated behind ai_autofix (Pro/Cloud). A licensed install that never chose a mode runs
+// "approval" (Ask first), so the investigation it pays for is on by default.
func (s *Service) GetEffectivePatrolAutonomyLevel() string {
cfg := s.GetConfig()
if cfg == nil {
@@ -855,6 +866,12 @@ func (s *Service) GetEffectivePatrolAutonomyLevel() string {
if !s.HasLicenseFeature(FeatureAIAutoFix) {
return config.PatrolAutonomyMonitor
}
+ if !cfg.PatrolAutonomyLevelChosen() {
+ if s.PatrolInvestigationLicensed() {
+ return config.PatrolAutonomyApproval
+ }
+ return config.PatrolAutonomyMonitor
+ }
s.mu.RLock()
orgID := s.orgID
policyProvider := s.patrolAutopilotPolicy
diff --git a/internal/ai/service_patrol_default_test.go b/internal/ai/service_patrol_default_test.go
new file mode 100644
index 0000000000..0698e3ffc9
--- /dev/null
+++ b/internal/ai/service_patrol_default_test.go
@@ -0,0 +1,51 @@
+package ai
+
+import (
+ "testing"
+
+ "github.com/rcourtman/pulse-go-rewrite/internal/config"
+)
+
+// TestEffectivePatrolAutonomyDefaultsToAskFirstOnlyWhenLicensed pins the Pro
+// default: an install that never chose a Patrol mode runs Ask first only on a
+// positive investigation licence answer. A missing checker (licence not yet
+// known) and an unlicensed install stay in Watch only, and an explicit choice
+// always wins.
+func TestEffectivePatrolAutonomyDefaultsToAskFirstOnlyWhenLicensed(t *testing.T) {
+ cases := []struct {
+ name string
+ checker LicenseChecker
+ stored string
+ want string
+ }{
+ {name: "licensed, never chose", checker: &mockLicenseChecker{hasFeature: true}, stored: "", want: config.PatrolAutonomyApproval},
+ {name: "licensed, chose watch only", checker: &mockLicenseChecker{hasFeature: true}, stored: config.PatrolAutonomyMonitor, want: config.PatrolAutonomyMonitor},
+ {name: "licensed, chose ask first", checker: &mockLicenseChecker{hasFeature: true}, stored: config.PatrolAutonomyApproval, want: config.PatrolAutonomyApproval},
+ {name: "unlicensed, never chose", checker: &mockLicenseChecker{hasFeature: false}, stored: "", want: config.PatrolAutonomyMonitor},
+ {name: "unlicensed, chose ask first", checker: &mockLicenseChecker{hasFeature: false}, stored: config.PatrolAutonomyApproval, want: config.PatrolAutonomyMonitor},
+ {name: "licence not yet known, never chose", checker: nil, stored: "", want: config.PatrolAutonomyMonitor},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ svc := NewService(nil, nil)
+ svc.cfg = &config.AIConfig{PatrolAutonomyLevel: tc.stored}
+ if tc.checker != nil {
+ svc.SetLicenseChecker(tc.checker)
+ }
+ if got := svc.GetEffectivePatrolAutonomyLevel(); got != tc.want {
+ t.Fatalf("GetEffectivePatrolAutonomyLevel() = %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestPatrolInvestigationLicensedTreatsMissingCheckerAsUnlicensed(t *testing.T) {
+ svc := NewService(nil, nil)
+ if svc.PatrolInvestigationLicensed() {
+ t.Fatal("a missing licence checker must not count as a positive licence")
+ }
+ svc.SetLicenseChecker(&mockLicenseChecker{hasFeature: true})
+ if !svc.PatrolInvestigationLicensed() {
+ t.Fatal("a licensed checker must count as a positive licence")
+ }
+}
diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go
index 639286a37f..0ce8adf078 100644
--- a/internal/api/ai_handlers.go
+++ b/internal/api/ai_handlers.go
@@ -7780,6 +7780,9 @@ type PatrolAutonomyResponse struct {
AutopilotStatus unifiedresources.PatrolAutopilotStatus `json:"autopilot_acknowledgement"`
InvestigationBudget int `json:"investigation_budget"`
InvestigationTimeoutSec int `json:"investigation_timeout_sec"`
+ // DefaultModeApplied is true when the install never chose a Patrol mode
+ // and its licence includes investigation, so it runs Ask first by default.
+ DefaultModeApplied bool `json:"default_mode_applied"`
}
type patrolAutopilotAcknowledgementRequest struct {
@@ -7900,9 +7903,15 @@ func (h *AISettingsHandler) mutatePatrolAutopilotConfig(ctx context.Context, mut
return nil
}
-func patrolAutonomyResponseForConfig(cfg *config.AIConfig, orgID string, policy unifiedresources.PatrolAutopilotServerPolicy, hasAutoFix bool) PatrolAutonomyResponse {
- requested := cfg.GetPatrolAutonomyLevel()
+// patrolAutonomyResponseForConfig projects the stored settings. defaultApplied
+// is the runtime's own decision (never chosen and positively licensed), so the
+// response cannot disagree with the mode Patrol actually runs.
+func patrolAutonomyResponseForConfig(cfg *config.AIConfig, orgID string, policy unifiedresources.PatrolAutopilotServerPolicy, hasAutoFix, defaultApplied bool) PatrolAutonomyResponse {
+ requested := cfg.ResolvedPatrolAutonomyLevel(defaultApplied)
effective, status := cfg.GetEffectivePatrolAutonomyWithPolicy(orgID, policy)
+ if defaultApplied {
+ effective = config.PatrolAutonomyApproval
+ }
if !hasAutoFix {
effective = config.PatrolAutonomyMonitor
status.Active = false
@@ -7916,6 +7925,7 @@ func patrolAutonomyResponseForConfig(cfg *config.AIConfig, orgID string, policy
AutopilotStatus: status,
InvestigationBudget: cfg.GetPatrolInvestigationBudget(),
InvestigationTimeoutSec: int(cfg.GetPatrolInvestigationTimeout().Seconds()),
+ DefaultModeApplied: defaultApplied,
}
}
@@ -7938,7 +7948,8 @@ func (h *AISettingsHandler) HandleGetPatrolAutonomy(w http.ResponseWriter, r *ht
}
hasAutoFix := aiService.HasLicenseFeature(featureAIAutoFixValue)
- settings := patrolAutonomyResponseForConfig(cfg, GetOrgID(r.Context()), h.currentPatrolAutopilotServerPolicy(), hasAutoFix)
+ defaultApplied := !cfg.PatrolAutonomyLevelChosen() && aiService.PatrolInvestigationLicensed()
+ settings := patrolAutonomyResponseForConfig(cfg, GetOrgID(r.Context()), h.currentPatrolAutopilotServerPolicy(), hasAutoFix, defaultApplied)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(settings)
@@ -8191,7 +8202,8 @@ func (h *AISettingsHandler) HandleUpdatePatrolAutonomyMonitorOnly(w http.Respons
writeErrorResponse(w, http.StatusInternalServerError, "save_failed", "Failed to save Pulse Patrol autonomy settings", nil)
return
}
- settings := patrolAutonomyResponseForConfig(aiService.GetConfig(), GetOrgID(r.Context()), h.currentPatrolAutopilotServerPolicy(), aiService.HasLicenseFeature(featureAIAutoFixValue))
+ // The monitor-only save always stores an explicit choice, so no default applies.
+ settings := patrolAutonomyResponseForConfig(aiService.GetConfig(), GetOrgID(r.Context()), h.currentPatrolAutopilotServerPolicy(), aiService.HasLicenseFeature(featureAIAutoFixValue), false)
if err := utils.WriteJSONResponse(w, map[string]interface{}{
"success": true,
"settings": settings,
diff --git a/internal/api/patrol_autopilot_test.go b/internal/api/patrol_autopilot_test.go
index 8d91973ece..413c449573 100644
--- a/internal/api/patrol_autopilot_test.go
+++ b/internal/api/patrol_autopilot_test.go
@@ -448,3 +448,56 @@ func TestPatrolAutopilotStoreUnavailableDoesNotChangeModeOrFabricateEvidence(t *
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
+
+// TestPatrolAutonomyReportsTheProDefault pins the GET projection of the Pro
+// default: a licensed install that never chose a mode reports Ask first with
+// default_mode_applied, an explicit Watch only choice is reported as chosen,
+// and an unlicensed install stays in Watch only without the flag.
+func TestPatrolAutonomyReportsTheProDefault(t *testing.T) {
+ cases := []struct {
+ name string
+ stored string
+ licensed bool
+ wantLevel string
+ wantDefault bool
+ }{
+ {name: "licensed, never chose", stored: "", licensed: true, wantLevel: config.PatrolAutonomyApproval, wantDefault: true},
+ {name: "licensed, chose watch only", stored: config.PatrolAutonomyMonitor, licensed: true, wantLevel: config.PatrolAutonomyMonitor, wantDefault: false},
+ {name: "unlicensed, never chose", stored: "", licensed: false, wantLevel: config.PatrolAutonomyMonitor, wantDefault: false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ tmp := t.TempDir()
+ persistence := config.NewConfigPersistence(tmp)
+ cfg := config.NewDefaultAIConfig()
+ cfg.PatrolAutonomyLevel = tc.stored
+ if err := persistence.SaveAIConfig(*cfg); err != nil {
+ t.Fatalf("SaveAIConfig: %v", err)
+ }
+ handler := newTestAISettingsHandler(&config.Config{DataPath: tmp}, persistence, nil)
+ handler.defaultAIService.SetLicenseChecker(stubLicenseChecker{allow: tc.licensed})
+ if err := handler.defaultAIService.LoadConfig(); err != nil {
+ t.Fatalf("LoadConfig: %v", err)
+ }
+
+ rec := httptest.NewRecorder()
+ handler.HandleGetPatrolAutonomy(rec, newLoopbackRequest(http.MethodGet, "/api/ai/patrol/autonomy", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body %s", rec.Code, rec.Body.String())
+ }
+ var resp PatrolAutonomyResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if resp.EffectiveAutonomyLevel != tc.wantLevel || resp.RequestedAutonomyLevel != tc.wantLevel {
+ t.Fatalf("requested/effective = %q/%q, want %q", resp.RequestedAutonomyLevel, resp.EffectiveAutonomyLevel, tc.wantLevel)
+ }
+ if resp.DefaultModeApplied != tc.wantDefault {
+ t.Fatalf("default_mode_applied = %v, want %v", resp.DefaultModeApplied, tc.wantDefault)
+ }
+ if got := handler.defaultAIService.GetEffectivePatrolAutonomyLevel(); got != tc.wantLevel {
+ t.Fatalf("service runs %q, response says %q", got, tc.wantLevel)
+ }
+ })
+ }
+}
diff --git a/internal/config/ai.go b/internal/config/ai.go
index 206ed47597..aafdd672a8 100644
--- a/internal/config/ai.go
+++ b/internal/config/ai.go
@@ -1026,6 +1026,23 @@ func (c *AIConfig) GetPatrolAutonomyLevel() string {
}
}
+// PatrolAutonomyLevelChosen reports whether the operator has ever saved a
+// Patrol mode. Only explicit saves write the stored level.
+func (c *AIConfig) PatrolAutonomyLevelChosen() bool {
+ return strings.TrimSpace(c.PatrolAutonomyLevel) != ""
+}
+
+// ResolvedPatrolAutonomyLevel returns the operator's chosen Patrol mode, or
+// the default for an install that never chose one: Ask first when its licence
+// includes Patrol investigation, so Pro investigates by default, and Watch
+// only otherwise. It does not apply the licence clamp; callers still do.
+func (c *AIConfig) ResolvedPatrolAutonomyLevel(investigationEntitled bool) string {
+ if !c.PatrolAutonomyLevelChosen() && investigationEntitled {
+ return PatrolAutonomyApproval
+ }
+ return c.GetPatrolAutonomyLevel()
+}
+
// GetEffectivePatrolAutonomy evaluates the requested Patrol mode against the
// server-owned Autopilot acknowledgement and activation evidence. The legacy
// PatrolFullModeUnlocked boolean is intentionally passed only so the evaluator
diff --git a/internal/config/ai_config_test.go b/internal/config/ai_config_test.go
index 2f2bdd2222..685503d24a 100644
--- a/internal/config/ai_config_test.go
+++ b/internal/config/ai_config_test.go
@@ -1383,3 +1383,30 @@ func TestAssistantControlProjectionPreservesSavedPolicy(t *testing.T) {
}
}
}
+
+func TestResolvedPatrolAutonomyLevelDefaultsOnlyNeverChosenLicensedInstalls(t *testing.T) {
+ tests := []struct {
+ name string
+ stored string
+ licensed bool
+ chosen bool
+ want string
+ }{
+ {name: "never chose, licensed", stored: "", licensed: true, chosen: false, want: PatrolAutonomyApproval},
+ {name: "never chose, unlicensed", stored: "", licensed: false, chosen: false, want: PatrolAutonomyMonitor},
+ {name: "blank counts as never chose", stored: " ", licensed: true, chosen: false, want: PatrolAutonomyApproval},
+ {name: "chose watch only, licensed", stored: PatrolAutonomyMonitor, licensed: true, chosen: true, want: PatrolAutonomyMonitor},
+ {name: "chose autopilot, licensed", stored: PatrolAutonomyFull, licensed: true, chosen: true, want: PatrolAutonomyFull},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := &AIConfig{PatrolAutonomyLevel: tt.stored}
+ if got := cfg.PatrolAutonomyLevelChosen(); got != tt.chosen {
+ t.Fatalf("PatrolAutonomyLevelChosen() = %v, want %v", got, tt.chosen)
+ }
+ if got := cfg.ResolvedPatrolAutonomyLevel(tt.licensed); got != tt.want {
+ t.Fatalf("ResolvedPatrolAutonomyLevel(%v) = %q, want %q", tt.licensed, got, tt.want)
+ }
+ })
+ }
+}