Related user story
#39178
Task
Add notify_before_patching to patch policies and make it behave like patch_when_closed at install time. The policy carries Fleet's managed app-open query as a read-only pre-install query, so an install skips while the app is open instead of quitting it. This sub-issue stops there. Turning that skip into a notification is the next sub-issue.
Model the whole change on patch_when_closed, added in #39962. Every touch point below already has a patch_when_closed line next to it.
This is the API contract the frontend and GitOps sub-issues build against, so land it early.
Migration
ALTER TABLE policies
ADD COLUMN notify_before_patching TINYINT(1) NOT NULL DEFAULT 0;
Create it with make migration name=AddNotifyBeforePatchingToPolicies. Mirror server/datastore/mysql/migrations/tables/20260807140831_PatchWhenClosed.go, including the columnExists guard. The Down function is a no-op.
Types
Add NotifyBeforePatching beside every PatchWhenClosed in server/fleet/policies.go:
| Line |
Struct |
Field |
| 80 |
PolicyPayload |
NotifyBeforePatching bool |
| 135 |
NewTeamPolicyPayload |
NotifyBeforePatching bool |
| 362 |
ModifyPolicyPayload |
NotifyBeforePatching *bool \json:"notify_before_patching" premium:"true"`` |
| 469 |
PolicyData |
NotifyBeforePatching bool \json:"notify_before_patching" db:"notify_before_patching"`` |
| 677 |
PolicySpec |
NotifyBeforePatching bool \json:"notify_before_patching"`` |
Also add it to PolicySoftwareInstallerData (server/fleet/policies.go:528-541), which is what the automation path reads.
Use a boolean, matching the REST API doc in #50677. The UI renders it as an "End user experience" dropdown with two options, so a boolean is enough today. See open question 3 before merging: #39176 and #44672 both add options to that dropdown later.
Validation
Four rules. Marko specified the exact user-facing text in a dev note on #50678. Use it verbatim.
| Rule |
Message |
| Requires a patch policy |
"notify_before_patching" is only supported for patch policies |
Mutually exclusive with patch_when_closed |
Only one of "patch_when_closed" or "notify_before_patching" can be set to true |
Rejects an explicit continuous_automations_enabled: false |
If "notify_before_patching" is true, "continuous_automations_enabled" can't be set to false. |
| macOS Fleet-maintained apps only |
"notify_before_patching" is available for macOS Fleet-maintained apps. It's coming soon to Windows. |
The first goes beside errPolicyPatchWhenClosedRequiresPatch at server/fleet/policies.go:153, and is applied in the three verify paths that already check PatchWhenClosed: lines 164, 367, and 734.
The third mirrors errPatchWhenClosedRequiresContinuousAutomations at server/service/global_policies.go:259, enforced at server/service/team_policies.go:314 on create and :723 on modify. Copy that shape exactly.
Reject an explicit false, do not silently override it. Omitting continuous_automations_enabled still auto-sets it to true. Only an explicit false is an error. This is already how patch_when_closed behaves, so it is a mirror rather than a new pattern.
Why mutual exclusion. The UI presents the two as sibling radio branches, so a policy with both set is one the UI cannot render.
Update package endpoint
The Deploy modal and the Edit software modal write through PATCH /software/package, not the policy endpoint, so the flag has to be accepted there too. #50677 documents it alongside automatic_install, patch, and patch_when_closed.
UpdateSoftwareInstallerPayload already carries PatchWhenClosed *bool at server/service/software_installers.go:67-68, parsed from the multipart form at :182 and passed through at :303. Add NotifyBeforePatching *bool following it exactly. A nil pointer leaves the value unchanged.
Why a pointer. The endpoint is a partial update. A plain bool would clear the flag on every request that omits it.
Skip output text
#50677 documents that the two flags produce different pre_install_query_output on a skip:
| Flag |
Output |
patch_when_closed |
Query didn't return result\nThe app was open. |
notify_before_patching |
Query didn't return result\nThe app was open. Fleet notifies the end user 1 hour before the patch is forced. |
Set this where the skip is detected, so the install-results endpoint and the details modals both read it from one place.
Managed pre-install query
software_installers.app_open_query already holds the managed query (server/fleet/software_installer.go:151), sourced from the Fleet-maintained app manifest. It is applied in ee/server/service/software_installers.go:3044 and :3701 when patch_when_closed is on. Extend both conditions so notify_before_patching applies it too.
Why reuse app_open_query rather than add a column. It is the same query answering the same question: is the app running? The two features differ in what happens after the skip, not in how the skip is detected.
Install-skip detection
server/service/orbit.go:1866-1872 decides whether a failed install was an app-open skip:
isAppOpenSkip := false
if result.Status() == fleet.SoftwareInstallFailed &&
result.PreInstallConditionOutput != nil && *result.PreInstallConditionOutput == "" {
if cur, curErr := svc.ds.GetSoftwareInstallResults(ctx, result.InstallUUID); curErr == nil && cur != nil {
isAppOpenSkip = cur.PolicyID != nil && cur.PatchWhenClosed
}
}
Change line 1870 to cur.PolicyID != nil && (cur.PatchWhenClosed || cur.NotifyBeforePatching), and add NotifyBeforePatching to whatever GetSoftwareInstallResults returns.
Keep the flag check, do not widen to "empty output". An ordinary empty pre_install_query on a non-managed policy must still count as a failure and consume a retry. The existing comment on line 1863 says so, and server/service/orbit_test.go:1368 guards it.
The downstream effects at orbit.go:1915 (attempt number forced to 0) and :2015 (InstallSkippedWhenAppOpen on the activity) then apply unchanged.
Policy automation
svc.processSoftwareForNewlyFailingPolicies at server/service/osquery.go:2143 already queues installs for failing policies with continuous_automations_enabled. It needs no change: the app-open gating happens agent-side via the pre-install query, and notify_before_patching forces continuous automations on.
Confirm this by reading the function rather than assuming. If it filters on PatchWhenClosed anywhere, add the new flag there too.
Condition of satisfaction
Migration and round-trip
MYSQL_TEST=1 go test ./server/datastore/mysql/... passes, and schema.sql is regenerated.
- A policy created with
notify_before_patching: true reads back true from the API.
Validation
notify_before_patching: true on a dynamic policy returns 422.
notify_before_patching: true with platform: windows returns the Windows message.
notify_before_patching: true together with patch_when_closed: true returns 422.
notify_before_patching: true sets continuous_automations_enabled: true when the request omits it.
notify_before_patching: true with an explicit continuous_automations_enabled: false returns 400 with the documented message, on both create and modify.
- Each of the four validation messages matches the text in the table above, character for character.
- Setting
notify_before_patching: false on an existing policy leaves continuous_automations_enabled alone.
- A non-premium request returns
ErrMissingLicense.
Managed query
- Creating a patch policy with
notify_before_patching: true sets the installer's pre-install query to the Fleet-maintained app's app_open_query.
- The query is read-only: an update that tries to change
pre_install_query on that installer is rejected, matching the patch_when_closed behavior.
- Clearing
notify_before_patching removes the managed query.
Update package endpoint
PATCH /software/package with notify_before_patching=true sets it, and omitting the field leaves it unchanged.
- The same four validation rules apply on this endpoint.
Install skip
- A policy-triggered install with
notify_before_patching: true and empty pre-install output records install_skipped_when_app_open: true, keeps attempt_number at 0, and does not consume a retry.
- The skip records the
notify_before_patching variant of the output text, and a patch_when_closed skip still records its own.
- Regression: an ordinary empty
pre_install_query on a non-managed policy still fails, counts, and retries. Extend server/service/orbit_test.go:1368.
- A self-service install, a manual install from host details, and a setup experience install all ignore the managed query, matching
patch_when_closed.
Tests
MYSQL_TEST=1 go test ./server/datastore/mysql/...
MYSQL_TEST=1 REDIS_TEST=1 go test ./server/service/... ./ee/server/service/...
go test ./server/fleet/...
- Run
go test ./server/service/ after datastore interface changes.
Related user story
#39178
Task
Add
notify_before_patchingto patch policies and make it behave likepatch_when_closedat install time. The policy carries Fleet's managed app-open query as a read-only pre-install query, so an install skips while the app is open instead of quitting it. This sub-issue stops there. Turning that skip into a notification is the next sub-issue.Model the whole change on
patch_when_closed, added in #39962. Every touch point below already has apatch_when_closedline next to it.This is the API contract the frontend and GitOps sub-issues build against, so land it early.
Migration
Create it with
make migration name=AddNotifyBeforePatchingToPolicies. Mirrorserver/datastore/mysql/migrations/tables/20260807140831_PatchWhenClosed.go, including thecolumnExistsguard. The Down function is a no-op.Types
Add
NotifyBeforePatchingbeside everyPatchWhenClosedinserver/fleet/policies.go:PolicyPayloadNotifyBeforePatching boolNewTeamPolicyPayloadNotifyBeforePatching boolModifyPolicyPayloadNotifyBeforePatching *bool \json:"notify_before_patching" premium:"true"``PolicyDataNotifyBeforePatching bool \json:"notify_before_patching" db:"notify_before_patching"``PolicySpecNotifyBeforePatching bool \json:"notify_before_patching"``Also add it to
PolicySoftwareInstallerData(server/fleet/policies.go:528-541), which is what the automation path reads.Use a boolean, matching the REST API doc in #50677. The UI renders it as an "End user experience" dropdown with two options, so a boolean is enough today. See open question 3 before merging: #39176 and #44672 both add options to that dropdown later.
Validation
Four rules. Marko specified the exact user-facing text in a dev note on #50678. Use it verbatim.
"notify_before_patching" is only supported for patch policiespatch_when_closedOnly one of "patch_when_closed" or "notify_before_patching" can be set to truecontinuous_automations_enabled: falseIf "notify_before_patching" is true, "continuous_automations_enabled" can't be set to false."notify_before_patching" is available for macOS Fleet-maintained apps. It's coming soon to Windows.The first goes beside
errPolicyPatchWhenClosedRequiresPatchatserver/fleet/policies.go:153, and is applied in the three verify paths that already checkPatchWhenClosed: lines 164, 367, and 734.The third mirrors
errPatchWhenClosedRequiresContinuousAutomationsatserver/service/global_policies.go:259, enforced atserver/service/team_policies.go:314on create and:723on modify. Copy that shape exactly.Reject an explicit
false, do not silently override it. Omittingcontinuous_automations_enabledstill auto-sets it totrue. Only an explicitfalseis an error. This is already howpatch_when_closedbehaves, so it is a mirror rather than a new pattern.Why mutual exclusion. The UI presents the two as sibling radio branches, so a policy with both set is one the UI cannot render.
Update package endpoint
The Deploy modal and the Edit software modal write through
PATCH /software/package, not the policy endpoint, so the flag has to be accepted there too. #50677 documents it alongsideautomatic_install,patch, andpatch_when_closed.UpdateSoftwareInstallerPayloadalready carriesPatchWhenClosed *boolatserver/service/software_installers.go:67-68, parsed from the multipart form at:182and passed through at:303. AddNotifyBeforePatching *boolfollowing it exactly. A nil pointer leaves the value unchanged.Why a pointer. The endpoint is a partial update. A plain
boolwould clear the flag on every request that omits it.Skip output text
#50677 documents that the two flags produce different
pre_install_query_outputon a skip:patch_when_closedQuery didn't return result\nThe app was open.notify_before_patchingQuery didn't return result\nThe app was open. Fleet notifies the end user 1 hour before the patch is forced.Set this where the skip is detected, so the install-results endpoint and the details modals both read it from one place.
Managed pre-install query
software_installers.app_open_queryalready holds the managed query (server/fleet/software_installer.go:151), sourced from the Fleet-maintained app manifest. It is applied inee/server/service/software_installers.go:3044and:3701whenpatch_when_closedis on. Extend both conditions sonotify_before_patchingapplies it too.Why reuse
app_open_queryrather than add a column. It is the same query answering the same question: is the app running? The two features differ in what happens after the skip, not in how the skip is detected.Install-skip detection
server/service/orbit.go:1866-1872decides whether a failed install was an app-open skip:Change line 1870 to
cur.PolicyID != nil && (cur.PatchWhenClosed || cur.NotifyBeforePatching), and addNotifyBeforePatchingto whateverGetSoftwareInstallResultsreturns.Keep the flag check, do not widen to "empty output". An ordinary empty
pre_install_queryon a non-managed policy must still count as a failure and consume a retry. The existing comment on line 1863 says so, andserver/service/orbit_test.go:1368guards it.The downstream effects at
orbit.go:1915(attempt number forced to 0) and:2015(InstallSkippedWhenAppOpenon the activity) then apply unchanged.Policy automation
svc.processSoftwareForNewlyFailingPoliciesatserver/service/osquery.go:2143already queues installs for failing policies withcontinuous_automations_enabled. It needs no change: the app-open gating happens agent-side via the pre-install query, andnotify_before_patchingforces continuous automations on.Confirm this by reading the function rather than assuming. If it filters on
PatchWhenClosedanywhere, add the new flag there too.Condition of satisfaction
Migration and round-trip
MYSQL_TEST=1 go test ./server/datastore/mysql/...passes, andschema.sqlis regenerated.notify_before_patching: truereads backtruefrom the API.Validation
notify_before_patching: trueon adynamicpolicy returns 422.notify_before_patching: truewithplatform: windowsreturns the Windows message.notify_before_patching: truetogether withpatch_when_closed: truereturns 422.notify_before_patching: truesetscontinuous_automations_enabled: truewhen the request omits it.notify_before_patching: truewith an explicitcontinuous_automations_enabled: falsereturns 400 with the documented message, on both create and modify.notify_before_patching: falseon an existing policy leavescontinuous_automations_enabledalone.ErrMissingLicense.Managed query
notify_before_patching: truesets the installer's pre-install query to the Fleet-maintained app'sapp_open_query.pre_install_queryon that installer is rejected, matching thepatch_when_closedbehavior.notify_before_patchingremoves the managed query.Update package endpoint
PATCH /software/packagewithnotify_before_patching=truesets it, and omitting the field leaves it unchanged.Install skip
notify_before_patching: trueand empty pre-install output recordsinstall_skipped_when_app_open: true, keepsattempt_numberat 0, and does not consume a retry.notify_before_patchingvariant of the output text, and apatch_when_closedskip still records its own.pre_install_queryon a non-managed policy still fails, counts, and retries. Extendserver/service/orbit_test.go:1368.patch_when_closed.Tests
MYSQL_TEST=1 go test ./server/datastore/mysql/...MYSQL_TEST=1 REDIS_TEST=1 go test ./server/service/... ./ee/server/service/...go test ./server/fleet/...go test ./server/service/after datastore interface changes.