Repository: akuity/kargo
Pull request #3731 (2025-03-27 22:19:23)

fix: soak time bug
I stumbled upon this bug today and it seemed serious enough to fix immediately.

It's been around since #3100, when soak time was first introduced.

The original strategy for assessing whether soak time had elapsed involved ensuring verification time was in the sufficiently distant past, but that offered no guarantee of how long the Freight had actually _spent_ in the Stage in question, which was exposed during review. The PR was subsequently amended to track how long a piece of Freight actually spent in any given Stage...

Except, as it turns out, I only actually fixed it for `stage.IsFreightAvailable(...)`, which answers whether a single piece of Freight is available to a given Stage.

I _missed_ the `ListFreightFromWarehouse()` which lists all Freight from a given Warehouse, with optional constraints pertaining to verification, approval, soak time, etc.

This PR fixes it.

The flawed logic (listing) was used in finding Freight _available_ for promotion to a given Stage, but the correct logic was used to validate Promotion requests received by the API server and also to validate Promotion resources themselves in a validating webhook.

The net effect of this is that some Promotions that should not have been allowed may have been created, but validation wouldn't have permitted them to proceed. More than anything else, it may have resulted in some head scratching.


Patch:
diff --git a/charts/kargo/templates/_helpers.tpl b/charts/kargo/templates/_helpers.tpl
index be7bac42..d4eff2fb 100644
--- a/charts/kargo/templates/_helpers.tpl
+++ b/charts/kargo/templates/_helpers.tpl
@@ -9,7 +9,6 @@ Expand the name of the chart.
 {{/*
 Create image reference as used by resources.
 */}}
-
 {{- define "kargo.image" -}}
 {{- $tag := default .Chart.AppVersion .Values.image.tag -}}
 {{- printf "%s:%s" .Values.image.repository $tag -}}
@@ -91,6 +90,9 @@ app.kubernetes.io/component: management-controller
 app.kubernetes.io/component: webhooks-server
 {{- end -}}
 
+{{/*
+Generate the base URL for the API service.
+*/}}
 {{- define "kargo.api.baseURL" -}}
 {{- if or (and .Values.api.ingress.enabled .Values.api.ingress.tls.enabled) (and (not .Values.api.ingress.enabled) .Values.api.tls.enabled) .Values.api.tls.terminatedUpstream -}}
 {{- printf "https://%s" .Values.api.host -}}
@@ -99,6 +101,23 @@ app.kubernetes.io/component: webhooks-server
 {{- end -}}
 {{- end -}}
 
+{{/*
+Determine the most appropriate CPU resource field for GOMAXPROCS.
+Prioritizes limits over requests, with a fallback to limits if neither is set.
+*/}}
+{{- define "kargo.selectCpuResourceField" -}}
+  {{- $resources := .resources -}}
+  {{- $hasLimits := and $resources (hasKey $resources "limits") (ne (toString $resources.limits.cpu) "") -}}
+  {{- $hasRequests := and $resources (hasKey $resources "requests") (ne (toString $resources.requests.cpu) "") -}}
+  {{- if $hasLimits -}}
+    limits.cpu
+  {{- else if $hasRequests -}}
+    requests.cpu
+  {{- else -}}
+    limits.cpu
+  {{- end -}}
+{{- end -}}
+
 {{- define "call-nested" }}
 {{- $dot := index . 0 }}
 {{- $subchart := index . 1 }}
diff --git a/charts/kargo/templates/api/deployment.yaml b/charts/kargo/templates/api/deployment.yaml
index ea3549de..78721d1e 100644
--- a/charts/kargo/templates/api/deployment.yaml
+++ b/charts/kargo/templates/api/deployment.yaml
@@ -71,7 +71,7 @@ spec:
               resourceFieldRef:
                 containerName: api
                 divisor: "1"
-                resource: limits.cpu
+                resource: {{ include "kargo.selectCpuResourceField" (dict "resources" .Values.api.resources) }}
           {{- with (concat .Values.global.env .Values.api.env) }}
           {{- toYaml . | nindent 10 }}
           {{- end }}
diff --git a/charts/kargo/templates/controller/deployment.yaml b/charts/kargo/templates/controller/deployment.yaml
index 4d85f99d..8880a0ae 100644
--- a/charts/kargo/templates/controller/deployment.yaml
+++ b/charts/kargo/templates/controller/deployment.yaml
@@ -71,7 +71,7 @@ spec:
             resourceFieldRef:
               containerName: controller
               divisor: "1"
-              resource: limits.cpu
+              resource: {{ include "kargo.selectCpuResourceField" (dict "resources" .Values.controller.resources) }}
         {{- with (concat .Values.global.env .Values.controller.env) }}
         {{- toYaml . | nindent 8 }}
         {{- end }}
diff --git a/charts/kargo/templates/dex-server/deployment.yaml b/charts/kargo/templates/dex-server/deployment.yaml
index 0a6fc0ee..d26a0dac 100644
--- a/charts/kargo/templates/dex-server/deployment.yaml
+++ b/charts/kargo/templates/dex-server/deployment.yaml
@@ -54,7 +54,7 @@ spec:
             resourceFieldRef:
               containerName: dex-server
               divisor: "1"
-              resource: limits.cpu
+              resource: {{ include "kargo.selectCpuResourceField" (dict "resources" .Values.api.oidc.dex.resources) }}
         {{- with (concat .Values.global.env .Values.api.oidc.dex.env) }}
         {{- toYaml . | nindent 8 }}
         {{- end }}
diff --git a/charts/kargo/templates/garbage-collector/cron-job.yaml b/charts/kargo/templates/garbage-collector/cron-job.yaml
index c2c1b024..449b4f45 100644
--- a/charts/kargo/templates/garbage-collector/cron-job.yaml
+++ b/charts/kargo/templates/garbage-collector/cron-job.yaml
@@ -70,7 +70,7 @@ spec:
                 resourceFieldRef:
                   containerName: garbage-collector
                   divisor: "1"
-                  resource: limits.cpu
+                  resource: {{ include "kargo.selectCpuResourceField" (dict "resources" .Values.garbageCollector.resources) }}
             {{- with (concat .Values.global.env .Values.garbageCollector.env) }}
             {{- toYaml . | nindent 12 }}
             {{- end }}
diff --git a/charts/kargo/templates/management-controller/deployment.yaml b/charts/kargo/templates/management-controller/deployment.yaml
index cf8123dd..5ebcca6a 100644
--- a/charts/kargo/templates/management-controller/deployment.yaml
+++ b/charts/kargo/templates/management-controller/deployment.yaml
@@ -71,7 +71,7 @@ spec:
             resourceFieldRef:
               containerName: management-controller
               divisor: "1"
-              resource: limits.cpu
+              resource: {{ include "kargo.selectCpuResourceField" (dict "resources" .Values.managementController.resources) }}
         {{- with (concat .Values.global.env .Values.managementController.env) }}
         {{- toYaml . | nindent 8 }}
         {{- end }}
diff --git a/charts/kargo/templates/webhooks-server/deployment.yaml b/charts/kargo/templates/webhooks-server/deployment.yaml
index cd7b8ad1..47c8680a 100644
--- a/charts/kargo/templates/webhooks-server/deployment.yaml
+++ b/charts/kargo/templates/webhooks-server/deployment.yaml
@@ -71,7 +71,7 @@ spec:
             resourceFieldRef:
               containerName: webhooks-server
               divisor: "1"
-              resource: limits.cpu
+              resource: {{ include "kargo.selectCpuResourceField" (dict "resources" .Values.webhooksServer.resources) }}
         {{- with (concat .Values.global.env .Values.webhooksServer.env) }}
         {{- toYaml . | nindent 8 }}
         {{- end }}
diff --git a/internal/api/stage.go b/internal/api/stage.go
index 9444f077..6053e9a2 100644
--- a/internal/api/stage.go
+++ b/internal/api/stage.go
@@ -83,7 +83,7 @@ func ListFreightAvailableToStage(
 				AvailabilityStrategy: req.Sources.AvailabilityStrategy,
 			}
 			if requiredSoak := req.Sources.RequiredSoakTime; requiredSoak != nil {
-				listOpts.VerifiedBefore = &metav1.Time{Time: time.Now().Add(-requiredSoak.Duration)}
+				listOpts.RequiredSoakTime = &requiredSoak.Duration
 			}
 		}
 		freightFromWarehouse, err := ListFreightFromWarehouse(
diff --git a/internal/api/warehouse.go b/internal/api/warehouse.go
index fb421cba..89f6adb6 100644
--- a/internal/api/warehouse.go
+++ b/internal/api/warehouse.go
@@ -84,11 +84,10 @@ type ListWarehouseFreightOptions struct {
 	//
 	// IMPORTANT: This is OR'ed with the ApprovedFor field.
 	VerifiedIn []string
-	// VerifiedBefore optionally specifies a time before which a Freight verified
-	// in any of the Stages named in the VerifiedIn field must have been verified.
-	// This is useful for filtering out Freight whose soak time has not yet
-	// elapsed.
-	VerifiedBefore *metav1.Time
+	// RequiredSoakTime optionally specifies a minimum duration that a piece of
+	// Freight must have continuously remained in a Stage at any time after being
+	// verified.
+	RequiredSoakTime *time.Duration
 	// AvailabilityStrategy specifies the semantics for how Freight is determined
 	// to be available. If not set, the default is to consider Freight available
 	// if it has been verified in any of the provided VerifiedIn stages.
@@ -196,7 +195,7 @@ func ListFreightFromWarehouse(
 		return lhs.Name == rhs.Name
 	})
 
-	if len(opts.VerifiedIn) == 0 || opts.VerifiedBefore == nil {
+	if len(opts.VerifiedIn) == 0 || opts.RequiredSoakTime == nil {
 		// Nothing left to do
 		return freight, nil
 	}
@@ -214,11 +213,9 @@ func ListFreightFromWarehouse(
 		// Track set of Stages that have passed the verification soak time
 		// for the Freight.
 		verifiedStages := sets.New[string]()
-		for stage, ver := range f.Status.VerifiedIn {
-			if verifiedAt := ver.VerifiedAt; verifiedAt != nil {
-				if verifiedAt.Time.Before(opts.VerifiedBefore.Time) {
-					verifiedStages.Insert(stage)
-				}
+		for stage := range f.Status.VerifiedIn {
+			if f.GetLongestSoak(stage) >= *opts.RequiredSoakTime {
+				verifiedStages.Insert(stage)
 			}
 		}
 
diff --git a/ui/package.json b/ui/package.json
index 494c78be..4126debd 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -21,7 +21,7 @@
     "@types/git-url-parse": "^9.0.3",
     "@types/json-schema": "^7.0.15",
     "@types/node": "^22.10.2",
-    "@types/react": "^19.0.10",
+    "@types/react": "^19.0.12",
     "@types/react-dom": "^19.0.4",
     "@types/react-infinite-scroller": "^1.2.5",
     "@types/semver": "^7.5.8",
@@ -33,7 +33,7 @@
     "eslint": "^9.17.0",
     "eslint-config-prettier": "^10.1.1",
     "eslint-plugin-import": "^2.31.0",
-    "eslint-plugin-prettier": "^5.2.3",
+    "eslint-plugin-prettier": "^5.2.5",
     "eslint-plugin-react": "^7.37.4",
     "eslint-plugin-react-refresh": "^0.4.19",
     "globals": "^16.0.0",
@@ -46,7 +46,7 @@
     "vite": "^6.0.12",
     "vite-plugin-compression": "^0.5.1",
     "vite-tsconfig-paths": "^5.1.4",
-    "vitest": "^3.0.8",
+    "vitest": "^3.0.9",
     "zx": "^8.3.2"
   },
   "packageManager": "pnpm@9.0.3",
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index b764b2ed..da1ba6d4 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -61,7 +61,7 @@ importers:
         version: 0.7.52
       '@xyflow/react':
         specifier: ^12.3.6
-        version: 12.3.6(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+        version: 12.3.6(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
       antd:
         specifier: ^5.22.5
         version: 5.23.0(date-fns@4.1.0)(moment@2.30.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -139,11 +139,11 @@ importers:
         specifier: ^22.10.2
         version: 22.10.5
       '@types/react':
-        specifier: ^19.0.10
-        version: 19.0.10
+        specifier: ^19.0.12
+        version: 19.0.12
       '@types/react-dom':
         specifier: ^19.0.4
-        version: 19.0.4(@types/react@19.0.10)
+        version: 19.0.4(@types/react@19.0.12)
       '@types/react-infinite-scroller':
         specifier: ^1.2.5
         version: 1.2.5
@@ -175,8 +175,8 @@ importers:
         specifier: ^2.31.0
         version: 2.31.0(@typescript-eslint/parser@8.19.1(eslint@9.17.0(jiti@1.21.7))(typescript@5.7.3))(eslint@9.17.0(jiti@1.21.7))
       eslint-plugin-prettier:
-        specifier: ^5.2.3
-        version: 5.2.3(eslint-config-prettier@10.1.1(eslint@9.17.0(jiti@1.21.7)))(eslint@9.17.0(jiti@1.21.7))(prettier@3.4.2)
+        specifier: ^5.2.5
+        version: 5.2.5(eslint-config-prettier@10.1.1(eslint@9.17.0(jiti@1.21.7)))(eslint@9.17.0(jiti@1.21.7))(prettier@3.4.2)
       eslint-plugin-react:
         specifier: ^7.37.4
         version: 7.37.4(eslint@9.17.0(jiti@1.21.7))
@@ -214,8 +214,8 @@ importers:
         specifier: ^5.1.4
         version: 5.1.4(typescript@5.7.3)(vite@6.0.12(@types/node@22.10.5)(jiti@1.21.7)(less@4.2.2)(yaml@2.7.0))
       vitest:
-        specifier: ^3.0.8
-        version: 3.0.8(@types/node@22.10.5)(jiti@1.21.7)(less@4.2.2)(yaml@2.7.0)
+        specifier: ^3.0.9
+        version: 3.0.9(@types/node@22.10.5)(jiti@1.21.7)(less@4.2.2)(yaml@2.7.0)
       zx:
         specifier: ^8.3.2
         version: 8.3.2
@@ -701,8 +701,8 @@ packages:
     resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
     engines: {node: '>=14'}
 
-  '@pkgr/core@0.1.1':
-    resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==}
+  '@pkgr/core@0.2.0':
+    resolution: {integrity: sha512-vsJDAkYR6qCPu+ioGScGiMYR7LvZYIXh/dlQeviqoTWNCVfKTLYD/LkNWH4Mxsv2a5vpIRc77FN5DnmK1eBggQ==}
     engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
 
   '@rc-component/async-validator@5.0.4':
@@ -1015,8 +1015,8 @@ packages:
   '@types/react-infinite-scroller@1.2.5':
     resolution: {integrity: sha512-fJU1jhMgoL6NJFrqTM0Ob7tnd2sQWGxe2ESwiU6FZWbJK/VO/Er5+AOhc+e2zbT0dk5pLygqctsulOLJ8xnSzw==}
 
-  '@types/react@19.0.10':
-    resolution: {integrity: sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==}
+  '@types/react@19.0.12':
+    resolution: {integrity: sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==}
 
   '@types/semver@7.5.8':
     resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==}
@@ -1074,11 +1074,11 @@ packages:
     peerDependencies:
       vite: ^4.2.0 || ^5.0.0 || ^6.0.0
 
-  '@vitest/expect@3.0.8':
-    resolution: {integrity: sha512-Xu6TTIavTvSSS6LZaA3EebWFr6tsoXPetOWNMOlc7LO88QVVBwq2oQWBoDiLCN6YTvNYsGSjqOO8CAdjom5DCQ==}
+  '@vitest/expect@3.0.9':
+    resolution: {integrity: sha512-5eCqRItYgIML7NNVgJj6TVCmdzE7ZVgJhruW0ziSQV4V7PvLkDL1bBkBdcTs/VuIz0IxPb5da1IDSqc1TR9eig==}
 
-  '@vitest/mocker@3.0.8':
-    resolution: {integrity: sha512-n3LjS7fcW1BCoF+zWZxG7/5XvuYH+lsFg+BDwwAz0arIwHQJFUEsKBQ0BLU49fCxuM/2HSeBPHQD8WjgrxMfow==}
+  '@vitest/mocker@3.0.9':
+    resolution: {integrity: sha512-ryERPIBOnvevAkTq+L1lD+DTFBRcjueL9lOUfXsLfwP92h4e+Heb+PjiqS3/OURWPtywfafK0kj++yDFjWUmrA==}
     peerDependencies:
       msw: ^2.4.9
       vite: ^5.0.0 || ^6.0.0
@@ -1088,20 +1088,20 @@ packages:
       vite:
         optional: true
 
-  '@vitest/pretty-format@3.0.8':
-    resolution: {integrity: sha512-BNqwbEyitFhzYMYHUVbIvepOyeQOSFA/NeJMIP9enMntkkxLgOcgABH6fjyXG85ipTgvero6noreavGIqfJcIg==}
+  '@vitest/pretty-format@3.0.9':
+    resolution: {integrity: sha512-OW9F8t2J3AwFEwENg3yMyKWweF7oRJlMyHOMIhO5F3n0+cgQAJZBjNgrF8dLwFTEXl5jUqBLXd9QyyKv8zEcmA==}
 
-  '@vitest/runner@3.0.8':
-    resolution: {integrity: sha512-c7UUw6gEcOzI8fih+uaAXS5DwjlBaCJUo7KJ4VvJcjL95+DSR1kova2hFuRt3w41KZEFcOEiq098KkyrjXeM5w==}
+  '@vitest/runner@3.0.9':
+    resolution: {integrity: sha512-NX9oUXgF9HPfJSwl8tUZCMP1oGx2+Sf+ru6d05QjzQz4OwWg0psEzwY6VexP2tTHWdOkhKHUIZH+fS6nA7jfOw==}
 
-  '@vitest/snapshot@3.0.8':
-    resolution: {integrity: sha512-x8IlMGSEMugakInj44nUrLSILh/zy1f2/BgH0UeHpNyOocG18M9CWVIFBaXPt8TrqVZWmcPjwfG/ht5tnpba8A==}
+  '@vitest/snapshot@3.0.9':
+    resolution: {integrity: sha512-AiLUiuZ0FuA+/8i19mTYd+re5jqjEc2jZbgJ2up0VY0Ddyyxg/uUtBDpIFAy4uzKaQxOW8gMgBdAJJ2ydhu39A==}
 
-  '@vitest/spy@3.0.8':
-    resolution: {integrity: sha512-MR+PzJa+22vFKYb934CejhR4BeRpMSoxkvNoDit68GQxRLSf11aT6CTj3XaqUU9rxgWJFnqicN/wxw6yBRkI1Q==}
+  '@vitest/spy@3.0.9':
+    resolution: {integrity: sha512-/CcK2UDl0aQ2wtkp3YVWldrpLRNCfVcIOFGlVGKO4R5eajsH393Z1yiXLVQ7vWsj26JOEjeZI0x5sm5P4OGUNQ==}
 
-  '@vitest/utils@3.0.8':
-    resolution: {integrity: sha512-nkBC3aEhfX2PdtQI/QwAWp8qZWwzASsU4Npbcd5RdMPBSSLCpkZp52P3xku3s3uA0HIEhGvEcF8rNkBsz9dQ4Q==}
+  '@vitest/utils@3.0.9':
+    resolution: {integrity: sha512-ilHM5fHhZ89MCp5aAaM9uhfl1c2JdxVxl3McqsdVyVNN6JffnEen8UMCdRTzOhGXNQGo5GNL9QugHrz727Wnng==}
 
   '@xyflow/react@12.3.6':
     resolution: {integrity: sha512-9GS+cz8hDZahpvTrVCmySAEgKUL8oN4b2q1DluHrKtkqhAMWfH2s7kblhbM4Y4Y4SUnH2lt4drXKZ/4/Lot/2Q==}
@@ -1683,13 +1683,13 @@ packages:
       '@typescript-eslint/parser':
         optional: true
 
-  eslint-plugin-prettier@5.2.3:
-    resolution: {integrity: sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==}
+  eslint-plugin-prettier@5.2.5:
+    resolution: {integrity: sha512-IKKP8R87pJyMl7WWamLgPkloB16dagPIdd2FjBDbyRYPKo93wS/NbCOPh6gH+ieNLC+XZrhJt/kWj0PS/DFdmg==}
     engines: {node: ^14.18.0 || >=16.0.0}
     peerDependencies:
       '@types/eslint': '>=8.0.0'
       eslint: '>=8.0.0'
-      eslint-config-prettier: '*'
+      eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0'
       prettier: '>=3.0.0'
     peerDependenciesMeta:
       '@types/eslint':
@@ -3224,8 +3224,8 @@ packages:
     engines: {node: '>=14.0.0'}
     hasBin: true
 
-  synckit@0.9.2:
-    resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==}
+  synckit@0.10.3:
+    resolution: {integrity: sha512-R1urvuyiTaWfeCggqEvpDJwAlDVdsT9NM+IP//Tk2x7qHCkSvBk/fwFgw/TLAHzZlrAnnazMcRw0ZD8HlYFTEQ==}
     engines: {node: ^14.18.0 || >=16.0.0}
 
   tailwindcss@3.4.17:
@@ -3381,8 +3381,8 @@ packages:
   victory-vendor@36.9.2:
     resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
 
-  vite-node@3.0.8:
-    resolution: {integrity: sha512-6PhR4H9VGlcwXZ+KWCdMqbtG649xCPZqfI9j2PsK1FcXgEzro5bGHcVKFCTqPLaNKZES8Evqv4LwvZARsq5qlg==}
+  vite-node@3.0.9:
+    resolution: {integrity: sha512-w3Gdx7jDcuT9cNn9jExXgOyKmf5UOTb6WMHz8LGAm54eS1Elf5OuBhCxl6zJxGhEeIkgsE1WbHuoL0mj/UXqXg==}
     engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
     hasBin: true
 
@@ -3444,16 +3444,16 @@ packages:
       yaml:
         optional: true
 
-  vitest@3.0.8:
-    resolution: {integrity: sha512-dfqAsNqRGUc8hB9OVR2P0w8PZPEckti2+5rdZip0WIz9WW0MnImJ8XiR61QhqLa92EQzKP2uPkzenKOAHyEIbA==}
+  vitest@3.0.9:
+    resolution: {integrity: sha512-BbcFDqNyBlfSpATmTtXOAOj71RNKDDvjBM/uPfnxxVGrG+FSH2RQIwgeEngTaTkuU/h0ScFvf+tRcKfYXzBybQ==}
     engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
     hasBin: true
     peerDependencies:
       '@edge-runtime/vm': '*'
       '@types/debug': ^4.1.12
       '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
-      '@vitest/browser': 3.0.8
-      '@vitest/ui': 3.0.8
+      '@vitest/browser': 3.0.9
+      '@vitest/ui': 3.0.9
       happy-dom: '*'
       jsdom: '*'
     peerDependenciesMeta:
@@ -4018,7 +4018,7 @@ snapshots:
   '@pkgjs/parseargs@0.11.0':
     optional: true
 
-  '@pkgr/core@0.1.1': {}
+  '@pkgr/core@0.2.0': {}
 
   '@rc-component/async-validator@5.0.4':
     dependencies:
@@ -4309,15 +4309,15 @@ snapshots:
 
   '@types/parse-path@7.0.3': {}
 
-  '@types/react-dom@19.0.4(@types/react@19.0.10)':
+  '@types/react-dom@19.0.4(@types/react@19.0.12)':
     dependencies:
-      '@types/react': 19.0.10
+      '@types/react': 19.0.12
 
   '@types/react-infinite-scroller@1.2.5':
     dependencies:
-      '@types/react': 19.0.10
+      '@types/react': 19.0.12
 
-  '@types/react@19.0.10':
+  '@types/react@19.0.12':
     dependencies:
       csstype: 3.1.3
 
@@ -4411,53 +4411,53 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  '@vitest/expect@3.0.8':
+  '@vitest/expect@3.0.9':
     dependencies:
-      '@vitest/spy': 3.0.8
-      '@vitest/utils': 3.0.8
+      '@vitest/spy': 3.0.9
+      '@vitest/utils': 3.0.9
       chai: 5.2.0
       tinyrainbow: 2.0.0
 
-  '@vitest/mocker@3.0.8(vite@6.0.12(@types/node@22.10.5)(jiti@1.21.7)(less@4.2.2)(yaml@2.7.0))':
+  '@vitest/mocker@3.0.9(vite@6.0.12(@types/node@22.10.5)(jiti@1.21.7)(less@4.2.2)(yaml@2.7.0))':
     dependencies:
-      '@vitest/spy': 3.0.8
+      '@vitest/spy': 3.0.9
       estree-walker: 3.0.3
       magic-string: 0.30.17
     optionalDependencies:
       vite: 6.0.12(@types/node@22.10.5)(jiti@1.21.7)(less@4.2.2)(yaml@2.7.0)
 
-  '@vitest/pretty-format@3.0.8':
+  '@vitest/pretty-format@3.0.9':
     dependencies:
       tinyrainbow: 2.0.0
 
-  '@vitest/runner@3.0.8':
+  '@vitest/runner@3.0.9':
     dependencies:
-      '@vitest/utils': 3.0.8
+      '@vitest/utils': 3.0.9
       pathe: 2.0.3
 
-  '@vitest/snapshot@3.0.8':
+  '@vitest/snapshot@3.0.9':
     dependencies:
-      '@vitest/pretty-format': 3.0.8
+      '@vitest/pretty-format': 3.0.9
       magic-string: 0.30.17
       pathe: 2.0.3
 
-  '@vitest/spy@3.0.8':
+  '@vitest/spy@3.0.9':
     dependencies:
       tinyspy: 3.0.2
 
-  '@vitest/utils@3.0.8':
+  '@vitest/utils@3.0.9':
     dependencies:
-      '@vitest/pretty-format': 3.0.8
+      '@vitest/pretty-format': 3.0.9
       loupe: 3.1.3
       tinyrainbow: 2.0.0
 
-  '@xyflow/react@12.3.6(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
+  '@xyflow/react@12.3.6(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@xyflow/system': 0.0.47
       classcat: 5.0.5
       react: 19.0.0
       react-dom: 19.0.0(react@19.0.0)
-      zustand: 4.5.6(@types/react@19.0.10)(react@19.0.0)
+      zustand: 4.5.6(@types/react@19.0.12)(react@19.0.0)
     transitivePeerDependencies:
       - '@types/react'
       - immer
@@ -5248,12 +5248,12 @@ snapshots:
       - eslint-import-resolver-webpack
       - supports-color
 
-  eslint-plugin-prettier@5.2.3(eslint-config-prettier@10.1.1(eslint@9.17.0(jiti@1.21.7)))(eslint@9.17.0(jiti@1.21.7))(prettier@3.4.2):
+  eslint-plugin-prettier@5.2.5(eslint-config-prettier@10.1.1(eslint@9.17.0(jiti@1.21.7)))(eslint@9.17.0(jiti@1.21.7))(prettier@3.4.2):
     dependencies:
       eslint: 9.17.0(jiti@1.21.7)
       prettier: 3.4.2
       prettier-linter-helpers: 1.0.0
-      synckit: 0.9.2
+      synckit: 0.10.3
     optionalDependencies:
       eslint-config-prettier: 10.1.1(eslint@9.17.0(jiti@1.21.7))
 
@@ -6977,9 +6977,9 @@ snapshots:
       csso: 5.0.5
       picocolors: 1.1.1
 
-  synckit@0.9.2:
+  synckit@0.10.3:
     dependencies:
-      '@pkgr/core': 0.1.1
+      '@pkgr/core': 0.2.0
       tslib: 2.8.1
 
   tailwindcss@3.4.17:
@@ -7167,7 +7167,7 @@ snapshots:
       d3-time: 3.1.0
       d3-timer: 3.0.1
 
-  vite-node@3.0.8(@types/node@22.10.5)(jiti@1.21.7)(less@4.2.2)(yaml@2.7.0):
+  vite-node@3.0.9(@types/node@22.10.5)(jiti@1.21.7)(less@4.2.2)(yaml@2.7.0):
     dependencies:
       cac: 6.7.14
       debug: 4.4.0
@@ -7224,15 +7224,15 @@ snapshots:
       less: 4.2.2
       yaml: 2.7.0
 
-  vitest@3.0.8(@types/node@22.10.5)(jiti@1.21.7)(less@4.2.2)(yaml@2.7.0):
+  vitest@3.0.9(@types/node@22.10.5)(jiti@1.21.7)(less@4.2.2)(yaml@2.7.0):
     dependencies:
-      '@vitest/expect': 3.0.8
-      '@vitest/mocker': 3.0.8(vite@6.0.12(@types/node@22.10.5)(jiti@1.21.7)(less@4.2.2)(yaml@2.7.0))
-      '@vitest/pretty-format': 3.0.8
-      '@vitest/runner': 3.0.8
-      '@vitest/snapshot': 3.0.8
-      '@vitest/spy': 3.0.8
-      '@vitest/utils': 3.0.8
+      '@vitest/expect': 3.0.9
+      '@vitest/mocker': 3.0.9(vite@6.0.12(@types/node@22.10.5)(jiti@1.21.7)(less@4.2.2)(yaml@2.7.0))
+      '@vitest/pretty-format': 3.0.9
+      '@vitest/runner': 3.0.9
+      '@vitest/snapshot': 3.0.9
+      '@vitest/spy': 3.0.9
+      '@vitest/utils': 3.0.9
       chai: 5.2.0
       debug: 4.4.0
       expect-type: 1.2.0
@@ -7244,7 +7244,7 @@ snapshots:
       tinypool: 1.0.2
       tinyrainbow: 2.0.0
       vite: 6.0.12(@types/node@22.10.5)(jiti@1.21.7)(less@4.2.2)(yaml@2.7.0)
-      vite-node: 3.0.8(@types/node@22.10.5)(jiti@1.21.7)(less@4.2.2)(yaml@2.7.0)
+      vite-node: 3.0.9(@types/node@22.10.5)(jiti@1.21.7)(less@4.2.2)(yaml@2.7.0)
       why-is-node-running: 2.3.0
     optionalDependencies:
       '@types/node': 22.10.5
@@ -7360,11 +7360,11 @@ snapshots:
 
   zod@3.24.2: {}
 
-  zustand@4.5.6(@types/react@19.0.10)(react@19.0.0):
+  zustand@4.5.6(@types/react@19.0.12)(react@19.0.0):
     dependencies:
       use-sync-external-store: 1.2.2(react@19.0.0)
     optionalDependencies:
-      '@types/react': 19.0.10
+      '@types/react': 19.0.12
       react: 19.0.0
 
   zx@8.3.2:


Test patch:
diff --git a/internal/api/stage_test.go b/internal/api/stage_test.go
index ebedcef1..ae5f2faa 100644
--- a/internal/api/stage_test.go
+++ b/internal/api/stage_test.go
@@ -208,8 +208,7 @@ func TestListFreightAvailableToStage(t *testing.T) {
 					Status: kargoapi.FreightStatus{
 						VerifiedIn: map[string]kargoapi.VerifiedStage{
 							testStage: {
-								VerifiedAt: &metav1.Time{Time: time.Now()},
-							},
+								LongestCompletedSoak: &metav1.Duration{Duration: 30 * time.Minute}},
 						},
 					},
 				},
@@ -222,7 +221,7 @@ func TestListFreightAvailableToStage(t *testing.T) {
 					Status: kargoapi.FreightStatus{
 						VerifiedIn: map[string]kargoapi.VerifiedStage{
 							testStage: {
-								VerifiedAt: &metav1.Time{Time: time.Now().Add(-time.Hour * 2)},
+								LongestCompletedSoak: &metav1.Duration{Duration: 2 * time.Hour},
 							},
 						},
 					},
diff --git a/internal/api/warehouse_test.go b/internal/api/warehouse_test.go
index 5169bb33..b8d3b6dd 100644
--- a/internal/api/warehouse_test.go
+++ b/internal/api/warehouse_test.go
@@ -174,12 +174,11 @@ func TestListFreightFromWarehouse(t *testing.T) {
 			},
 		},
 		{
-			name: "success with VerifiedIn and VerifiedBefore options",
+			name: "success with VerifiedIn and RequiredSoakTime options",
 			opts: &ListWarehouseFreightOptions{
-				ApprovedFor:    testStage,
-				VerifiedIn:     []string{testUpstreamStage},
-				VerifiedBefore: &metav1.Time{Time: time.Now().Add(-1 * time.Hour)},
-			},
+				ApprovedFor:      testStage,
+				VerifiedIn:       []string{testUpstreamStage},
+				RequiredSoakTime: ptr.To(time.Hour)},
 			objects: []client.Object{
 				&kargoapi.Freight{ // This should not be returned
 					ObjectMeta: metav1.ObjectMeta{
@@ -196,7 +195,9 @@ func TestListFreightFromWarehouse(t *testing.T) {
 						ApprovedFor: map[string]kargoapi.ApprovedStage{testStage: {}},
 						// Doesn't matter that it's verified upstream, because this is the
 						// wrong warehouse
-						VerifiedIn: map[string]kargoapi.VerifiedStage{testUpstreamStage: {}},
+						VerifiedIn: map[string]kargoapi.VerifiedStage{testUpstreamStage: {
+							LongestCompletedSoak: &metav1.Duration{Duration: 2 * time.Hour},
+						}},
 					},
 				},
 				&kargoapi.Freight{ // This should not be returned
@@ -238,7 +239,7 @@ func TestListFreightFromWarehouse(t *testing.T) {
 						// yet elapsed
 						VerifiedIn: map[string]kargoapi.VerifiedStage{
 							testUpstreamStage: {
-								VerifiedAt: ptr.To(metav1.Now()),
+								LongestCompletedSoak: &metav1.Duration{Duration: 30 * time.Minute},
 							},
 						},
 					},
@@ -257,7 +258,7 @@ func TestListFreightFromWarehouse(t *testing.T) {
 						// elapsed
 						VerifiedIn: map[string]kargoapi.VerifiedStage{
 							testUpstreamStage: {
-								VerifiedAt: ptr.To(metav1.NewTime(time.Now().Add(-2 * time.Hour))),
+								LongestCompletedSoak: &metav1.Duration{Duration: 2 * time.Hour},
 							},
 						},
 					},
@@ -278,7 +279,7 @@ func TestListFreightFromWarehouse(t *testing.T) {
 				AvailabilityStrategy: kargoapi.FreightAvailabilityStrategyAll,
 				ApprovedFor:          testStage,
 				VerifiedIn:           []string{testUpstreamStage, testUpstreamStage2},
-				VerifiedBefore:       &metav1.Time{Time: time.Now().Add(-1 * time.Hour)},
+				RequiredSoakTime:     ptr.To(time.Hour),
 			},
 			objects: []client.Object{
 				&kargoapi.Freight{ // This should be returned as its approved for the Stage
@@ -294,11 +295,7 @@ func TestListFreightFromWarehouse(t *testing.T) {
 						// This is approved for the Stage
 						ApprovedFor: map[string]kargoapi.ApprovedStage{testStage: {}},
 						// This is only verified in a single upstream Stage
-						VerifiedIn: map[string]kargoapi.VerifiedStage{
-							testUpstreamStage: {
-								VerifiedAt: ptr.To(metav1.Now()),
-							},
-						},
+						VerifiedIn: map[string]kargoapi.VerifiedStage{},
 					},
 				},
 				&kargoapi.Freight{
@@ -317,10 +314,10 @@ func TestListFreightFromWarehouse(t *testing.T) {
 						// This is verified in all of the upstream Stages and the soak time has lapsed
 						VerifiedIn: map[string]kargoapi.VerifiedStage{
 							testUpstreamStage: {
-								VerifiedAt: ptr.To(metav1.NewTime(time.Now().Add(-2 * time.Hour))),
+								LongestCompletedSoak: &metav1.Duration{Duration: 2 * time.Hour},
 							},
 							testUpstreamStage2: {
-								VerifiedAt: ptr.To(metav1.NewTime(time.Now().Add(-2 * time.Hour))),
+								LongestCompletedSoak: &metav1.Duration{Duration: 2 * time.Hour},
 							},
 						},
 					},
@@ -339,9 +336,7 @@ func TestListFreightFromWarehouse(t *testing.T) {
 						ApprovedFor: map[string]kargoapi.ApprovedStage{},
 						// This is not verified in all of the upstream Stages
 						VerifiedIn: map[string]kargoapi.VerifiedStage{
-							testUpstreamStage: {
-								VerifiedAt: ptr.To(metav1.Now()),
-							},
+							testUpstreamStage: {},
 						},
 					},
 				},
@@ -360,10 +355,10 @@ func TestListFreightFromWarehouse(t *testing.T) {
 						// This is verified in all of the upstream Stages but only passed the soak time of one
 						VerifiedIn: map[string]kargoapi.VerifiedStage{
 							testUpstreamStage: {
-								VerifiedAt: ptr.To(metav1.NewTime(time.Now().Add(-2 * time.Hour))),
+								LongestCompletedSoak: &metav1.Duration{Duration: 2 * time.Hour},
 							},
 							testUpstreamStage2: {
-								VerifiedAt: ptr.To(metav1.Now()),
+								LongestCompletedSoak: &metav1.Duration{Duration: 30 * time.Minute},
 							},
 						},
 					},
@@ -384,7 +379,7 @@ func TestListFreightFromWarehouse(t *testing.T) {
 				AvailabilityStrategy: "Invalid",
 				ApprovedFor:          testStage,
 				VerifiedIn:           []string{testUpstreamStage, testUpstreamStage2},
-				VerifiedBefore:       &metav1.Time{Time: time.Now().Add(-1 * time.Hour)},
+				RequiredSoakTime:     ptr.To(time.Hour),
 			},
 			assertions: func(t *testing.T, _ []kargoapi.Freight, err error) {
 				require.ErrorContains(t, err, "unsupported AvailabilityStrategy")
diff --git a/internal/controller/stages/regular_stages_test.go b/internal/controller/stages/regular_stages_test.go
index 725a6a11..aeaf5270 100644
--- a/internal/controller/stages/regular_stages_test.go
+++ b/internal/controller/stages/regular_stages_test.go
@@ -4794,7 +4794,7 @@ func TestRegularStageReconciler_autoPromoteFreight(t *testing.T) {
 			},
 		},
 		{
-			name: "handles verified freight from upstream stages with verification duration requirement",
+			name: "handles verified freight from upstream stages with soak time requirement",
 			stage: &kargoapi.Stage{
 				ObjectMeta: metav1.ObjectMeta{
 					Namespace: "fake-project",
@@ -4874,9 +4874,8 @@ func TestRegularStageReconciler_autoPromoteFreight(t *testing.T) {
 					Status: kargoapi.FreightStatus{
 						VerifiedIn: map[string]kargoapi.VerifiedStage{
 							"upstream-stage": {
-								// Should be selected because it was verified
-								// after the required duration.
-								VerifiedAt: &metav1.Time{Time: now.Add(-2 * time.Hour)},
+								// Should be selected because the soak time has elapsed
+								LongestCompletedSoak: &metav1.Duration{Duration: 2 * time.Hour},
 							},
 						},
 					},