Related user story
#39178
Task
Implement notify_before_patching as the first end-user notification kind. When a policy-driven install skips because the app is open, batch those apps into one notification for the host, render the toast contents, record the outcome as an activity, and queue the installs when the end user picks Update now.
After this sub-issue an admin can watch the whole chain: the app-open skip, the toast on the end user's Mac, and the activity saying whether it was shown. The countdown and the forced install come next.
Depends on the notifications platform and the policy field.
Migration
The kind needs to query its apps on two hot paths, so they get columns rather than living in the notification's JSON payload.
CREATE TABLE patch_notifications (
notification_uuid VARCHAR(36) NOT NULL,
install_at TIMESTAMP NULL,
reminder_sent_at TIMESTAMP NULL,
PRIMARY KEY (notification_uuid),
CONSTRAINT fk_pn_notification FOREIGN KEY (notification_uuid)
REFERENCES end_user_notifications (uuid) ON DELETE CASCADE
);
CREATE TABLE patch_notification_apps (
notification_uuid VARCHAR(36) NOT NULL,
policy_id INT UNSIGNED NULL,
software_title_id INT UNSIGNED NULL,
software_installer_id INT UNSIGNED NULL,
PRIMARY KEY (notification_uuid, software_title_id),
KEY idx_pna_policy (policy_id),
CONSTRAINT fk_pna_notification FOREIGN KEY (notification_uuid)
REFERENCES end_user_notifications (uuid) ON DELETE CASCADE,
CONSTRAINT fk_pna_policy FOREIGN KEY (policy_id) REFERENCES policies (id) ON DELETE SET NULL
);
install_at is written by the next sub-issue. It is declared here so the schema lands once.
Why not keep the apps in payload. The dedup guard and the Automation runs query both filter by app and by policy. Scanning JSON for that on every policy run does not hold up.
Batching
After SaveHostSoftwareInstallResult records an app-open skip (server/service/orbit.go:1915), collect the host's skips into one notification.
for each app-open skip on host H for a policy with notify_before_patching:
if the app already appears in patch_notification_apps for a
non-terminal end_user_notifications row on H:
skip it
else:
add it to this pass's batch
if the batch is non-empty:
create one end_user_notifications row (kind = notify_before_patching)
insert one patch_notification_apps row per app
The dedup guard is required, not an optimization. notify_before_patching forces continuous_automations_enabled, so the policy fires on every host refetch. Without the guard, each refetch starts a new toast and a new countdown for the same app. This is the End user already notified about this app? → Wait, nothing to do branch of the decisions flowchart.
Non-terminal means pending, dispatched, or displayed. A notification that has failed, expired, or been acted on no longer covers its apps.
One notification per host per pass, not per app. Fleet-maintained apps update about twice a day, sometimes ten at once. One toast listing them all is the design; ten toasts is not.
Render
func (k *patchKind) Render(ctx context.Context, n *fleet.EndUserNotification) (*fleet.NotificationView, error)
Read the apps from patch_notification_apps, and the deadline from patch_notifications.install_at. Logo comes from Settings > Organization settings > Organization info (fleet.DesktopOrgInfo).
Copy is verbatim from the design. The payload carries a reminder flag that picks between the two forms:
| Field |
First notification |
Reminder |
| Title |
Save your work 💾 |
Save your work 💾 |
| Description |
These apps will close and update in **1 hour**. |
These apps will close and update in **5 minutes**. |
| Secondary action |
Remind me 5 minutes before |
Hide |
| Primary action |
Update now |
Update now |
With one app, the description reads This app will close and update in **5 minutes**. Once installs are queued, each item carries Installing... and only Hide remains.
Activities
Add to server/fleet/activities.go, beside ActivityTypeInstalledSoftware at line 1203:
type ActivityTypeNotifiedEndUserBeforePatching struct {
HostID uint `json:"host_id"`
HostDisplayName string `json:"host_display_name"`
PatchNotificationUUID string `json:"patch_notification_uuid"`
SoftwareTitles []string `json:"software_titles"`
PolicyIDs []uint `json:"policy_ids"`
TimeBefore int `json:"time_before"` // seconds: 3600 or 300
InstallAt time.Time `json:"install_at"`
Status string `json:"status"` // "success" or "failed"
ScriptExecutionID string `json:"script_execution_id,omitempty"`
}
Emit it from OnOutcome. The reminder reuses patch_notification_uuid, which is why core re-dispatches one row instead of creating a second.
One activity type, not two. Marko settled this on the parent story: a single notified_end_user_before_patching, with status distinguishing the outcome, modelled on installed_software. The deadline field is install_at, for consistency with other endpoints.
No reason field. Carry script_execution_id instead. The frontend fetches the run with GET /api/v1/fleet/scripts/results/{execution_id} and picks its copy from the exit code (#50679, and Figma dev note 5582:46519). The platform still maps exit codes to a NotificationReason internally for its retry policy, but that stays server-side.
A deferred notification is reported two ways. Marko added a new notify exit code for "another notification is displayed" on 2026-08-11, having missed it when he built the command. So a deferral the binary catches has a script_execution_id and that exit code. A deferral the dispatcher catches first, by refusing to start a second script while one is unfinished on that host, has no script and so no script_execution_id. Both render the same sentence. Keep both paths: the server-side check saves a pointless round-trip, and the binary is authoritative about what is actually on screen.
This needs a Fleet Desktop release. The exit code does not exist in the shipped 1.5.0 binary. Get the number from Marko before implementing; do not guess it.
Absence of script_execution_id is unambiguous only because the patch kind sets no expires_at, so a server-side deferral is the only activity-emitting outcome without a script run. Do not set an expiry on this kind.
Automation runs
ListPolicyAutomationActivities (server/datastore/mysql/activities.go:1799) is a UNION ALL over branches built in buildPolicyAutomationBranches (:1749). Add a branch that joins the new activity to the policy through patch_notification_apps:
SELECT ... FROM activity_past ap
JOIN patch_notification_apps pna
ON pna.notification_uuid = ap.details->>'$.patch_notification_uuid'
...
WHERE ap.activity_type = 'notified_end_user_before_patching'
AND pna.policy_id = ?
Why join rather than read policy_ids from the JSON. One notification covers several policies, so the activity appears in the Automation runs table of each. idx_pna_policy makes that a lookup instead of a scan of every activity's JSON.
Respect the existing status filter: error selects failures, success selects displayed.
Update now
OnAction(ctx, n, "update_now") queues an install for each app in the notification, using ds.InsertSoftwareInstallRequest with the policy ID, matching server/service/osquery.go:2309. Set the notification to acted.
Bypass the app-open gate here. The end user asked for the update, so skipping because the app is open would do nothing and look broken.
Update now cancels the countdown. Setting the notification to acted takes it out of the reminder and install passes in #50913, so no 5 minute reminder follows. The parent story's test plan checks this.
Skip activity
The app-open skip keeps using the existing installed_software activity with the skip flag, for both patch_when_closed and notify_before_patching. Marko confirmed this on the parent story: no new activity type for the skip. The two are told apart by pre_install_query_output, which #50911 sets to different text per flag, and the frontend renders extra copy from that.
The merged doc names this field wrong. #49106 (docs-v4.91.0, line 1661) documents the payload field as "skipped_install" and refers to patch_only_when_closed. Neither exists in code. The payload field is install_skipped_when_app_open (server/fleet/activities.go:1219), and the policy field is patch_when_closed. skipped_install does exist, but as a frontend display status derived from the flag, not as a payload field. Emit install_skipped_when_app_open, and fix the doc in #50918.
Condition of satisfaction
Batching and dedup
- Two app-open skips on one host in one pass produce one notification with two
patch_notification_apps rows.
- A second skip for an app already covered by a
pending, dispatched, or displayed notification does not create a second notification.
- A skip for an app whose earlier notification is
failed, expired, or acted does create a new one.
- A skip for a different app while a notification is live creates a second notification with its own UUID.
- Skips on two different hosts produce two notifications.
- A skip from a
patch_when_closed policy creates no notification, but still emits the installed_software activity with the skip flag.
Render
- The view lists every app in the notification, with icon URLs and the org logo.
- The
reminder payload flag switches the description and the secondary action label.
- One app renders
This app will close and update in ...; more than one renders These apps ....
- Once
acted, every item shows Installing... and only Hide is returned.
Activities
- Exit code 0 emits the activity with
status: "success", every software title, every policy ID, and time_before: 3600.
- A failure emits it with
status: "failed" and the reason from the platform.
- A reminder emits a second activity with the same
patch_notification_uuid and time_before: 300.
- A displayed or failed notification carries the
script_execution_id of the run that produced it.
- A notification deferred by the dispatcher emits an activity with no
script_execution_id.
- A notification deferred by the binary emits an activity with the new exit code and its
script_execution_id.
- The activity appears in both the global and host activity feeds.
Automation runs
- A notification covering two policies appears in the Automation runs table of both.
status=error returns only failures; status=success returns only displayed.
- Regression:
MYSQL_TEST=1 go test -run TestListPolicyAutomationActivities ./server/datastore/mysql/... still passes for the existing branches.
Update now
POST /device/{token}/notifications/{uuid}/actions with update_now queues one install per app, with the policy ID set.
- Those installs run even when the app is open.
- A second
update_now on an already-acted notification does not double-queue.
- After
update_now, no 5 minute reminder is sent and no install is queued by the deadline pass.
Tests
MYSQL_TEST=1 go test ./server/datastore/mysql/...
MYSQL_TEST=1 REDIS_TEST=1 go test ./server/service/...
- End to end: policy fails with the app open, install skips, notification is created, script returns 0, activity appears with the right titles and policy IDs, and the Automation runs table shows a row for each policy.
Related user story
#39178
Task
Implement
notify_before_patchingas the first end-user notification kind. When a policy-driven install skips because the app is open, batch those apps into one notification for the host, render the toast contents, record the outcome as an activity, and queue the installs when the end user picks Update now.After this sub-issue an admin can watch the whole chain: the app-open skip, the toast on the end user's Mac, and the activity saying whether it was shown. The countdown and the forced install come next.
Depends on the notifications platform and the policy field.
Migration
The kind needs to query its apps on two hot paths, so they get columns rather than living in the notification's JSON payload.
install_atis written by the next sub-issue. It is declared here so the schema lands once.Why not keep the apps in
payload. The dedup guard and the Automation runs query both filter by app and by policy. Scanning JSON for that on every policy run does not hold up.Batching
After
SaveHostSoftwareInstallResultrecords an app-open skip (server/service/orbit.go:1915), collect the host's skips into one notification.The dedup guard is required, not an optimization.
notify_before_patchingforcescontinuous_automations_enabled, so the policy fires on every host refetch. Without the guard, each refetch starts a new toast and a new countdown for the same app. This is theEnd user already notified about this app? → Wait, nothing to dobranch of the decisions flowchart.Non-terminal means
pending,dispatched, ordisplayed. A notification that hasfailed,expired, or beenactedon no longer covers its apps.One notification per host per pass, not per app. Fleet-maintained apps update about twice a day, sometimes ten at once. One toast listing them all is the design; ten toasts is not.
Render
Read the apps from
patch_notification_apps, and the deadline frompatch_notifications.install_at. Logo comes from Settings > Organization settings > Organization info (fleet.DesktopOrgInfo).Copy is verbatim from the design. The payload carries a
reminderflag that picks between the two forms:Save your work 💾Save your work 💾These apps will close and update in **1 hour**.These apps will close and update in **5 minutes**.Remind me 5 minutes beforeHideUpdate nowUpdate nowWith one app, the description reads
This app will close and update in **5 minutes**.Once installs are queued, each item carriesInstalling...and onlyHideremains.Activities
Add to
server/fleet/activities.go, besideActivityTypeInstalledSoftwareat line 1203:Emit it from
OnOutcome. The reminder reusespatch_notification_uuid, which is why core re-dispatches one row instead of creating a second.One activity type, not two. Marko settled this on the parent story: a single
notified_end_user_before_patching, withstatusdistinguishing the outcome, modelled oninstalled_software. The deadline field isinstall_at, for consistency with other endpoints.No
reasonfield. Carryscript_execution_idinstead. The frontend fetches the run withGET /api/v1/fleet/scripts/results/{execution_id}and picks its copy from the exit code (#50679, and Figma dev note5582:46519). The platform still maps exit codes to aNotificationReasoninternally for its retry policy, but that stays server-side.A deferred notification is reported two ways. Marko added a new
notifyexit code for "another notification is displayed" on 2026-08-11, having missed it when he built the command. So a deferral the binary catches has ascript_execution_idand that exit code. A deferral the dispatcher catches first, by refusing to start a second script while one is unfinished on that host, has no script and so noscript_execution_id. Both render the same sentence. Keep both paths: the server-side check saves a pointless round-trip, and the binary is authoritative about what is actually on screen.This needs a Fleet Desktop release. The exit code does not exist in the shipped 1.5.0 binary. Get the number from Marko before implementing; do not guess it.
Absence of
script_execution_idis unambiguous only because the patch kind sets noexpires_at, so a server-side deferral is the only activity-emitting outcome without a script run. Do not set an expiry on this kind.Automation runs
ListPolicyAutomationActivities(server/datastore/mysql/activities.go:1799) is aUNION ALLover branches built inbuildPolicyAutomationBranches(:1749). Add a branch that joins the new activity to the policy throughpatch_notification_apps:Why join rather than read
policy_idsfrom the JSON. One notification covers several policies, so the activity appears in the Automation runs table of each.idx_pna_policymakes that a lookup instead of a scan of every activity's JSON.Respect the existing
statusfilter:errorselects failures,successselects displayed.Update now
OnAction(ctx, n, "update_now")queues an install for each app in the notification, usingds.InsertSoftwareInstallRequestwith the policy ID, matchingserver/service/osquery.go:2309. Set the notification toacted.Bypass the app-open gate here. The end user asked for the update, so skipping because the app is open would do nothing and look broken.
Update now cancels the countdown. Setting the notification to
actedtakes it out of the reminder and install passes in #50913, so no 5 minute reminder follows. The parent story's test plan checks this.Skip activity
The app-open skip keeps using the existing
installed_softwareactivity with the skip flag, for bothpatch_when_closedandnotify_before_patching. Marko confirmed this on the parent story: no new activity type for the skip. The two are told apart bypre_install_query_output, which #50911 sets to different text per flag, and the frontend renders extra copy from that.The merged doc names this field wrong. #49106 (
docs-v4.91.0, line 1661) documents the payload field as"skipped_install"and refers topatch_only_when_closed. Neither exists in code. The payload field isinstall_skipped_when_app_open(server/fleet/activities.go:1219), and the policy field ispatch_when_closed.skipped_installdoes exist, but as a frontend display status derived from the flag, not as a payload field. Emitinstall_skipped_when_app_open, and fix the doc in #50918.Condition of satisfaction
Batching and dedup
patch_notification_appsrows.pending,dispatched, ordisplayednotification does not create a second notification.failed,expired, oracteddoes create a new one.patch_when_closedpolicy creates no notification, but still emits theinstalled_softwareactivity with the skip flag.Render
reminderpayload flag switches the description and the secondary action label.This app will close and update in ...; more than one rendersThese apps ....acted, every item showsInstalling...and onlyHideis returned.Activities
status: "success", every software title, every policy ID, andtime_before: 3600.status: "failed"and the reason from the platform.patch_notification_uuidandtime_before: 300.script_execution_idof the run that produced it.script_execution_id.script_execution_id.Automation runs
status=errorreturns only failures;status=successreturns only displayed.MYSQL_TEST=1 go test -run TestListPolicyAutomationActivities ./server/datastore/mysql/...still passes for the existing branches.Update now
POST /device/{token}/notifications/{uuid}/actionswithupdate_nowqueues one install per app, with the policy ID set.update_nowon an already-acted notification does not double-queue.update_now, no 5 minute reminder is sent and no install is queued by the deadline pass.Tests
MYSQL_TEST=1 go test ./server/datastore/mysql/...MYSQL_TEST=1 REDIS_TEST=1 go test ./server/service/...