diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index bc4f13bd3f..273aca5e68 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -4195,10 +4195,14 @@ resolve canonical/source IDs and unique aliases before collection, reject human-facing labels in the settings payload must frame the operator boundary as Patrol mode rather than Patrol configuration. Monitor-only Patrol autonomy saves are part of the same runtime gate: - when the safe-remediation extension or entitlement is unavailable, both the - browser state owner and `internal/api/ai_handlers.go` must clear stale - full-mode unlock state while clamping autonomy back to `monitor`, so paid - remediation permission cannot survive through a free runtime save. + when the safe-remediation extension or entitlement is unavailable, the + API must clear stale full-mode unlock and Autopilot activation state while + clamping autonomy back to `monitor` (the monitor-only handler in + `internal/api/ai_handlers.go` without the extension, the + `SaveAutonomySettings` adapter in `internal/api/router_routes_ai_relay.go` + behind the paid handler), and the browser state owner must refuse a paid + level when a save starts, so paid remediation permission cannot survive + through a free runtime save. Event-triggered Patrol runtime policy is also part of this gate: when local development background automation or another runtime policy pauses automatic alert/anomaly-triggered checks, `internal/ai` must expose an explicit diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index b8420140fb..6fc1c60ea1 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -3347,8 +3347,8 @@ a new API state machine, queue contract, or verification-accounting field. and the Patrol runtime-remediation destination shared with the AI settings endpoint, so run-history runtime-failure actions and runtime-finding actions may reuse the governed provider-settings route while still presenting that destination in Patrol as provider configuration instead of generic `AI Settings` copy and the Patrol route-shell destination itself, so the thin page shell at `frontend-modern/src/pages/AIIntelligence.tsx` may continue to bridge the shared AI-runtime payload boundary while exposing `/patrol` as the canonical product route and keeping retired `/ai` browser entry points unregistered and the Patrol route-shell accessibility boundary, so brand icons in `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx` stay decorative when the same heading already exposes visible Patrol text, preventing duplicate accessible names such as `Pulse Patrol Patrol` - and the Patrol mode selector boundary, so the default header and the - Patrol mode dialog compose the shared + and the Patrol mode selector boundary, so the header's `Mode and automation` + disclosure composes the shared `frontend-modern/src/components/shared/FilterButtonGroup.tsx` primitive for the visible `Watch only` / `Ask first` / `Safe auto-fix` / `Autopilot` presentation while the API contract remains the sole owner of @@ -4028,14 +4028,20 @@ the authoritative analysis outcome. self-hosted v6 GA UI and the Patrol autonomy save contract, so Community/free runtime payloads may persist only `monitor` autonomy settings through - `/api/ai/patrol/autonomy`, while `approval`, `assisted`, and `full` return - the canonical license-required response instead of a generic save failure, - and Patrol frontend state owners must clamp stale paid autonomy to `monitor` - and send `full_mode_unlocked:false` before submitting that endpoint when the - safe-remediation entitlement is not effective. The monitor-only backend path - must likewise clear stale full-mode unlock state in its response and - persistence layer instead of preserving paid remediation state through a free - configuration save + `/api/ai/patrol/autonomy`, while `approval`, `assisted`, and an + acknowledged `full` return the canonical license-required response instead + of a generic save failure (the shared acknowledgement gate refuses a `full` + request without a current Autopilot acknowledgement first, for example with + `409 acknowledgement_required` when it carries none), and the Patrol + frontend state owner must + refuse any level other than `monitor` when a save starts while the + safe-remediation entitlement is not effective; its payload carries only + `autonomy_level`, the investigation budget and timeout, and (for Autopilot) + `acknowledgement_id`, never `full_mode_unlocked`. The monitor-only backend + path owns the unlock clamp: + it must clear stale full-mode unlock and Autopilot activation state in its + response and persistence layer instead of preserving paid remediation state + through a free configuration save and the Patrol settings-save readiness contract, so `/api/settings/ai/update` may save a selected Patrol provider/model even when that model is not ready for tool-backed Patrol execution, but it must @@ -9641,19 +9647,22 @@ configuration from paid remediation autonomy. `GET /api/ai/patrol/autonomy` continues to clamp effective Community autonomy to `monitor`, and `PUT /api/ai/patrol/autonomy` in the open-source/free adapter must accept and persist only `monitor` settings while returning the canonical license-required -payload for investigation or remediation autonomy levels. Frontend Patrol state -owners must not rely on that 402 as normal control flow for stale local state: -when the current entitlement locks safe remediation, they submit `monitor` even -if older persisted settings or a previous entitlement left `approval`, -`assisted`, or `full` in memory. +payload for investigation or remediation autonomy levels (a `full` request +reaches that adapter only with a current Autopilot acknowledgement; otherwise +the shared acknowledgement gate refuses it first, for example with +`409 acknowledgement_required` when it carries none). Frontend Patrol +state owners must not rely on that 402 as normal control flow for stale local +state: when the current entitlement locks safe remediation, the Patrol header +presents `monitor` as the effective mode and the state owner starts no save but +an explicit `monitor` choice, even if older persisted settings or a previous +entitlement left `approval`, `assisted`, or `full` in memory. After a successful persisted update whose effective mode actually crosses from `monitor` to `approval`, `assisted`, or eligible `full`, the shared API mutation boundary invokes the bounded Patrol actionable-finding reconciliation pass. The pass runs only after `ApplyPatrolAutonomyConfig` has published the new runtime authority; failed writes, Community clamps, repeated paid-mode saves, budget-only edits, and demotions must not start backlog work. -The Patrol header and configuration-dialog presentation for that API boundary -must compose the shared +The Patrol header presentation for that API boundary must compose the shared `frontend-modern/src/components/shared/FilterButtonGroup.tsx` selector: the endpoint contract still owns the accepted autonomy values and license-required response shape, while the frontend owns only the option mapping, diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 8d4fd75cb2..078efe5ba2 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -3185,9 +3185,13 @@ Agent`), with the plain-language source phrase available through accessible configuration issue rather than a failed save. An Assistant handoff from it must send a source-named, model-only briefing and close the popover so the shared Assistant drawer is not visually hidden behind feature chrome. When - governed fixes are locked, the Patrol state owner must clear stale - full-mode unlock state before persisting the monitor-only autonomy payload, - so disabled paid controls cannot leak stale permission into a save. When a + governed fixes are locked, the Patrol header presents `Watch only` as the + effective mode with no enabled paid choice, and the Patrol state owner + refuses any other level when a save starts. Its autonomy payload + never carries `full_mode_unlocked`: `PUT /api/ai/patrol/autonomy` itself + clears stale full-mode unlock and Autopilot activation state on every save + below Autopilot, so disabled paid controls cannot leak stale permission + into a save. When a restored Patrol assessment session is attached, the shared Assistant drawer empty state must stay aligned with that source-named briefing and must not render generic diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 3b374b993c..ec43e93642 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -1631,12 +1631,14 @@ after a page render: recoverable Patrol provider/model settings saves must persist and echo structured readiness cause metadata, manual run requests must return the structured readiness reason if a stale UI still submits, and scheduled or scoped alert/anomaly runs must skip before calling the model while -preserving the blocked reason and cause in Patrol status. The Patrol -control state owner must also clamp stale investigation/remediation -autonomy back to findings-only `monitor` and clear stale full-mode unlock state -before persisting Patrol control when the safe-remediation entitlement is not -effective, so an expired or downgraded plan cannot turn a recoverable control -review into a Pro-only save failure. Patrol renders no inline error +preserving the blocked reason and cause in Patrol status. When the +safe-remediation entitlement is not effective, the Patrol header must present +findings-only `monitor` as the effective mode and the Patrol control state +owner must refuse any investigation/remediation level when a save starts; +the `monitor` save it does send is accepted without the entitlement, and the +API clears stale full-mode unlock and Autopilot activation state on it, so an +expired or downgraded plan cannot turn a recoverable control review into a +Pro-only save failure. Patrol renders no inline error surface for these failures today: switching Patrol on or off while it is not ready raises a fixed warning notification, a failed Patrol mode save raises an error notification, and neither offers an Assistant handoff. A provider or model save @@ -1651,8 +1653,10 @@ prominent layout so all four mode labels remain readable without inventing a Patrol-local selector. Patrol owns the default visible four-level policy presentation (`Watch only`, `Ask first`, `Safe auto-fix`, `Autopilot`), entitlement locks, and the rule that choosing the highest -Autopilot level sends `full_mode_unlocked:true` while choosing any lower level clears that -acknowledgement. The shared primitive owns pressed-state semantics, +Autopilot level opens the acknowledgement dialog and saves `full` only with a +recorded `acknowledgement_id` (the API derives the compatibility +`full_mode_unlocked` value itself), while saving any lower level lets the API +clear the Autopilot activation. The shared primitive owns pressed-state semantics, disabled-option behavior, and active/inactive selector styling. That same Patrol-owned presentation rule also applies to the findings empty state: `frontend-modern/src/components/AI/FindingsPanel.tsx` must not treat diff --git a/frontend-modern/browser-verification/994121d9684bd9f0.json b/frontend-modern/browser-verification/994121d9684bd9f0.json new file mode 100644 index 0000000000..58875b1694 --- /dev/null +++ b/frontend-modern/browser-verification/994121d9684bd9f0.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "base_sha": "32fad38aa486ac116f885cb77e27317ea351c957", + "verified_at": "2026-10-07T23:52:39Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts" + ], + "content_sha256": { + "frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "789f7e4a6f28e07d6d1d28abc29843ab02a90eef4f787fa024f0ba383abc8563" + }, + "routes": [ + "/patrol (header 'Mode and automation' disclosure)", + "/patrol (Activate Autopilot acknowledgement dialog)" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Removal of the test-only resolvePatrolAutonomyLevelForSave / resolvePatrolAutonomySettingsForSave exports; no production code called them.", + "Embedded production builds (vite build into internal/api/frontend-modern/dist, go build) of the parent 32fad38aa4 and of this change, run on pulse-dev because local memory pressure refused heavy work. The two dists are byte-identical (187 files, every sha256 equal): Rollup had already dropped both helpers.", + "Each build on its own private DEMO_MODE mock stack with a fresh data dir, one at a time; the same Playwright script ran against both at both widths. Results compared field by field: 6 sessions, 80 recorded fields, 0 differences.", + "Paid entitlement (ai_autofix added to /api/license/runtime-capabilities via page.route): all four Patrol mode choices enabled; Ask first, Watch only and Autopilot each saved through PUT /api/ai/patrol/autonomy (stubbed 200 plus a GET reconcile rewrite); Autopilot opened the acknowledgement dialog with no PUT until 'Record acknowledgement and activate' (one stubbed acknowledgement POST). Every PUT body carried only autonomy_level, investigation_budget, investigation_timeout_sec and, for Autopilot, acknowledgement_id; none carried full_mode_unlocked.", + "Runtime lock under demo presentation (ai_autofix blocked with paid_runtime_required): demo forces hideCommercial, so the header shows Watch only with no mode selector and no save path.", + "Runtime lock with the demo presentation flags cleared in the browser (GET /api/security/status rewritten): 'Pulse Pro runtime required' notice, Watch only selected, Ask first / Safe auto-fix / Autopilot disabled; forced clicks on them sent no PUT and opened no Autopilot dialog; Watch only sent exactly autonomy_level monitor plus the investigation budget and timeout.", + "Console/network noise identical on both builds and unrelated to the change: 403 POST /api/ai/workflow-prompts/activity (demo write guard on the starter record) in the paid sessions, 404 GET /api/license/commercial-posture in the cleared-presentation sessions." + ], + "interactions": [ + "Demo auto sign-in; open /patrol, expand 'Mode and automation', read the selector's pressed and disabled states.", + "Paid: click Ask first, then Watch only, then Autopilot (re-clicking only while the previous save was still reconciling), tick the acknowledgement checkbox and click 'Record acknowledgement and activate'.", + "Locked (selector visible): force-click Ask first, Safe auto-fix and Autopilot, then click Watch only.", + "Desktop 1440x900 and phone 390x844 contexts; screenshots of the initial, Autopilot dialog and final states inspected." + ] +} diff --git a/frontend-modern/src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts b/frontend-modern/src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts index 4e771ea3a9..e04ee3c2e5 100644 --- a/frontend-modern/src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts @@ -5,8 +5,6 @@ import { getPatrolSavedReadinessWarning, patrolStartFailureMessage, recordPatrolControlStarterActivity, - resolvePatrolAutonomyLevelForSave, - resolvePatrolAutonomySettingsForSave, resolvePatrolBlockedActionCause, } from '../usePatrolIntelligenceState'; import patrolIntelligenceStateSource from '../usePatrolIntelligenceState.ts?raw'; @@ -80,6 +78,17 @@ describe('usePatrolIntelligenceState', () => { expect(patrolIntelligenceStateSource).toContain('if (requestId === refreshRequestId) {'); }); + it('refuses paid Patrol modes in the state owner while governed fixes are locked', () => { + // The header disables paid choices too; these guards keep the state owner + // from starting a paid save if a caller reaches it anyway. + expect(patrolIntelligenceStateSource).toContain( + "if (controlLocked && level !== 'monitor') return;", + ); + expect(patrolIntelligenceStateSource).toContain( + 'if (isUpdatingAutonomy() || autoFixLocked()) return;', + ); + }); + 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', @@ -206,78 +215,6 @@ describe('usePatrolIntelligenceState', () => { ); }); - describe('resolvePatrolAutonomyLevelForSave', () => { - it('clamps stale paid autonomy to monitor when governed fixes are locked', () => { - expect(resolvePatrolAutonomyLevelForSave('full', true, true)).toBe('monitor'); - expect(resolvePatrolAutonomyLevelForSave('assisted', false, true)).toBe('monitor'); - expect(resolvePatrolAutonomyLevelForSave('approval', false, true)).toBe('monitor'); - }); - - it('preserves paid autonomy choices when governed fixes are available', () => { - expect(resolvePatrolAutonomyLevelForSave('assisted', false, false)).toBe('assisted'); - expect(resolvePatrolAutonomyLevelForSave('assisted', true, false)).toBe('assisted'); - expect(resolvePatrolAutonomyLevelForSave('full', true, false)).toBe('full'); - expect(resolvePatrolAutonomyLevelForSave('full', false, false)).toBe('assisted'); - expect(resolvePatrolAutonomyLevelForSave('approval', false, false)).toBe('approval'); - }); - }); - - describe('resolvePatrolAutonomySettingsForSave', () => { - it('clears stale full-mode state when governed fixes are locked', () => { - expect( - resolvePatrolAutonomySettingsForSave({ - level: 'full', - fullModeUnlocked: true, - autoFixLocked: true, - }), - ).toEqual({ autonomyLevel: 'monitor', fullModeUnlocked: false }); - }); - - it('does not carry full-mode state into non-remediation modes', () => { - expect( - resolvePatrolAutonomySettingsForSave({ - level: 'monitor', - fullModeUnlocked: true, - autoFixLocked: false, - }), - ).toEqual({ autonomyLevel: 'monitor', fullModeUnlocked: false }); - - expect( - resolvePatrolAutonomySettingsForSave({ - level: 'approval', - fullModeUnlocked: true, - autoFixLocked: false, - }), - ).toEqual({ autonomyLevel: 'approval', fullModeUnlocked: false }); - }); - - it('keeps full control tied to the explicit full selection', () => { - expect( - resolvePatrolAutonomySettingsForSave({ - level: 'assisted', - fullModeUnlocked: true, - autoFixLocked: false, - }), - ).toEqual({ autonomyLevel: 'assisted', fullModeUnlocked: false }); - - expect( - resolvePatrolAutonomySettingsForSave({ - level: 'full', - fullModeUnlocked: true, - autoFixLocked: false, - }), - ).toEqual({ autonomyLevel: 'full', fullModeUnlocked: true }); - - expect( - resolvePatrolAutonomySettingsForSave({ - level: 'full', - fullModeUnlocked: false, - autoFixLocked: false, - }), - ).toEqual({ autonomyLevel: 'assisted', fullModeUnlocked: false }); - }); - }); - describe('getPatrolSavedReadinessWarning', () => { it('stays quiet when the saved settings do not block Patrol readiness', () => { expect(getPatrolSavedReadinessWarning(null)).toBeNull(); diff --git a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts index 5c33d076a8..d759c49aab 100644 --- a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts +++ b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts @@ -86,36 +86,6 @@ export const patrolStartFailureMessage = (error: unknown): string => ? `Could not reach Pulse to start Patrol: ${patrolErrorMessage(error, 'network request failed')}` : patrolErrorMessage(error, 'Pulse rejected the Patrol run'); -export function resolvePatrolAutonomyLevelForSave( - level: PatrolAutonomyLevel, - fullModeUnlocked: boolean, - autoFixLocked: boolean, -): PatrolAutonomyLevel { - if (autoFixLocked) return 'monitor'; - if (level === 'full') { - return fullModeUnlocked ? 'full' : 'assisted'; - } - return level; -} - -export function resolvePatrolAutonomySettingsForSave({ - level, - fullModeUnlocked, - autoFixLocked, -}: { - level: PatrolAutonomyLevel; - fullModeUnlocked: boolean; - autoFixLocked: boolean; -}): { autonomyLevel: PatrolAutonomyLevel; fullModeUnlocked: boolean } { - const canUseFullMode = !autoFixLocked && level === 'full' && fullModeUnlocked; - const autonomyLevel = resolvePatrolAutonomyLevelForSave(level, canUseFullMode, autoFixLocked); - - return { - autonomyLevel, - fullModeUnlocked: autonomyLevel === 'full', - }; -} - /** * A Patrol on/off save whose response reports Patrol not ready was still * saved, so it raises a warning rather than an error. diff --git a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx index f945d40037..d0d3f9301f 100644 --- a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx +++ b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx @@ -1249,6 +1249,49 @@ describe('AIIntelligence entitlement gating', () => { expect(screen.queryByText(/Install the Pulse Pro runtime/)).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'Open Pro downloads' })).not.toBeInTheDocument(); }); + + it('saves only Watch only, without an unlock field, while governed fixes are locked', async () => { + useLicensedCommunityRuntime(); + // A previous entitlement left a paid mode and the compatibility unlock in memory. + getPatrolAutonomySettingsMock.mockResolvedValue( + defaultPatrolAutonomySettings({ + autonomy_level: 'assisted', + requested_autonomy_level: 'assisted', + effective_autonomy_level: 'assisted', + full_mode_unlocked: true, + }), + ); + + render(() => ); + + const modes = within(await screen.findByRole('group', { name: 'Patrol mode' })); + await waitFor(() => expect(getPatrolAutonomySettingsMock).toHaveBeenCalled()); + await getPatrolAutonomySettingsMock.mock.results[0]?.value; + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(modes.getByRole('button', { name: 'Watch only' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + for (const paidMode of ['Ask first', 'Safe auto-fix', 'Autopilot']) { + expect(modes.getByRole('button', { name: paidMode })).toBeDisabled(); + fireEvent.click(modes.getByRole('button', { name: paidMode })); + } + expect(screen.queryByRole('dialog', { name: 'Activate Autopilot' })).toBeNull(); + expect(updatePatrolAutonomySettingsMock).not.toHaveBeenCalled(); + + fireEvent.click(modes.getByRole('button', { name: 'Watch only' })); + + // The browser sends no full_mode_unlocked field; the API clears stale + // unlock and Autopilot activation state on every non-Autopilot save. + await waitFor(() => { + expect(updatePatrolAutonomySettingsMock).toHaveBeenCalledTimes(1); + }); + expect(updatePatrolAutonomySettingsMock).toHaveBeenCalledWith({ + autonomy_level: 'monitor', + investigation_budget: 15, + investigation_timeout_sec: 300, + }); + }); }); it('keeps the Patrol model catalog out of the operator page', async () => {