Repository: kolide/launcher

Issue:
Calling kolide_macos_software_update results in panic on Tahoe
kolide_macos_software_update:

```
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[SUSharedPrefs isAppStoreAutoUpdatesManaged]: unrecognized selector sent to instance 0xa924003c0'
*** First throw call stack:
(
	0   CoreFoundation                      0x00000001981b2690 __exceptionPreprocess + 176
	1   libobjc.A.dylib                     0x0000000197c9a430 objc_exception_throw + 88
	2   CoreFoundation                      0x0000000198293a90 +[NSObject(NSObject) instanceMethodSignatureForSelector:] + 0
	3   CoreFoundation                      0x000000019814bcf8 ___forwarding___ + 1480
	4   CoreFoundation                      0x000000019814b670 _CF_forwarding_prep_0 + 96
	5   launcher                            0x000000010332569c getSoftwareUpdateConfiguration + 300
	6   launcher                            0x0000000103325564 _cgo_9eb405a90cbd_Cfunc_getSoftwareUpdateConfiguration + 56
	7   launcher                            0x00000001028d968c launcher + 530060
)
libc++abi: terminating due to uncaught exception of type NSException
SIGABRT: abort
PC=0x1980895b0 m=8 sigcode=0
signal arrived during cgo execution
```

Confirmed this issue does not affect Sequoia.

Discussion:

Solution patch:
diff --git a/ee/agent/flags/flag_controller.go b/ee/agent/flags/flag_controller.go
index 147962e5..9caa38da 100644
--- a/ee/agent/flags/flag_controller.go
+++ b/ee/agent/flags/flag_controller.go
@@ -794,3 +794,15 @@ func (fc *FlagController) ResetOnHardwareChangeEnabled() bool {
 		WithDefaultBool(false),
 	).get(fc.getControlServerValue(keys.ResetOnHardwareChangeEnabled))
 }
+
+func (fc *FlagController) AutoupdateDownloadSplay() time.Duration {
+	return NewDurationFlagValue(fc.slogger, keys.AutoupdateDownloadSplay,
+		WithDefault(8*time.Hour),
+		WithMin(0*time.Second),
+		WithMax(72*time.Hour),
+	).get(fc.getControlServerValue(keys.AutoupdateDownloadSplay))
+}
+
+func (fc *FlagController) SetAutoupdateDownloadSplay(val time.Duration) error {
+	return fc.setControlServerValue(keys.AutoupdateDownloadSplay, durationToBytes(val))
+}
diff --git a/ee/agent/flags/keys/keys.go b/ee/agent/flags/keys/keys.go
index e80b6093..caa9a9dc 100644
--- a/ee/agent/flags/keys/keys.go
+++ b/ee/agent/flags/keys/keys.go
@@ -45,6 +45,7 @@ const (
 	AutoupdateInterval               FlagKey = "autoupdate_interval"
 	UpdateChannel                    FlagKey = "update_channel"
 	AutoupdateInitialDelay           FlagKey = "autoupdater_initial_delay"
+	AutoupdateDownloadSplay          FlagKey = "autoupdate_download_splay"
 	UpdateDirectory                  FlagKey = "update_directory"
 	PinnedLauncherVersion            FlagKey = "pinned_launcher_version"
 	PinnedOsquerydVersion            FlagKey = "pinned_osqueryd_version"
diff --git a/ee/agent/types/flags.go b/ee/agent/types/flags.go
index 81a290db..5585e15e 100644
--- a/ee/agent/types/flags.go
+++ b/ee/agent/types/flags.go
@@ -262,4 +262,7 @@ type Flags interface {
 	// ResetOnHardwareChangeEnabled controls whether launcher will reset its database on hardware change detected
 	SetResetOnHardwareChangeEnabled(enabled bool) error
 	ResetOnHardwareChangeEnabled() bool
+
+	AutoupdateDownloadSplay() time.Duration
+	SetAutoupdateDownloadSplay(val time.Duration) error
 }
diff --git a/ee/agent/types/mocks/flags.go b/ee/agent/types/mocks/flags.go
index 97567628..c1ce800e 100644
--- a/ee/agent/types/mocks/flags.go
+++ b/ee/agent/types/mocks/flags.go
@@ -52,6 +52,24 @@ func (_m *Flags) Autoupdate() bool {
 	return r0
 }
 
+// AutoupdateDownloadSplay provides a mock function with no fields
+func (_m *Flags) AutoupdateDownloadSplay() time.Duration {
+	ret := _m.Called()
+
+	if len(ret) == 0 {
+		panic("no return value specified for AutoupdateDownloadSplay")
+	}
+
+	var r0 time.Duration
+	if rf, ok := ret.Get(0).(func() time.Duration); ok {
+		r0 = rf()
+	} else {
+		r0 = ret.Get(0).(time.Duration)
+	}
+
+	return r0
+}
+
 // AutoupdateInitialDelay provides a mock function with no fields
 func (_m *Flags) AutoupdateInitialDelay() time.Duration {
 	ret := _m.Called()
@@ -919,6 +937,24 @@ func (_m *Flags) SetAutoupdate(enabled bool) error {
 	return r0
 }
 
+// SetAutoupdateDownloadSplay provides a mock function with given fields: val
+func (_m *Flags) SetAutoupdateDownloadSplay(val time.Duration) error {
+	ret := _m.Called(val)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetAutoupdateDownloadSplay")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(time.Duration) error); ok {
+		r0 = rf(val)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
 // SetAutoupdateInitialDelay provides a mock function with given fields: delay
 func (_m *Flags) SetAutoupdateInitialDelay(delay time.Duration) error {
 	ret := _m.Called(delay)
diff --git a/ee/agent/types/mocks/knapsack.go b/ee/agent/types/mocks/knapsack.go
index 21d04bc0..41dfe2e2 100644
--- a/ee/agent/types/mocks/knapsack.go
+++ b/ee/agent/types/mocks/knapsack.go
@@ -92,6 +92,24 @@ func (_m *Knapsack) Autoupdate() bool {
 	return r0
 }
 
+// AutoupdateDownloadSplay provides a mock function with no fields
+func (_m *Knapsack) AutoupdateDownloadSplay() time.Duration {
+	ret := _m.Called()
+
+	if len(ret) == 0 {
+		panic("no return value specified for AutoupdateDownloadSplay")
+	}
+
+	var r0 time.Duration
+	if rf, ok := ret.Get(0).(func() time.Duration); ok {
+		r0 = rf()
+	} else {
+		r0 = ret.Get(0).(time.Duration)
+	}
+
+	return r0
+}
+
 // AutoupdateInitialDelay provides a mock function with no fields
 func (_m *Knapsack) AutoupdateInitialDelay() time.Duration {
 	ret := _m.Called()
@@ -1419,6 +1437,24 @@ func (_m *Knapsack) SetAutoupdate(enabled bool) error {
 	return r0
 }
 
+// SetAutoupdateDownloadSplay provides a mock function with given fields: val
+func (_m *Knapsack) SetAutoupdateDownloadSplay(val time.Duration) error {
+	ret := _m.Called(val)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SetAutoupdateDownloadSplay")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(time.Duration) error); ok {
+		r0 = rf(val)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
 // SetAutoupdateInitialDelay provides a mock function with given fields: delay
 func (_m *Knapsack) SetAutoupdateInitialDelay(delay time.Duration) error {
 	ret := _m.Called(delay)
diff --git a/ee/tables/macos_software_update/SUSharedPrefs.h b/ee/tables/macos_software_update/SUSharedPrefs.h
index 71a6a254..f7646fd7 100644
--- a/ee/tables/macos_software_update/SUSharedPrefs.h
+++ b/ee/tables/macos_software_update/SUSharedPrefs.h
@@ -107,6 +107,5 @@
 - (BOOL)isValidAnyUserPreference:(id)arg1;
 - (BOOL)useBackgroundNSURLSession;
 - (void)reloadPreferences;
-- (BOOL)isAppStoreAutoUpdatesManaged;
 
 @end
diff --git a/ee/tables/macos_software_update/software_update_table.go b/ee/tables/macos_software_update/software_update_table.go
index a26798cb..1ae8061e 100644
--- a/ee/tables/macos_software_update/software_update_table.go
+++ b/ee/tables/macos_software_update/software_update_table.go
@@ -32,7 +32,6 @@ func MacOSUpdate(flags types.Flags, slogger *slog.Logger) *table.Plugin {
 		table.IntegerColumn("autoupdate_enabled"),
 		table.IntegerColumn("download_managed"),
 		table.IntegerColumn("download"),
-		table.IntegerColumn("app_updates_managed"),
 		table.IntegerColumn("app_updates"),
 		table.IntegerColumn("os_updates_managed"),
 		table.IntegerColumn("os_updates"),
@@ -66,7 +65,6 @@ func (table *osUpdateTable) generateMacUpdate(ctx context.Context, queryContext
 		isAutomaticallyCheckForUpdatesEnabled             = C.int(0)
 		isdoBackgroundDownloadManaged                     = C.int(0)
 		doesBackgroundDownload                            = C.int(0)
-		isAppStoreAutoUpdatesManaged                      = C.int(0)
 		doesAppStoreAutoUpdates                           = C.int(0)
 		doesOSXAutoUpdatesManaged                         = C.int(0)
 		doesOSXAutoUpdates                                = C.int(0)
@@ -81,7 +79,6 @@ func (table *osUpdateTable) generateMacUpdate(ctx context.Context, queryContext
 		&isAutomaticallyCheckForUpdatesEnabled,
 		&isdoBackgroundDownloadManaged,
 		&doesBackgroundDownload,
-		&isAppStoreAutoUpdatesManaged,
 		&doesAppStoreAutoUpdates,
 		&doesOSXAutoUpdatesManaged,
 		&doesOSXAutoUpdates,
@@ -97,7 +94,6 @@ func (table *osUpdateTable) generateMacUpdate(ctx context.Context, queryContext
 			"autoupdate_enabled":                   fmt.Sprintf("%d", isAutomaticallyCheckForUpdatesEnabled),
 			"download_managed":                     fmt.Sprintf("%d", isdoBackgroundDownloadManaged),
 			"download":                             fmt.Sprintf("%d", doesBackgroundDownload),
-			"app_updates_managed":                  fmt.Sprintf("%d", isAppStoreAutoUpdatesManaged),
 			"app_updates":                          fmt.Sprintf("%d", doesAppStoreAutoUpdates),
 			"os_updates_managed":                   fmt.Sprintf("%d", doesOSXAutoUpdatesManaged),
 			"os_updates":                           fmt.Sprintf("%d", doesOSXAutoUpdates),
diff --git a/ee/tables/macos_software_update/sus.h b/ee/tables/macos_software_update/sus.h
index 388e8e2d..1683a807 100644
--- a/ee/tables/macos_software_update/sus.h
+++ b/ee/tables/macos_software_update/sus.h
@@ -9,7 +9,6 @@ void getSoftwareUpdateConfiguration(
     int* isAutomaticallyCheckForUpdatesEnabled,
     int* isdoBackgroundDownloadManaged,
     int* doesBackgroundDownload,
-    int* isAppStoreAutoUpdatesManaged,
     int* doesAppStoreAutoUpdates,
     int* doesOSXAutoUpdatesManaged,
     int* doesOSXAutoUpdates,
diff --git a/ee/tables/macos_software_update/sus.m b/ee/tables/macos_software_update/sus.m
index e1d5b6d8..ed727f66 100644
--- a/ee/tables/macos_software_update/sus.m
+++ b/ee/tables/macos_software_update/sus.m
@@ -12,7 +12,6 @@ void getSoftwareUpdateConfiguration(
     int* isAutomaticallyCheckForUpdatesEnabled,
     int* isdoBackgroundDownloadManaged,
     int* doesBackgroundDownload,
-    int* isAppStoreAutoUpdatesManaged,
     int* doesAppStoreAutoUpdates,
     int* doesOSXAutoUpdatesManaged,
     int* doesOSXAutoUpdates,
@@ -57,8 +56,6 @@ void getSoftwareUpdateConfiguration(
                        : [manager doesBackgroundDownload];
   *doesBackgroundDownload = value ? 1 : 0;
 
-  *isAppStoreAutoUpdatesManaged =
-      [manager isAppStoreAutoUpdatesManaged] ? 1 : 0;
   *doesAppStoreAutoUpdates = [manager doesAppStoreAutoUpdates] ? 1 : 0;
 
   value = os_framework ? [settings automaticallyInstallOSUpdatesManaged]
diff --git a/ee/tuf/autoupdate.go b/ee/tuf/autoupdate.go
index 7c563548..592619c4 100644
--- a/ee/tuf/autoupdate.go
+++ b/ee/tuf/autoupdate.go
@@ -11,6 +11,7 @@ import (
 	"fmt"
 	"io"
 	"log/slog"
+	"math/rand"
 	"net/http"
 	"os"
 	"path"
@@ -54,7 +55,8 @@ var autoupdatableBinaryMap = map[string]autoupdatableBinary{
 }
 
 type ReleaseFileCustomMetadata struct {
-	Target string `json:"target"`
+	Target      string `json:"target"`
+	PromoteTime int64  `json:"promote_time"`
 }
 
 // Control server subsystem (used to send "update now" commands)
@@ -98,6 +100,7 @@ type TufAutoupdater struct {
 	signalRestart          chan error
 	slogger                *slog.Logger
 	restartFuncs           map[autoupdatableBinary]func(context.Context) error
+	calculatedSplayDelay   *atomic.Int64 // the randomly selected delay within the download splay window
 }
 
 type TufAutoupdaterOption func(*TufAutoupdater)
@@ -135,6 +138,7 @@ func NewTufAutoupdater(ctx context.Context, k types.Knapsack, metadataHttpClient
 		osquerierRetryInterval: 30 * time.Second,
 		slogger:                k.Slogger().With("component", "tuf_autoupdater"),
 		restartFuncs:           make(map[autoupdatableBinary]func(context.Context) error),
+		calculatedSplayDelay:   &atomic.Int64{},
 	}
 
 	for _, opt := range opts {
@@ -158,7 +162,7 @@ func NewTufAutoupdater(ctx context.Context, k types.Knapsack, metadataHttpClient
 	}
 
 	// Subscribe to changes in update-related flags
-	ta.knapsack.RegisterChangeObserver(ta, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion)
+	ta.knapsack.RegisterChangeObserver(ta, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion, keys.AutoupdateDownloadSplay)
 
 	return ta, nil
 }
@@ -247,7 +251,8 @@ func (ta *TufAutoupdater) Execute() (err error) {
 		ta.slogger.Log(context.TODO(), slog.LevelInfo,
 			"checking for updates",
 		)
-		if err := ta.checkForUpdate(context.TODO(), binaries); err != nil {
+		// always allow our AutoupdateDownloadSplay delay during routine checks for autoupdates
+		if err := ta.checkForUpdate(context.TODO(), binaries, true); err != nil {
 			observability.AutoupdateFailureCounter.Add(context.TODO(), 1)
 			ta.slogger.Log(context.TODO(), slog.LevelError,
 				"error checking for update",
@@ -335,7 +340,8 @@ func (ta *TufAutoupdater) Do(data io.Reader) error {
 		"binaries_to_update", fmt.Sprintf("%+v", binariesToUpdate),
 	)
 
-	if err := ta.checkForUpdate(ctx, binariesToUpdate); err != nil {
+	// do not allow AutoupdateDownloadSplay delay during autoupdate now requests
+	if err := ta.checkForUpdate(ctx, binariesToUpdate, false); err != nil {
 		observability.AutoupdateFailureCounter.Add(ctx, 1)
 		ta.slogger.Log(ctx, slog.LevelError,
 			"error checking for update per control server request",
@@ -360,6 +366,13 @@ func (ta *TufAutoupdater) FlagsChanged(ctx context.Context, flagKeys ...keys.Fla
 	ctx, span := observability.StartSpan(ctx)
 	defer span.End()
 
+	// check if our autoupdate download splay has changed- if so,
+	// reset our internally stored value and our calculated splay delay so
+	// that it will be recalculated next time it is required
+	if slices.Contains(flagKeys, keys.AutoupdateDownloadSplay) {
+		ta.calculatedSplayDelay.Store(0)
+	}
+
 	binariesToCheckForUpdate := make([]autoupdatableBinary, 0)
 
 	// Check to see if update channel has changed
@@ -396,7 +409,8 @@ func (ta *TufAutoupdater) FlagsChanged(ctx context.Context, flagKeys ...keys.Fla
 	}
 
 	// At least one binary requires a recheck -- perform that now
-	if err := ta.checkForUpdate(ctx, binariesToCheckForUpdate); err != nil {
+	// do not allow AutoupdateDownloadSplay delay when responding to flag changes
+	if err := ta.checkForUpdate(ctx, binariesToCheckForUpdate, false); err != nil {
 		observability.AutoupdateFailureCounter.Add(ctx, 1)
 		ta.slogger.Log(ctx, slog.LevelError,
 			"error checking for update after autoupdate setting changed",
@@ -479,7 +493,9 @@ func (ta *TufAutoupdater) currentRunningVersion(binary autoupdatableBinary) (str
 
 // checkForUpdate fetches latest metadata from the TUF server, then checks to see if there's
 // a new release that we should download. If so, it will add the release to our updates library.
-func (ta *TufAutoupdater) checkForUpdate(ctx context.Context, binariesToCheck []autoupdatableBinary) error {
+// If allowDelay is set to false, our knapsack.AutoupdateDownloadSplay will be ignored and the update
+// will be downloaded immediately, regardless of promotion time.
+func (ta *TufAutoupdater) checkForUpdate(ctx context.Context, binariesToCheck []autoupdatableBinary, allowDelay bool) error {
 	ctx, span := observability.StartSpan(ctx, "binaries", fmt.Sprintf("%+v", binariesToCheck))
 	defer span.End()
 
@@ -523,7 +539,7 @@ func (ta *TufAutoupdater) checkForUpdate(ctx context.Context, binariesToCheck []
 	updatesDownloaded := make(map[autoupdatableBinary]string)
 	updateErrors := make([]error, 0)
 	for _, binary := range binariesToCheck {
-		downloadedUpdateVersion, err := ta.downloadUpdate(binary, targets)
+		downloadedUpdateVersion, err := ta.downloadUpdate(binary, targets, allowDelay)
 		if err != nil {
 			updateErrors = append(updateErrors, fmt.Errorf("could not download update for %s: %w", binary, err))
 		}
@@ -586,8 +602,9 @@ func (ta *TufAutoupdater) checkForUpdate(ctx context.Context, binariesToCheck []
 }
 
 // downloadUpdate will download a new release for the given binary, if available from TUF
-// and not already downloaded.
-func (ta *TufAutoupdater) downloadUpdate(binary autoupdatableBinary, targets data.TargetFiles) (string, error) {
+// and not already downloaded. If allowDelay is true, the download may be delayed according to
+// the promotion time and the knapsack.AutoupdateDownloadSplay.
+func (ta *TufAutoupdater) downloadUpdate(binary autoupdatableBinary, targets data.TargetFiles, allowDelay bool) (string, error) {
 	target, targetMetadata, err := findTarget(context.Background(), binary, targets, ta.pinnedVersions[binary], ta.updateChannel, ta.slogger)
 	if err != nil {
 		return "", fmt.Errorf("could not find appropriate target: %w", err)
@@ -600,6 +617,11 @@ func (ta *TufAutoupdater) downloadUpdate(binary autoupdatableBinary, targets dat
 		return "", nil
 	}
 
+	// determine whether we should skip this check cycle if delaying the download
+	if allowDelay && ta.shouldDelayDownload(binary, targets) {
+		return "", nil
+	}
+
 	if ta.libraryManager.Available(binary, target) {
 		// The release is already available in the library but we don't know if we're running it --
 		// err on the side of not restarting.
@@ -709,6 +731,32 @@ func findRelease(ctx context.Context, binary autoupdatableBinary, targets data.T
 	return "", data.TargetFileMeta{}, fmt.Errorf("could not find metadata for release target %s for binary %s", releaseTarget, binary)
 }
 
+// findReleasePromoteTime extracts the promotion timestamp from the release file metadata for a given binary and channel.
+// It searches the TUF targets for the appropriate release.json file based on the binary, OS, architecture, and channel,
+// then unmarshals the custom metadata to retrieve the PromoteTime field.
+// Returns the Unix timestamp of when the release was promoted, or 0 if the release file is not found or cannot be parsed.
+func findReleasePromoteTime(ctx context.Context, binary autoupdatableBinary, targets data.TargetFiles, channel string) int64 {
+	_, span := observability.StartSpan(ctx)
+	defer span.End()
+
+	// find the metadata for the channel release file - this will include the promote_time
+	targetReleaseFile := path.Join(string(binary), runtime.GOOS, PlatformArch(), channel, "release.json")
+	for targetName, target := range targets {
+		if targetName != targetReleaseFile {
+			continue
+		}
+
+		var custom ReleaseFileCustomMetadata
+		if err := json.Unmarshal(*target.Custom, &custom); err != nil {
+			return 0
+		}
+
+		return custom.PromoteTime
+	}
+
+	return 0
+}
+
 // PlatformArch returns the correct arch for the runtime OS. For now, since osquery doesn't publish an arm64 release,
 // we use the universal binaries for darwin.
 func PlatformArch() string {
@@ -718,3 +766,77 @@ func PlatformArch() string {
 
 	return runtime.GOARCH
 }
+
+// shouldDelayDownload determines whether to delay downloading an update based on our AutoupdateDownloadSplay mechanism.
+// It returns false (no delay) if:
+// - AutoupdateDownloadSplay is disabled (set to 0s)
+// - the release promote time cannot be determined or is unset
+// - the promotion happened longer ago than the configured splay duration
+// Otherwise, it uses a randomly selected delay offset within the splay window,
+// returning true if the current time is before the calculated delay cutoff.
+func (ta *TufAutoupdater) shouldDelayDownload(binary autoupdatableBinary, targets data.TargetFiles) bool {
+	// if the splay is disabled, we should always download immediately
+	if ta.knapsack.AutoupdateDownloadSplay() == 0 {
+		return false
+	}
+
+	slogger := ta.slogger.With(
+		"download_splay_minutes", ta.knapsack.AutoupdateDownloadSplay().Minutes(),
+		"binary", binary,
+		"update_channel", ta.updateChannel,
+	)
+
+	releasePromotedAt := findReleasePromoteTime(context.TODO(), binary, targets, ta.updateChannel)
+	// if for any reason we can't determine the promote time, we should download immediately.
+	// this also covers the case where we have not published a promote time for whatever reason
+	if releasePromotedAt == 0 {
+		slogger.Log(context.TODO(), slog.LevelDebug, "no release promotion time found, will not delay download")
+		return false
+	}
+
+	promoteStart := time.Unix(releasePromotedAt, 0)
+	// if promotion happened greater than our max splay threshold, we should download immediately
+	if time.Since(promoteStart) > ta.knapsack.AutoupdateDownloadSplay() {
+		slogger.Log(context.TODO(), slog.LevelDebug,
+			"promote start was longer ago than download splay, will not delay download",
+			"promote_start", promoteStart,
+		)
+		return false
+	}
+
+	splayDelaySeconds := ta.getSplayDelaySeconds()
+	delayCutoffTime := time.Unix(releasePromotedAt+splayDelaySeconds, 0)
+	slogger.Log(context.TODO(), slog.LevelInfo,
+		"release promoted within splay time, determining download eligibility",
+		"promote_start", promoteStart,
+		"delay_seconds", splayDelaySeconds,
+		"delay_cutoff", delayCutoffTime,
+		"delay_minutes_from_now", time.Until(delayCutoffTime).Minutes(),
+		"will_delay", time.Now().Before(delayCutoffTime),
+	)
+
+	// we should delay unless the current time is after the delay cutoff selected
+	return time.Now().Before(delayCutoffTime)
+}
+
+// getSplayDelaySeconds returns a random delay value in seconds to indicate how long we should
+// wait after promotion_time before downloading the update.
+// This value is stable across a single launcher run- it uses a cached value if already calculated,
+// otherwise generates a new random value between 0 and the configured AutoupdateDownloadSplay duration in seconds.
+func (ta *TufAutoupdater) getSplayDelaySeconds() int64 {
+	currentValue := ta.calculatedSplayDelay.Load()
+	if currentValue != 0 {
+		return currentValue
+	}
+
+	maxSplayValue := int64(ta.knapsack.AutoupdateDownloadSplay().Seconds())
+	// we should never get here but no need to generate a random number if splay is disabled
+	if maxSplayValue == 0 {
+		return 0
+	}
+	// set a minimum of 1 second to ensure we don't set autoupdateDelaySplay to zero value
+	// and regenerate a new value on subsequent calls
+	newSplayValue := rand.Int63n(maxSplayValue) + 1
+	ta.calculatedSplayDelay.Store(newSplayValue)
+	return newSplayValue
+}



Test patch:
diff --git a/ee/agent/flags/flag_controller_test.go b/ee/agent/flags/flag_controller_test.go
index 12065ec2..88e7562f 100644
--- a/ee/agent/flags/flag_controller_test.go
+++ b/ee/agent/flags/flag_controller_test.go
@@ -221,6 +221,13 @@ func TestControllerDurationFlags(t *testing.T) {
 			valuesToSet: []time.Duration{1 * time.Second, 7 * time.Second, 20 * time.Minute},
 			valuesToGet: []time.Duration{5 * time.Second, 7 * time.Second, 20 * time.Minute},
 		},
+		{
+			name:        "AutoupdateDownloadSplay",
+			getFlag:     func(fc *FlagController) time.Duration { return fc.AutoupdateDownloadSplay() },
+			setFlag:     func(fc *FlagController, d time.Duration) error { return fc.SetAutoupdateDownloadSplay(d) },
+			valuesToSet: []time.Duration{0 * time.Second, 12 * time.Hour, 110 * time.Hour},
+			valuesToGet: []time.Duration{0 * time.Second, 12 * time.Hour, 72 * time.Hour},
+		},
 	}
 	for _, tt := range tests {
 		tt := tt
diff --git a/ee/tuf/autoupdate_test.go b/ee/tuf/autoupdate_test.go
index 1e068a27..bda84019 100644
--- a/ee/tuf/autoupdate_test.go
+++ b/ee/tuf/autoupdate_test.go
@@ -3,6 +3,7 @@ package tuf
 import (
 	"bytes"
 	"context"
+	_ "embed"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -10,9 +11,11 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"os"
+	"path"
 	"path/filepath"
 	"runtime"
 	"strings"
+	"sync/atomic"
 	"testing"
 	"time"
 
@@ -24,6 +27,7 @@ import (
 	"github.com/kolide/launcher/pkg/threadsafebuffer"
 	mock "github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/require"
+	"github.com/theupdateframework/go-tuf/data"
 )
 
 func TestNewTufAutoupdater(t *testing.T) {
@@ -40,7 +44,7 @@ func TestNewTufAutoupdater(t *testing.T) {
 	mockKnapsack.On("AutoupdateInitialDelay").Return(0 * time.Second)
 	mockKnapsack.On("PinnedLauncherVersion").Return("")
 	mockKnapsack.On("PinnedOsquerydVersion").Return("")
-	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion).Return()
+	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion, keys.AutoupdateDownloadSplay).Return()
 
 	_, err := NewTufAutoupdater(context.TODO(), mockKnapsack, http.DefaultClient, http.DefaultClient, newMockQuerier(t))
 	require.NoError(t, err, "could not initialize new TUF autoupdater")
@@ -86,7 +90,8 @@ func TestExecute_launcherUpdate(t *testing.T) {
 	mockKnapsack.On("LocalDevelopmentPath").Return("")
 	mockKnapsack.On("InModernStandby").Return(false)
 	mockKnapsack.On("LatestOsquerydPath", mock.Anything).Return(fakeOsqBinaryPath)
-	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion).Return()
+	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion, keys.AutoupdateDownloadSplay).Return()
+	mockKnapsack.On("AutoupdateDownloadSplay").Return(0 * time.Second)
 	mockQuerier := newMockQuerier(t)
 
 	// Set logger so that we can capture output
@@ -183,8 +188,9 @@ func TestExecute_osquerydUpdate(t *testing.T) {
 	mockKnapsack.On("UpdateDirectory").Return("")
 	mockKnapsack.On("MirrorServerURL").Return("https://example.com")
 	mockKnapsack.On("InModernStandby").Return(false)
-	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion).Return()
+	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion, keys.AutoupdateDownloadSplay).Return()
 	mockKnapsack.On("LatestOsquerydPath", mock.Anything).Return(fakeOsqBinaryPath)
+	mockKnapsack.On("AutoupdateDownloadSplay").Return(0 * time.Second)
 	mockQuerier := newMockQuerier(t)
 
 	// Set logger so that we can capture output
@@ -271,8 +277,9 @@ func TestExecute_downgrade(t *testing.T) {
 	mockKnapsack.On("UpdateDirectory").Return("")
 	mockKnapsack.On("MirrorServerURL").Return("https://example.com")
 	mockKnapsack.On("InModernStandby").Return(false)
-	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion).Return()
+	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion, keys.AutoupdateDownloadSplay).Return()
 	mockKnapsack.On("LatestOsquerydPath", mock.Anything).Return(fakeOsqBinaryPath)
+	mockKnapsack.On("AutoupdateDownloadSplay").Return(0 * time.Second)
 	mockQuerier := newMockQuerier(t)
 
 	// Set logger so that we can capture output
@@ -359,7 +366,7 @@ func TestExecute_withInitialDelay(t *testing.T) {
 	mockKnapsack.On("UpdateChannel").Return("nightly")
 	mockKnapsack.On("PinnedLauncherVersion").Return("")
 	mockKnapsack.On("PinnedOsquerydVersion").Return("")
-	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion).Return()
+	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion, keys.AutoupdateDownloadSplay).Return()
 	mockQuerier := newMockQuerier(t)
 
 	// Set logger so that we can capture output
@@ -424,7 +431,7 @@ func TestExecute_inModernStandby(t *testing.T) {
 	mockKnapsack.On("PinnedOsquerydVersion").Return("")
 	mockKnapsack.On("InModernStandby").Return(true)
 	mockKnapsack.On("Slogger").Return(multislogger.NewNopLogger())
-	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion).Return()
+	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion, keys.AutoupdateDownloadSplay).Return()
 	mockKnapsack.On("LatestOsquerydPath", mock.Anything).Return(fakeOsqBinaryPath)
 	mockQuerier := newMockQuerier(t)
 
@@ -487,7 +494,7 @@ func TestInterrupt_Multiple(t *testing.T) {
 	mockKnapsack.On("PinnedLauncherVersion").Return("")
 	mockKnapsack.On("PinnedOsquerydVersion").Return("")
 	mockKnapsack.On("InModernStandby").Return(false)
-	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion).Return()
+	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion, keys.AutoupdateDownloadSplay).Return()
 	mockKnapsack.On("LatestOsquerydPath", mock.Anything).Return(fakeOsqBinaryPath)
 	mockQuerier := newMockQuerier(t)
 
@@ -623,9 +630,8 @@ func TestDo(t *testing.T) {
 			mockQuerier := newMockQuerier(t)
 			mockKnapsack.On("Slogger").Return(multislogger.NewNopLogger())
 			mockKnapsack.On("InModernStandby").Return(false)
-			mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion).Return()
+			mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion, keys.AutoupdateDownloadSplay).Return()
 			mockKnapsack.On("LatestOsquerydPath", mock.Anything).Return(fakeOsqBinaryPath).Maybe()
-
 			// Set up autoupdater
 			autoupdater, err := NewTufAutoupdater(context.TODO(), mockKnapsack, http.DefaultClient, http.DefaultClient, mockQuerier, WithOsqueryRestart(func(context.Context) error { return nil }))
 			require.NoError(t, err, "could not initialize new TUF autoupdater")
@@ -696,8 +702,9 @@ func TestDo_HandlesSimultaneousUpdates(t *testing.T) {
 	mockKnapsack.On("InModernStandby").Return(false)
 	mockQuerier := newMockQuerier(t)
 	mockKnapsack.On("Slogger").Return(multislogger.NewNopLogger())
-	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion).Return()
+	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion, keys.AutoupdateDownloadSplay).Return()
 	mockKnapsack.On("LatestOsquerydPath", mock.Anything).Return(fakeOsqBinaryPath)
+	mockKnapsack.On("AutoupdateDownloadSplay").Return(0 * time.Second)
 
 	// Set up autoupdater
 	autoupdater, err := NewTufAutoupdater(context.TODO(), mockKnapsack, http.DefaultClient, http.DefaultClient, mockQuerier, WithOsqueryRestart(func(context.Context) error { return nil }))
@@ -779,8 +786,9 @@ func TestDo_WillNotExecuteDuringInitialDelay(t *testing.T) {
 	mockQuerier := newMockQuerier(t)
 	mockKnapsack.On("Slogger").Return(multislogger.NewNopLogger())
 	mockKnapsack.On("InModernStandby").Return(false)
-	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion).Return()
+	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion, keys.AutoupdateDownloadSplay).Return()
 	mockKnapsack.On("LatestOsquerydPath", mock.Anything).Return(fakeOsqBinaryPath)
+	mockKnapsack.On("AutoupdateDownloadSplay").Return(0 * time.Second)
 
 	// Set up autoupdater
 	autoupdater, err := NewTufAutoupdater(context.TODO(), mockKnapsack, http.DefaultClient, http.DefaultClient, mockQuerier, WithOsqueryRestart(func(context.Context) error { return nil }))
@@ -856,7 +864,7 @@ func TestFlagsChanged_UpdateChannelChanged(t *testing.T) {
 	mockKnapsack.On("PinnedLauncherVersion").Return("")
 	mockKnapsack.On("PinnedOsquerydVersion").Return("")
 	mockKnapsack.On("InModernStandby").Return(false)
-	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion).Return()
+	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion, keys.AutoupdateDownloadSplay).Return()
 	mockKnapsack.On("LatestOsquerydPath", mock.Anything).Return(fakeOsqBinaryPath)
 
 	// Start out on beta channel, then swap to nightly
@@ -921,7 +929,7 @@ func TestFlagsChanged_PinnedVersionChanged(t *testing.T) {
 	mockKnapsack.On("UpdateChannel").Return("nightly")
 	mockKnapsack.On("PinnedLauncherVersion").Return("")
 	mockKnapsack.On("InModernStandby").Return(false)
-	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion).Return()
+	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion, keys.AutoupdateDownloadSplay).Return()
 	mockKnapsack.On("LatestOsquerydPath", mock.Anything).Return(fakeOsqBinaryPath)
 
 	// Start out with no pinned version, then set a pinned version
@@ -978,7 +986,7 @@ func TestFlagsChanged_DuringInitialDelay(t *testing.T) {
 	mockKnapsack.On("MirrorServerURL").Return("https://example.com")
 	mockQuerier := newMockQuerier(t)
 	mockKnapsack.On("Slogger").Return(multislogger.NewNopLogger())
-	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion).Return()
+	mockKnapsack.On("RegisterChangeObserver", mock.Anything, keys.UpdateChannel, keys.PinnedLauncherVersion, keys.PinnedOsquerydVersion, keys.AutoupdateDownloadSplay).Return()
 
 	// Start out with a pinned version, then unset the pinned version
 	pinnedLauncherVersion := "1.7.3"
@@ -1085,3 +1093,226 @@ func Test_currentRunningVersion_osqueryd_handlesQueryError(t *testing.T) {
 	require.Error(t, err, "expected an error returning osquery version when querying osquery fails")
 	require.Equal(t, "", osqueryVersion)
 }
+
+//go:embed testdata/test_promote_time_target_files.json
+var sampleTargetJson []byte
+
+func getSampleTargets(t *testing.T) data.TargetFiles {
+	var targetFiles data.TargetFiles
+	err := json.Unmarshal(sampleTargetJson, &targetFiles)
+	require.NoError(t, err, "expected to be able to unmarshal sample json into data.TargetFiles")
+	return targetFiles
+}
+
+func Test_findReleasePromoteTime(t *testing.T) {
+	t.Parallel()
+
+	// note that the sample targets file json is pre-curated so that the the promotion times
+	// match across OS and arch to simplify testing across platforms here
+	targets := getSampleTargets(t)
+	tests := []struct {
+		name                string
+		binary              autoupdatableBinary
+		channel             string
+		expectedPromoteTime int64
+	}{
+		{
+			name:                "osqueryd with valid alpha targets",
+			binary:              "osqueryd",
+			channel:             "alpha",
+			expectedPromoteTime: 1750955751,
+		},
+		{
+			name:                "osqueryd with valid nightly targets",
+			binary:              "osqueryd",
+			channel:             "nightly",
+			expectedPromoteTime: 1750870406,
+		},
+		{
+			name:                "osqueryd with valid stable targets",
+			binary:              "osqueryd",
+			channel:             "stable",
+			expectedPromoteTime: 1750954246,
+		},
+		{
+			// note that this is testing our zero value behavior,
+			// all beta targets are in the sample targets file are intentionally missing promote_time
+			name:                "osqueryd with missing beta target promotion times",
+			binary:              "osqueryd",
+			channel:             "beta",
+			expectedPromoteTime: 0,
+		},
+		{
+			name:                "launcher with valid alpha targets",
+			binary:              "launcher",
+			channel:             "alpha",
+			expectedPromoteTime: 1750961031,
+		},
+		{
+			name:                "launcher with valid nightly targets",
+			binary:              "launcher",
+			channel:             "nightly",
+			expectedPromoteTime: 1750859203,
+		},
+		{
+			name:                "launcher with valid stable targets",
+			binary:              "launcher",
+			channel:             "stable",
+			expectedPromoteTime: 1750955736,
+		},
+		{
+			// note that this is testing our zero value behavior,
+			// all beta targets are in the sample targets file are intentionally missing promote_time
+			name:                "launcher with missing beta target promotion times",
+			binary:              "launcher",
+			channel:             "beta",
+			expectedPromoteTime: 0,
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			promoteTime := findReleasePromoteTime(context.Background(), tt.binary, targets, tt.channel)
+			require.Equal(t, tt.expectedPromoteTime, promoteTime)
+		})
+	}
+}
+
+func Test_shouldDelayDownloadRespectsDisabledSplay(t *testing.T) {
+	t.Parallel()
+
+	mockKnapsack := typesmocks.NewKnapsack(t)
+	mockKnapsack.On("AutoupdateDownloadSplay").Return(0 * time.Second)
+
+	autoupdater := &TufAutoupdater{
+		slogger:              multislogger.NewNopLogger(),
+		knapsack:             mockKnapsack,
+		calculatedSplayDelay: &atomic.Int64{},
+	}
+
+	require.False(t, autoupdater.shouldDelayDownload(autoupdatableBinary("osqueryd"), data.TargetFiles{}))
+}
+
+func Test_shouldDelayDownloadDoesNotDelayWithoutPromoteTime(t *testing.T) {
+	t.Parallel()
+
+	mockKnapsack := typesmocks.NewKnapsack(t)
+	mockKnapsack.On("AutoupdateDownloadSplay").Return(8 * time.Hour)
+
+	autoupdater := &TufAutoupdater{
+		slogger:              multislogger.NewNopLogger(),
+		knapsack:             mockKnapsack,
+		updateChannel:        "beta", // none of the beta targets have promote_time set
+		calculatedSplayDelay: &atomic.Int64{},
+	}
+
+	targets := getSampleTargets(t)
+	require.False(t, autoupdater.shouldDelayDownload(autoupdatableBinary("osqueryd"), targets))
+	require.False(t, autoupdater.shouldDelayDownload(autoupdatableBinary("launcher"), targets))
+}
+
+func Test_shouldDelayDownloadDoesNotDelayIfPromoteTimeExceedsSplay(t *testing.T) {
+	t.Parallel()
+
+	mockKnapsack := typesmocks.NewKnapsack(t)
+	mockKnapsack.On("AutoupdateDownloadSplay").Return(2 * time.Hour)
+
+	autoupdater := &TufAutoupdater{
+		slogger:              multislogger.NewNopLogger(),
+		knapsack:             mockKnapsack,
+		updateChannel:        "alpha", // all promote_times in here will always be greater than 2 hours ago (splay time)
+		calculatedSplayDelay: &atomic.Int64{},
+	}
+
+	targets := getSampleTargets(t)
+	require.False(t, autoupdater.shouldDelayDownload(autoupdatableBinary("osqueryd"), targets))
+	require.False(t, autoupdater.shouldDelayDownload(autoupdatableBinary("launcher"), targets))
+}
+
+func Test_shouldDelayDownloadDelaysIfPromotedWithinSplay(t *testing.T) {
+	t.Parallel()
+
+	mockKnapsack := typesmocks.NewKnapsack(t)
+	mockKnapsack.On("AutoupdateDownloadSplay").Return(8 * time.Hour)
+
+	autoupdater := &TufAutoupdater{
+		slogger:              multislogger.NewNopLogger(),
+		knapsack:             mockKnapsack,
+		updateChannel:        "alpha",
+		calculatedSplayDelay: &atomic.Int64{},
+	}
+
+	targets := getSampleTargets(t)
+	for _, binary := range []string{"osqueryd", "launcher"} {
+		// find the release file from samples and update the promote time to be within the splay time
+		targetReleaseFile := path.Join(binary, runtime.GOOS, PlatformArch(), autoupdater.updateChannel, "release.json")
+		existingTarget := targets[targetReleaseFile]
+		// setting promote time to be 5 minutes after splay time so there's no way it should not be delayed, regardless of uuid hash or seed
+		updatedPromote := fmt.Sprintf(`{"promote_time": %d}`, time.Now().Add(5*time.Minute).Unix())
+		newCustomMetadata := json.RawMessage([]byte(updatedPromote))
+		existingTarget.Custom = &newCustomMetadata
+		targets[targetReleaseFile] = existingTarget
+
+		require.True(t, autoupdater.shouldDelayDownload(autoupdatableBinary(binary), targets))
+	}
+}
+
+func Test_splayDelaySecondsReturnsConsistentDelay(t *testing.T) {
+	t.Parallel()
+
+	downloadSplayDuration := 8 * time.Hour
+	mockKnapsack := typesmocks.NewKnapsack(t)
+	mockKnapsack.On("AutoupdateDownloadSplay").Return(downloadSplayDuration)
+
+	autoupdater := &TufAutoupdater{
+		slogger:              multislogger.NewNopLogger(),
+		knapsack:             mockKnapsack,
+		updateChannel:        "alpha",
+		calculatedSplayDelay: &atomic.Int64{},
+	}
+	initialDelay := autoupdater.getSplayDelaySeconds()
+
+	// initial delay should have a minimum value of 1,
+	// and should never exceed our AutoupdateDownloadSplay time in seconds
+	require.GreaterOrEqual(t, initialDelay, int64(1))
+	require.LessOrEqual(t, initialDelay, int64(downloadSplayDuration.Seconds()))
+
+	for range 3 {
+		require.Equal(t, initialDelay, autoupdater.getSplayDelaySeconds())
+	}
+}
+
+func Test_FlagsChangedAutoupdateDownloadSplayResetsCalculatedSplay(t *testing.T) {
+	t.Parallel()
+
+	downloadSplayDuration := 8 * time.Hour
+	mockKnapsack := typesmocks.NewKnapsack(t)
+	mockKnapsack.On("AutoupdateDownloadSplay").Return(downloadSplayDuration)
+	mockKnapsack.On("UpdateChannel").Return("alpha")
+
+	autoupdater := &TufAutoupdater{
+		slogger:              multislogger.NewNopLogger(),
+		knapsack:             mockKnapsack,
+		updateChannel:        "alpha",
+		calculatedSplayDelay: &atomic.Int64{},
+		pinnedVersions:       map[autoupdatableBinary]string{},
+	}
+	initialDelay := autoupdater.getSplayDelaySeconds()
+
+	// initial delay should have a minimum value of 1,
+	// and should never exceed our AutoupdateDownloadSplay time in seconds
+	require.GreaterOrEqual(t, initialDelay, int64(1))
+	require.LessOrEqual(t, initialDelay, int64(downloadSplayDuration.Seconds()))
+
+	autoupdater.FlagsChanged(context.TODO(), keys.AutoupdateDownloadSplay)
+	// verify that it was reset
+	require.Equal(t, int64(0), autoupdater.calculatedSplayDelay.Load())
+
+	// verify that it is repopulated on next read
+	newDelay := autoupdater.getSplayDelaySeconds()
+	require.GreaterOrEqual(t, newDelay, int64(1))
+	require.LessOrEqual(t, newDelay, int64(downloadSplayDuration.Seconds()))
+}
diff --git a/ee/tuf/testdata/test_promote_time_target_files.json b/ee/tuf/testdata/test_promote_time_target_files.json
new file mode 100644
index 00000000..51b3d115
--- /dev/null
+++ b/ee/tuf/testdata/test_promote_time_target_files.json
@@ -0,0 +1,532 @@
+{
+    "launcher/darwin/universal/stable/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/darwin/universal/launcher-1.22.0.tar.gz",
+            "promote_time": 1750955736
+        }
+    },
+    "launcher/windows/amd64/stable/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/windows/amd64/launcher-1.22.0.tar.gz",
+            "promote_time": 1750955736
+        }
+    },
+    "launcher/windows/arm64/stable/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/windows/arm64/launcher-1.22.0.tar.gz",
+            "promote_time": 1750955736
+        }
+    },
+    "launcher/linux/arm64/stable/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/linux/arm64/launcher-1.22.0.tar.gz",
+            "promote_time": 1750955736
+        }
+    },
+    "launcher/linux/amd64/stable/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/linux/amd64/launcher-1.22.0.tar.gz",
+            "promote_time": 1750955736
+        }
+    },
+    "launcher/darwin/universal/beta/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/darwin/universal/launcher-1.22.0.tar.gz"
+        }
+    },
+    "launcher/windows/amd64/beta/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/windows/amd64/launcher-1.22.0.tar.gz"
+        }
+    },
+    "launcher/windows/arm64/beta/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/windows/arm64/launcher-1.22.0.tar.gz"
+        }
+    },
+    "launcher/linux/arm64/beta/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/linux/arm64/launcher-1.22.0.tar.gz"
+        }
+    },
+    "launcher/linux/amd64/beta/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/linux/amd64/launcher-1.22.0.tar.gz"
+        }
+    },
+    "launcher/darwin/amd64/alpha/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/darwin/amd64/launcher-1.22.0.tar.gz",
+            "promote_time": 1750955736
+        }
+    },
+    "launcher/darwin/amd64/nightly/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/darwin/amd64/launcher-1.22.0-8-g3d775255.tar.gz",
+            "promote_time": 1750859205
+        }
+    },
+    "launcher/darwin/arm64/alpha/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/darwin/arm64/launcher-1.22.0.tar.gz",
+            "promote_time": 1750955735
+        }
+    },
+    "launcher/darwin/arm64/nightly/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/darwin/arm64/launcher-1.22.0-1-gdd5f2441.tar.gz",
+            "promote_time": 1750954243
+        }
+    },
+    "launcher/darwin/universal/alpha/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/darwin/universal/launcher-1.22.0.tar.gz",
+            "promote_time": 1750961031
+        }
+    },
+    "launcher/darwin/universal/nightly/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/darwin/universal/launcher-1.22.0-8-g3d775255.tar.gz",
+            "promote_time": 1750859203
+        }
+    },
+    "launcher/linux/amd64/alpha/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/linux/amd64/launcher-1.22.0.tar.gz",
+            "promote_time": 1750961031
+        }
+    },
+    "launcher/linux/amd64/nightly/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/linux/amd64/launcher-1.22.0-8-g3d775255.tar.gz",
+            "promote_time": 1750859203
+        }
+    },
+    "launcher/linux/arm64/alpha/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/linux/arm64/launcher-1.22.0.tar.gz",
+            "promote_time": 1750961031
+        }
+    },
+    "osqueryd/windows/arm64/beta/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "osqueryd/windows/arm64/osqueryd-5.17.0.tar.gz"
+        }
+    },
+    "osqueryd/windows/amd64/beta/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "osqueryd/windows/amd64/osqueryd-5.17.0.tar.gz"
+        }
+    },
+    "osqueryd/linux/amd64/beta/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "osqueryd/linux/amd64/osqueryd-5.17.0.tar.gz"
+        }
+    },
+    "osqueryd/linux/arm64/beta/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "osqueryd/linux/arm64/osqueryd-5.17.0.tar.gz"
+        }
+    },
+    "osqueryd/darwin/universal/beta/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "osqueryd/darwin/universal/osqueryd-5.17.0.tar.gz"
+        }
+    },
+    "osqueryd/windows/arm64/stable/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "osqueryd/windows/arm64/osqueryd-5.17.0.tar.gz",
+            "promote_time": 1750954246
+        }
+    },
+    "osqueryd/windows/amd64/stable/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "osqueryd/windows/amd64/osqueryd-5.17.0.tar.gz",
+            "promote_time": 1750954246
+        }
+    },
+    "osqueryd/linux/amd64/stable/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "osqueryd/linux/amd64/osqueryd-5.17.0.tar.gz",
+            "promote_time": 1750954246
+        }
+    },
+    "osqueryd/linux/arm64/stable/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "osqueryd/linux/arm64/osqueryd-5.17.0.tar.gz",
+            "promote_time": 1750954246
+        }
+    },
+    "osqueryd/darwin/universal/stable/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "osqueryd/darwin/universal/osqueryd-5.17.0.tar.gz",
+            "promote_time": 1750954246
+        }
+    },
+    "launcher/linux/arm64/launcher-1.22.0-1-gdd5f2441.tar.gz": {
+        "length": 10782666,
+        "hashes": {
+            "sha512": "66074fee703ec07c352fe09ea817544a9d795d1c8c7fa32c20697c79daf2e4b3109277c207aade0fb511d0a67c7d85a62249e66705e6de641c231bc986af80a1"
+        },
+        "custom": {
+            "publish_time": 1750954246,
+            "publisher": "porter"
+        }
+    },
+    "launcher/linux/arm64/launcher-1.22.0-5-gc5205b76.tar.gz": {
+        "length": 10782650,
+        "hashes": {
+            "sha512": "186b1fb5956526aa5c3a5d6de49947f3a3cfe7c7e5394282fe5087847b310b5e06e73d2b25dd7f098e12273e2a94ba974268f4823ff8e62ae75df79368d834b6"
+        },
+        "custom": {
+            "publish_time": 1750952177,
+            "publisher": "porter"
+        }
+    },
+    "launcher/linux/arm64/launcher-1.22.0.tar.gz": {
+        "length": 10782458,
+        "hashes": {
+            "sha512": "5e449dcf0038cd849c9c6649b8455200f5633d27a95289dbf431fb897ebadf3e88fe5674c638be72f3b22c1f7acb6b733392a81d575e9ab3d1fa65be6663c579"
+        },
+        "custom": {
+            "publish_time": 1750953738,
+            "publisher": "porter"
+        }
+    },
+    "launcher/linux/arm64/nightly/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/linux/arm64/launcher-1.22.0-1-gdd5f2441.tar.gz",
+            "promote_time": 1750859203
+        }
+    },
+    "launcher/windows/amd64/alpha/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/windows/amd64/launcher-1.22.0.tar.gz",
+            "promote_time": 1750961031
+        }
+    },
+    "launcher/windows/amd64/nightly/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/windows/amd64/launcher-1.22.0-8-g3d775255.tar.gz",
+            "promote_time": 1750859203
+        }
+    },
+    "launcher/windows/arm64/alpha/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/windows/arm64/launcher-1.22.0.tar.gz",
+            "promote_time": 1750961031
+        }
+    },
+    "launcher/windows/arm64/launcher-1.22.0-1-gdd5f2441.tar.gz": {
+        "length": 9550554,
+        "hashes": {
+            "sha512": "3325c2e79bd1986fa7d300cef51686d415e2cefd086c9f359ae1ba1c7b20854d6b7f5961b4e2ed2b3b83f17be1cb13add079ef474e9dbcf1449f6fc778f01c5d"
+        },
+        "custom": {
+            "publish_time": 1750954244,
+            "publisher": "porter"
+        }
+    },
+    "launcher/windows/arm64/launcher-1.22.0-5-gc5205b76.tar.gz": {
+        "length": 9550542,
+        "hashes": {
+            "sha512": "1b49f06f1405bc89b3ed863cd9a152e95884006bbaa3d17c7024870c64fbbab321029ff2d25b19ecc68bf101435c5a8c6963712c202e384b976881feda3b5da4"
+        },
+        "custom": {
+            "publish_time": 1750952175,
+            "publisher": "porter"
+        }
+    },
+    "launcher/windows/arm64/launcher-1.22.0.tar.gz": {
+        "length": 9550633,
+        "hashes": {
+            "sha512": "5ab37274893cf63969e9f9361b6883eaefbbf8c9d17bdb158f2531174a885e550968397b4c4458f66347fe71e1b11a1c63878c93f3584a57e58655a1a93e21d2"
+        },
+        "custom": {
+            "publish_time": 1750953736,
+            "publisher": "porter"
+        }
+    },
+    "launcher/windows/arm64/nightly/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "launcher/windows/arm64/launcher-1.22.0-1-gdd5f2441.tar.gz",
+            "promote_time": 1750859203
+        }
+    },
+    "osqueryd/darwin/universal/alpha/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "osqueryd/darwin/universal/osqueryd-5.17.0.tar.gz",
+            "promote_time": 1750955751
+        }
+    },
+    "osqueryd/darwin/universal/nightly/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "osqueryd/darwin/universal/osqueryd-5.17.0.tar.gz",
+            "promote_time": 1750870406
+        }
+    },
+    "osqueryd/linux/amd64/alpha/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "osqueryd/linux/amd64/osqueryd-5.17.0.tar.gz",
+            "promote_time": 1750955751
+        }
+    },
+    "osqueryd/linux/amd64/nightly/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "osqueryd/linux/amd64/osqueryd-5.17.0.tar.gz",
+            "promote_time": 1750870406
+        }
+    },
+    "osqueryd/linux/arm64/alpha/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "osqueryd/linux/arm64/osqueryd-5.17.0.tar.gz",
+            "promote_time": 1750955751
+        }
+    },
+    "osqueryd/linux/arm64/nightly/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "osqueryd/linux/arm64/osqueryd-5.17.0.tar.gz",
+            "promote_time": 1750870406
+        }
+    },
+    "osqueryd/windows/amd64/alpha/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "osqueryd/windows/amd64/osqueryd-5.17.0.tar.gz",
+            "promote_time": 1750955751
+        }
+    },
+    "osqueryd/windows/amd64/nightly/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "osqueryd/windows/amd64/osqueryd-5.17.0.tar.gz",
+            "promote_time": 1750870406
+        }
+    },
+    "osqueryd/windows/arm64/alpha/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "osqueryd/windows/arm64/osqueryd-5.17.0.tar.gz",
+            "promote_time": 1750955751
+        }
+    },
+    "osqueryd/windows/arm64/nightly/release.json": {
+        "length": 2,
+        "hashes": {
+            "sha512": "27c74670adb75075fad058d5ceaf7b20c4e7786c83bae8a32f626f9782af34c9a33c2046ef60fd2a7878d378e29fec851806bbd9a67878f3a9f1cda4830763fd"
+        },
+        "custom": {
+            "target": "osqueryd/windows/arm64/osqueryd-5.17.0.tar.gz",
+            "promote_time": 1750870406
+        }
+    },
+    "launcher/darwin/arm64/launcher-1.22.0-1-gdd5f2441.tar.gz": {
+        "length": 19622378,
+        "hashes": {
+            "sha512": "e7a86484e8807f4415b3894b9326ef756a9ae0cb28ae33b172e00b5da3b671aa761eb34ae22b08bb827cbdc08a755c396e4718618b307428f3056f3877b499b4"
+        },
+        "custom": {
+            "publish_time": 1750954241,
+            "publisher": "porter"
+        }
+    },
+    "launcher/darwin/arm64/launcher-1.22.0-5-gc5205b76.tar.gz": {
+        "length": 19603910,
+        "hashes": {
+            "sha512": "93f7ef2ac486c3df5c8ce6ee8b7ffae70d10f952e385d3ef5b4451d2b16f8e90103806d826f23be19c9ceefb98b09b6c4b03794956ae2dbcbad2ece4cbcae47d"
+        },
+        "custom": {
+            "publish_time": 1750952172,
+            "publisher": "porter"
+        }
+    },
+    "launcher/darwin/arm64/launcher-1.22.0.tar.gz": {
+        "length": 19622705,
+        "hashes": {
+            "sha512": "eaed8c4181bf2d69ccfbe4858fde33a11f9df62851920b03519edbf8e0bc2108e1f685202a2fb7abb464c4f12b72790f9ce13b5f7436ca54e6f8a998f5622bb9"
+        },
+        "custom": {
+            "publish_time": 1750953733,
+            "publisher": "porter"
+        }
+    },
+    "launcher/darwin/universal/launcher-1.22.0.tar.gz": {
+        "length": 40621571,
+        "hashes": {
+            "sha512": "b1dee8b95a244c7a88489445a6a3fffec1e2d2c6e5fdc8bca42bd0b45e25a42683994cc251059cf32ac3f971abfc1f3f16dca1ca9089f98842ec38bfeccb3e25"
+        },
+        "custom": {
+            "publish_time": 1750961030,
+            "publisher": "porter"
+        }
+    }
+}
\ No newline at end of file