Repository: PrefectHQ/prefect
Pull request: add `freeze` annotation
closes #11073

this PR adds a `freeze` annotation type as described in the linked PR. adding a parameterized type (e.g. `freeze[str]`) is not necessary

the field's value becomes a read-only `Enum` with only this member value (which be refined to more specifically patch the parameter schema)

```python
from prefect import flow
from prefect.utilities.annotations import freeze


@flow(log_prints=True)
def test_freeze(value: str) -> str:
    print(f"value: {value}, type: {type(value)}")
    assert isinstance(value, str)
    return value


if __name__ == "__main__":
    test_freeze.serve(parameters={"value": freeze[str]("test")})
```
![image](https://github.com/user-attachments/assets/a16250fe-089a-4b55-92c8-81f37ebfc212)

![image](https://github.com/user-attachments/assets/25df647a-b12d-41d2-8ef0-6f81a23089eb)



Problem statement:
Parameter Freezing or Hiding for Deployment Flow Configuration
### First check

- [X] I added a descriptive title to this issue.
- [X] I used the GitHub search to find a similar request and didn't find it.
- [X] I searched the Prefect documentation for this feature.

### Prefect Version

2.x

### Describe the current behavior

In the current version of Prefect, when creating a deployment that sets the value of some parameters of a flow, these parameters remain editable in the UI. This is evident even if default values are set for the parameters. An example scenario involves setting parameters for a flow with Enums, where the default values are specified but can still be altered in the UI.

The below results in param1 still being editable in the UI:
```
class testEnum1(Enum):
    a = "a"
    b = "b"


class testEnum2(Enum):
    c = "c"
    d = "d"


@task(log_prints=True)
def task1(enum1, enum2):
    print(f"enum1: {enum1}")
    print(f"enum2: {enum2}")


@flow
def test_flow(
    param1: testEnum1,
    param2: testEnum2,
):
    task1(enum1=param1, enum2=param2)


if __name__ == "__main__":
    deployment = test_flow.deploy(parameters={"param1": "a"})
```



### Describe the proposed behavior

Add the ability to lock or hide specific flow parameters for a given deployments so they become immutable and possibly hidden in the UI.

Per @cicdw , this could be implemented through a freeze function like the below:
```
from prefect import freeze

deployment = test_flow.deploy(parameters={"param1": freeze("a")})
```

This function would manipulate the underlying OpenAPI schema, ensuring that the frozen parameter, such as "param1" in this case, is stored as an Enum with only one possible value ("a"). This logic can be client-side, making the implementation straightforward and non-intrusive.

### Example Use

This functionality would better support the deployment of dynamic workflows where a deployment represents a distinct version of a flow and the parameter values passed don't make sense to alter again to runtime.

For example, if I have a single flow that needs to run differently for each of my customers, I could deploy multiple instances of the same flow with unique parameters corresponding to each customer. In such cases, it wouldn't make sense to have a deployment that is specific to a customer while also allowing the customer name parameter to be editable. Freezing the customer-specific parameter ensures the deployment remains consistent and tailored to the intended customer.

### Additional context

_No response_

Hints from issue discussion:
Hey @dlomartra1 ! I strongly agree with your proposal and especially given example. I'm wondering how this situation should be tackled according to current Prefect implementation and policies.
This is in fact already possible - at least for **all** deployments of a flow

```python
@flow
def test_flow(a: int, b: Literal[2] = 2) -> int: ...
```


<img width="1230" alt="Image" src="https://github.com/user-attachments/assets/03969a0c-cda2-450d-88b8-a7629d5c0196" />
While technically it's possible it adds logic in Python code and I think in this specific case it's better to keep variable values in `prefect.yaml` configuration file.

Commit message: fix validator

Patch to review:
diff --git a/src/prefect/client/schemas/actions.py b/src/prefect/client/schemas/actions.py
index 7dbc5f9d8bf7..bb8c7eea10f9 100644
--- a/src/prefect/client/schemas/actions.py
+++ b/src/prefect/client/schemas/actions.py
@@ -1,9 +1,12 @@
+from __future__ import annotations
+
 from copy import deepcopy
 from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, Union
 from uuid import UUID, uuid4
 
 import jsonschema
 from pydantic import Field, field_validator, model_validator
+from typing_extensions import Self
 
 import prefect.client.schemas.objects as objects
 from prefect._internal.schemas.bases import ActionBaseModel
@@ -42,6 +45,7 @@
     PositiveInteger,
     StrictVariableValue,
 )
+from prefect.utilities.annotations import freeze
 from prefect.utilities.collections import listrepr
 from prefect.utilities.pydantic import get_class_fields_only
 
@@ -275,6 +279,27 @@ def check_valid_configuration(self, base_job_template: dict[str, Any]) -> None:
 
             jsonschema.validate(self.job_variables, variables_schema)
 
+    @model_validator(mode="after")
+    def validate_parameters(self) -> Self:
+        """Update the parameter schema to mark frozen parameters as readonly."""
+        if not self.parameters:
+            return self
+
+        for key, value in self.parameters.items():
+            if isinstance(value, freeze):
+                raw_value = value.unfreeze()
+                if (
+                    self.parameter_openapi_schema is not None
+                    and key in self.parameter_openapi_schema.get("properties", {})
+                ):
+                    self.parameter_openapi_schema["properties"][key]["readOnly"] = True
+                    self.parameter_openapi_schema["properties"][key]["enum"] = [
+                        raw_value
+                    ]
+                    self.parameters[key] = raw_value
+
+        return self
+
 
 class DeploymentUpdate(ActionBaseModel):
     """Data used by the Prefect REST API to update a deployment."""
@@ -349,6 +374,27 @@ def check_valid_configuration(self, base_job_template: dict[str, Any]) -> None:
         if variables_schema is not None:
             jsonschema.validate(self.job_variables, variables_schema)
 
+    @model_validator(mode="after")
+    def validate_parameters(self) -> Self:
+        """Update the parameter schema to mark frozen parameters as readonly."""
+        if not self.parameters:
+            return self
+
+        for key, value in self.parameters.items():
+            if isinstance(value, freeze):
+                raw_value = value.unfreeze()
+                if (
+                    self.parameter_openapi_schema is not None
+                    and key in self.parameter_openapi_schema.get("properties", {})
+                ):
+                    self.parameter_openapi_schema["properties"][key]["readOnly"] = True
+                    self.parameter_openapi_schema["properties"][key]["enum"] = [
+                        raw_value
+                    ]
+                    self.parameters[key] = raw_value
+
+        return self
+
 
 class FlowRunUpdate(ActionBaseModel):
     """Data used by the Prefect REST API to update a flow run."""
diff --git a/src/prefect/deployments/runner.py b/src/prefect/deployments/runner.py
index f8093b512509..e7620685ab5a 100644
--- a/src/prefect/deployments/runner.py
+++ b/src/prefect/deployments/runner.py
@@ -355,7 +355,7 @@ async def _update(self, deployment_id: UUID, client: PrefectClient):
         update_payload = self.model_dump(
             mode="json",
             exclude_unset=True,
-            exclude={"storage", "name", "flow_name", "triggers"},
+            exclude={"storage", "name", "flow_name", "triggers", "parameters"},
         )
 
         if self.storage:
@@ -367,7 +367,9 @@ async def _update(self, deployment_id: UUID, client: PrefectClient):
         await client.update_deployment(
             deployment_id,
             deployment=DeploymentUpdate(
-                **update_payload, parameter_openapi_schema=parameter_openapi_schema
+                **update_payload,
+                parameter_openapi_schema=parameter_openapi_schema,
+                parameters=self.parameters,
             ),
         )
 
diff --git a/src/prefect/utilities/annotations.py b/src/prefect/utilities/annotations.py
index 2e264f334d74..9126e00098d5 100644
--- a/src/prefect/utilities/annotations.py
+++ b/src/prefect/utilities/annotations.py
@@ -108,3 +108,25 @@ class NotSet:
     """
     Singleton to distinguish `None` from a value that is not provided by the user.
     """
+
+
+class freeze(BaseAnnotation[T]):
+    """
+    Wrapper for parameters in deployments.
+
+    Indicates that this parameter should be frozen in the UI and not editable
+    when creating flow runs from this deployment.
+
+    Example:
+    ```python
+    @flow
+    def my_flow(customer_id: str):
+        # flow logic
+
+    deployment = my_flow.deploy(parameters={"customer_id": freeze("customer123")})
+    ```
+    """
+
+    def unfreeze(self) -> T:
+        """Return the unwrapped value."""
+        return self.unwrap()
diff --git a/tests/runner/test_runner.py b/tests/runner/test_runner.py
index 459bbd00a6ee..afaf04f3ecdf 100644
--- a/tests/runner/test_runner.py
+++ b/tests/runner/test_runner.py
@@ -68,6 +68,7 @@
 from prefect.states import Cancelling
 from prefect.testing.utilities import AsyncMock
 from prefect.utilities import processutils
+from prefect.utilities.annotations import freeze
 from prefect.utilities.dockerutils import parse_image_tag
 from prefect.utilities.filesystem import tmpchdir
 from prefect.utilities.slugify import slugify
@@ -2519,6 +2520,65 @@ async def test_deployment_name_with_dots(self):
         )
         assert deployment2.name == "flow-from-my.python.module"
 
+    async def test_from_flow_with_frozen_parameters(
+        self, prefect_client: PrefectClient
+    ):
+        """Test that frozen parameters are properly handled in deployment creation."""
+
+        @flow
+        def dummy_flow_4(value: Any): ...
+
+        deployment_object = RunnerDeployment.from_flow(
+            dummy_flow_4,
+            __file__,
+            parameters={"value": freeze("test")},
+        )
+        assert deployment_object.parameters == {"value": freeze("test")}
+        deployment_id = await deployment_object.apply()
+
+        deployment = await prefect_client.read_deployment(deployment_id)
+
+        assert deployment.parameters == {"value": "test"}
+        assert (
+            deployment.parameter_openapi_schema["properties"]["value"]["readOnly"]
+            is True
+        )
+        assert deployment.parameter_openapi_schema["properties"]["value"]["enum"] == [
+            "test"
+        ]
+
+    async def test_from_flow_with_frozen_parameters_preserves_type(
+        self, prefect_client: PrefectClient
+    ):
+        """Test that frozen parameters preserve their type information."""
+
+        @flow
+        def dummy_flow_5(number: int): ...
+
+        deployment_object = RunnerDeployment.from_flow(
+            dummy_flow_5,
+            __file__,
+            parameters={"number": freeze(42)},
+        )
+        assert deployment_object.parameters == {"number": freeze(42)}
+
+        deployment_id = await deployment_object.apply()
+
+        deployment = await prefect_client.read_deployment(deployment_id)
+
+        assert deployment.parameters == {"number": 42}
+        assert (
+            deployment.parameter_openapi_schema["properties"]["number"]["type"]
+            == "integer"
+        )
+        assert (
+            deployment.parameter_openapi_schema["properties"]["number"]["readOnly"]
+            is True
+        )
+        assert deployment.parameter_openapi_schema["properties"]["number"]["enum"] == [
+            42
+        ]
+
 
 class TestServer:
     async def test_healthcheck_fails_as_expected(self):
diff --git a/tests/utilities/test_annotations.py b/tests/utilities/test_annotations.py
index da9716e4ee30..ad37901bd41d 100644
--- a/tests/utilities/test_annotations.py
+++ b/tests/utilities/test_annotations.py
@@ -1,6 +1,6 @@
 import random
 
-from prefect.utilities.annotations import unmapped
+from prefect.utilities.annotations import freeze, unmapped
 
 
 class TestUnmapped:
@@ -9,3 +9,8 @@ def test_always_returns_same_value(self):
 
         for _ in range(10):
             assert thing[random.randint(0, 100)] == "hello"
+
+
+class TestFreeze:
+    def test_round_trip(self):
+        assert freeze("hello").unfreeze() == "hello"


Code review comments:
diff_hunk: @@ -275,6 +279,27 @@ def check_valid_configuration(self, base_job_template: dict[str, Any]) -> None:
 
             jsonschema.validate(self.job_variables, variables_schema)
 
+    @model_validator(mode="after")
+    def validate_parameters(self) -> Self:
+        """Update the parameter schema to mark frozen parameters as readonly."""
+        if not self.parameters:
+            return self
+
+        for key, value in self.parameters.items():
+            if isinstance(value, freeze):
+                raw_value = value.unfreeze()
+                if (
+                    self.parameter_openapi_schema is not None
+                    and key in self.parameter_openapi_schema.get("properties", {})
+                ):
+                    self.parameter_openapi_schema["properties"][key]["readOnly"] = True
+                    self.parameter_openapi_schema["properties"][key]["enum"] = [
path: src/prefect/client/schemas/actions.py
text: @user1:
does this require the raw value to be JSON serializable or otherwise impose any restrictions on the types you can `freeze`?

@author:
hmm. yes I think values would need to be JSON serializable.. open to suggestions on how to handle that

@user1:
Yea that's probably fine for now (maybe we should add a `ValueError` validation to `freeze(...)`?) but I can see a world where more complex types are requested

@author:
added in [05f4cab](https://github.com/PrefectHQ/prefect/pull/17374/commits/05f4cab492e22cd091158af842fb7c3a8a9c7455)