Repository: dagster-io/dagster
Pull request #28828: A new asset freshness API
Author: login: anuthebananu
Created: 2025-03-28T03:40:04Z | Merged: 2025-04-10T02:03:56Z

## Summary & Motivation
Introduces a new API for defining freshness as a top-level attribute on an asset. This API will replace freshness checks and freshness policies as the way to model asset freshness in Dagster.

For more details on the motivation behind needing a new API, please refer to the [internal product review](https://www.notion.so/dagster/New-Asset-Freshness-System-Product-Review-1c318b92e46280fe859ce86f5d78934a?d=1c718b92e462807ca2e1001c0a331dc0#1c718b92e46280c8973ef9358ec38367).

The implementation here is slightly different than the desired end state, which is to reclaim the deprecated `FreshnessPolicy` construct. Instead we're writing the new freshness policy to the asset spec metadata. This will unblock internal testing and give us time to implement a longer-term migration plan for `FreshnessPolicy`.

### Auxiliary Changes
- Relocate `SerializableTimeDelta` from `dagster._core.definitions.declarative_automation.util` to `dagster_shared.serdes.utils`

## How I Tested These Changes
- new + existing unit tests for backward compatibility

## Changelog

> Insert changelog entry or delete this section.



Diff:
diff --git a/js_modules/dagster-ui/packages/ui-core/src/graphql/schema.graphql b/js_modules/dagster-ui/packages/ui-core/src/graphql/schema.graphql
index 5ccd85ec1eef4..4b9aac2b2c114 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/graphql/schema.graphql
+++ b/js_modules/dagster-ui/packages/ui-core/src/graphql/schema.graphql
@@ -78,6 +78,7 @@ enum DagsterEventType {
   ALERT_SUCCESS
   ALERT_FAILURE
   LOGS_CAPTURED
+  FRESHNESS_STATE_EVALUATION
 }
 
 type ExecutionStepFailureEvent implements MessageEvent & StepEvent & ErrorEvent {
diff --git a/js_modules/dagster-ui/packages/ui-core/src/graphql/types.ts b/js_modules/dagster-ui/packages/ui-core/src/graphql/types.ts
index 67d64cf71b3d6..634636f9277a8 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/graphql/types.ts
+++ b/js_modules/dagster-ui/packages/ui-core/src/graphql/types.ts
@@ -997,6 +997,7 @@ export enum DagsterEventType {
   ASSET_OBSERVATION = 'ASSET_OBSERVATION',
   ASSET_STORE_OPERATION = 'ASSET_STORE_OPERATION',
   ENGINE_EVENT = 'ENGINE_EVENT',
+  FRESHNESS_STATE_EVALUATION = 'FRESHNESS_STATE_EVALUATION',
   HANDLED_OUTPUT = 'HANDLED_OUTPUT',
   HOOK_COMPLETED = 'HOOK_COMPLETED',
   HOOK_ERRORED = 'HOOK_ERRORED',
diff --git a/python_modules/dagster/dagster/_core/definitions/asset_spec.py b/python_modules/dagster/dagster/_core/definitions/asset_spec.py
index 0506d8fe88efc..c3482c4d1c9ee 100644
--- a/python_modules/dagster/dagster/_core/definitions/asset_spec.py
+++ b/python_modules/dagster/dagster/_core/definitions/asset_spec.py
@@ -11,7 +11,7 @@
     overload,
 )
 
-from dagster_shared.serdes import whitelist_for_serdes
+from dagster_shared.serdes import serialize_value, whitelist_for_serdes
 
 import dagster._check as check
 from dagster._annotations import (
@@ -26,6 +26,10 @@
     AutomationCondition,
 )
 from dagster._core.definitions.events import AssetKey, CoercibleToAssetKey
+from dagster._core.definitions.freshness import (
+    INTERNAL_FRESHNESS_POLICY_METADATA_KEY,
+    InternalFreshnessPolicy,
+)
 from dagster._core.definitions.freshness_policy import FreshnessPolicy
 from dagster._core.definitions.partition import PartitionsDefinition
 from dagster._core.definitions.partition_mapping import PartitionMapping
@@ -108,6 +112,11 @@ def validate_kind_tags(kinds: Optional[AbstractSet[str]]) -> None:
     breaking_version="1.10.0",
     additional_warn_text="use `automation_condition` instead",
 )
+@hidden_param(
+    param="internal_freshness_policy",
+    breaking_version="1.10.0",
+    additional_warn_text="experimental feature, use freshness checks instead",
+)
 @record_custom
 class AssetSpec(IHasInternalInit, IHaveNew, LegacyNamedTupleMixin):
     """Specifies the core attributes of an asset, except for the function that materializes or
@@ -195,6 +204,15 @@ def __new__(
         }
         validate_kind_tags(kind_tags)
 
+        internal_freshness_policy: Optional[InternalFreshnessPolicy] = kwargs.get(
+            "internal_freshness_policy"
+        )
+        if internal_freshness_policy:
+            metadata = {
+                **(metadata or {}),
+                INTERNAL_FRESHNESS_POLICY_METADATA_KEY: serialize_value(internal_freshness_policy),
+            }
+
         return super().__new__(
             cls,
             key=key,
@@ -435,3 +453,13 @@ def map_asset_specs(
         obj.map_asset_specs(func) if isinstance(obj, AssetsDefinition) else func(obj)
         for obj in iterable
     ]
+
+
+def attach_internal_freshness_policy(spec: AssetSpec, policy: InternalFreshnessPolicy) -> AssetSpec:
+    """Apply a freshness policy to an asset spec, attaching it to the spec's metadata.
+
+    You can use this in Definitions.map_asset_specs to attach a freshness policy to an asset spec.
+    """
+    return spec.merge_attributes(
+        metadata={INTERNAL_FRESHNESS_POLICY_METADATA_KEY: serialize_value(policy)}  # pyright: ignore[reportArgumentType]
+    )
diff --git a/python_modules/dagster/dagster/_core/definitions/declarative_automation/operands/operands.py b/python_modules/dagster/dagster/_core/definitions/declarative_automation/operands/operands.py
index 6877c0cb746da..f6e063f774ac3 100644
--- a/python_modules/dagster/dagster/_core/definitions/declarative_automation/operands/operands.py
+++ b/python_modules/dagster/dagster/_core/definitions/declarative_automation/operands/operands.py
@@ -3,6 +3,7 @@
 from typing import TYPE_CHECKING, Optional
 
 from dagster_shared.serdes import whitelist_for_serdes
+from dagster_shared.serdes.utils import SerializableTimeDelta
 
 from dagster._core.asset_graph_view.entity_subset import EntitySubset
 from dagster._core.definitions.asset_key import AssetCheckKey, AssetKey
@@ -14,7 +15,6 @@
 from dagster._core.definitions.declarative_automation.operands.subset_automation_condition import (
     SubsetAutomationCondition,
 )
-from dagster._core.definitions.declarative_automation.utils import SerializableTimeDelta
 from dagster._record import record
 from dagster._utils.schedules import reverse_cron_string_iterator
 
diff --git a/python_modules/dagster/dagster/_core/definitions/declarative_automation/utils.py b/python_modules/dagster/dagster/_core/definitions/declarative_automation/utils.py
index cb4a8f853979f..8b137891791fe 100644
--- a/python_modules/dagster/dagster/_core/definitions/declarative_automation/utils.py
+++ b/python_modules/dagster/dagster/_core/definitions/declarative_automation/utils.py
@@ -1,27 +1 @@
-import datetime
-from typing import NamedTuple
 
-from dagster_shared.serdes import whitelist_for_serdes
-
-
-@whitelist_for_serdes
-class SerializableTimeDelta(NamedTuple):
-    """A Dagster-serializable version of a datetime.timedelta. The datetime.timedelta class
-    internally stores values as an integer number of days, seconds, and microseconds. This class
-    handles converting between the in-memory and serializable formats.
-    """
-
-    days: int
-    seconds: int
-    microseconds: int
-
-    @staticmethod
-    def from_timedelta(timedelta: datetime.timedelta) -> "SerializableTimeDelta":
-        return SerializableTimeDelta(
-            days=timedelta.days, seconds=timedelta.seconds, microseconds=timedelta.microseconds
-        )
-
-    def to_timedelta(self) -> datetime.timedelta:
-        return datetime.timedelta(
-            days=self.days, seconds=self.seconds, microseconds=self.microseconds
-        )
diff --git a/python_modules/dagster/dagster/_core/definitions/decorators/asset_decorator.py b/python_modules/dagster/dagster/_core/definitions/decorators/asset_decorator.py
index 172b420c0ded6..9bae119834772 100644
--- a/python_modules/dagster/dagster/_core/definitions/decorators/asset_decorator.py
+++ b/python_modules/dagster/dagster/_core/definitions/decorators/asset_decorator.py
@@ -34,6 +34,7 @@
     CoercibleToAssetKey,
     CoercibleToAssetKeyPrefix,
 )
+from dagster._core.definitions.freshness import INTERNAL_FRESHNESS_POLICY_METADATA_KEY
 from dagster._core.definitions.freshness_policy import FreshnessPolicy
 from dagster._core.definitions.input import GraphIn
 from dagster._core.definitions.metadata import ArbitraryMetadataMapping, RawMetadataMapping
@@ -50,6 +51,7 @@
 from dagster._core.errors import DagsterInvalidDefinitionError
 from dagster._core.storage.tags import KIND_PREFIX
 from dagster._core.types.dagster_type import DagsterType
+from dagster._serdes import serialize_value
 from dagster._utils.tags import normalize_tags
 from dagster._utils.warnings import disable_dagster_warnings
 
@@ -138,6 +140,12 @@ def _validate_hidden_non_argument_dep_param(
     emit_runtime_warning=False,
     breaking_version="1.10.0",
 )
+@hidden_param(
+    param="internal_freshness_policy",
+    emit_runtime_warning=False,
+    breaking_version="1.10.0",
+    additional_warn_text="experimental, use freshness checks instead.",
+)
 def asset(
     compute_fn: Optional[Callable[..., Any]] = None,
     *,
@@ -293,6 +301,13 @@ def downstream_asset(conditional_asset):
         **{f"{KIND_PREFIX}{kind}": "" for kind in kinds or []},
     }
 
+    internal_freshness_policy = kwargs.get("internal_freshness_policy")
+    if internal_freshness_policy:
+        metadata = {
+            **(metadata or {}),
+            INTERNAL_FRESHNESS_POLICY_METADATA_KEY: serialize_value(internal_freshness_policy),
+        }
+
     only_allow_hidden_params_in_kwargs(asset, kwargs)
 
     args = AssetDecoratorArgs(
diff --git a/python_modules/dagster/dagster/_core/definitions/freshness.py b/python_modules/dagster/dagster/_core/definitions/freshness.py
new file mode 100644
index 0000000000000..91f3883652ce4
--- /dev/null
+++ b/python_modules/dagster/dagster/_core/definitions/freshness.py
@@ -0,0 +1,66 @@
+import datetime
+from abc import ABC
+from collections.abc import Mapping
+from enum import Enum
+from typing import Any, Optional
+
+from dagster_shared.serdes.utils import SerializableTimeDelta
+
+from dagster._core.definitions.asset_key import AssetKey
+from dagster._record import IHaveNew, record
+from dagster._serdes import deserialize_value, whitelist_for_serdes
+from dagster._utils import check
+
+
+@whitelist_for_serdes
+class FreshnessState(str, Enum):
+    PASS = "PASS"
+    WARN = "WARN"
+    FAIL = "FAIL"
+    UNKNOWN = "UNKNOWN"
+
+
+@whitelist_for_serdes
+@record
+class FreshnessStateEvaluation:
+    key: AssetKey
+    freshness_state: FreshnessState
+
+
+INTERNAL_FRESHNESS_POLICY_METADATA_KEY = "dagster/internal_freshness_policy"
+
+
+class InternalFreshnessPolicy(ABC):
+    @classmethod
+    def from_asset_spec_metadata(
+        cls, metadata: Mapping[str, Any]
+    ) -> Optional["InternalFreshnessPolicy"]:
+        serialized_policy = metadata.get(INTERNAL_FRESHNESS_POLICY_METADATA_KEY)
+        if serialized_policy is None:
+            return None
+        return deserialize_value(serialized_policy.value, cls)  # pyright: ignore
+
+    @staticmethod
+    def time_window(
+        fail_window: datetime.timedelta, warn_window: Optional[datetime.timedelta] = None
+    ) -> "TimeWindowFreshnessPolicy":
+        return TimeWindowFreshnessPolicy.from_timedeltas(fail_window, warn_window)
+
+
+@whitelist_for_serdes
+@record
+class TimeWindowFreshnessPolicy(InternalFreshnessPolicy, IHaveNew):
+    fail_window: SerializableTimeDelta
+    warn_window: Optional[SerializableTimeDelta] = None
+
+    @classmethod
+    def from_timedeltas(
+        cls, fail_window: datetime.timedelta, warn_window: Optional[datetime.timedelta] = None
+    ):
+        if warn_window:
+            check.invariant(warn_window < fail_window, "warn_window must be less than fail_window")
+
+        return cls(
+            fail_window=SerializableTimeDelta.from_timedelta(fail_window),
+            warn_window=SerializableTimeDelta.from_timedelta(warn_window) if warn_window else None,
+        )
diff --git a/python_modules/dagster/dagster/_core/events/__init__.py b/python_modules/dagster/dagster/_core/events/__init__.py
index 65b4c6bbcd206..42d6b110fb58c 100644
--- a/python_modules/dagster/dagster/_core/events/__init__.py
+++ b/python_modules/dagster/dagster/_core/events/__init__.py
@@ -67,6 +67,7 @@
 
 if TYPE_CHECKING:
     from dagster._core.definitions.events import ObjectStoreOperation
+    from dagster._core.definitions.freshness import FreshnessStateEvaluation
     from dagster._core.execution.plan.plan import ExecutionPlan
     from dagster._core.execution.plan.step import StepKind
 
@@ -93,6 +94,7 @@
     "AssetCheckEvaluationPlanned",
     "AssetFailedToMaterializeData",
     "RunEnqueuedData",
+    "FreshnessStateEvaluation",
 ]
 
 
@@ -169,6 +171,8 @@ class DagsterEventType(str, Enum):
 
     LOGS_CAPTURED = "LOGS_CAPTURED"
 
+    FRESHNESS_STATE_EVALUATION = "FRESHNESS_STATE_EVALUATION"
+
 
 EVENT_TYPE_TO_DISPLAY_STRING = {
     DagsterEventType.PIPELINE_ENQUEUED: "RUN_ENQUEUED",
diff --git a/python_modules/dagster/dagster/_core/instance/__init__.py b/python_modules/dagster/dagster/_core/instance/__init__.py
index 96b457b97db47..b13a163437755 100644
--- a/python_modules/dagster/dagster/_core/instance/__init__.py
+++ b/python_modules/dagster/dagster/_core/instance/__init__.py
@@ -37,6 +37,7 @@
 )
 from dagster._core.definitions.data_version import extract_data_provenance_from_entry
 from dagster._core.definitions.events import AssetKey, AssetObservation
+from dagster._core.definitions.freshness import FreshnessStateEvaluation
 from dagster._core.definitions.partition_key_range import PartitionKeyRange
 from dagster._core.errors import (
     DagsterHomeNotSetError,
@@ -3351,7 +3352,12 @@ def get_latest_materialization_code_versions(
     @public
     def report_runless_asset_event(
         self,
-        asset_event: Union["AssetMaterialization", "AssetObservation", "AssetCheckEvaluation"],
+        asset_event: Union[
+            "AssetMaterialization",
+            "AssetObservation",
+            "AssetCheckEvaluation",
+            "FreshnessStateEvaluation",
+        ],
     ):
         """Record an event log entry related to assets that does not belong to a Dagster run."""
         from dagster._core.events import (
@@ -3371,10 +3377,13 @@ def report_runless_asset_event(
         elif isinstance(asset_event, AssetObservation):
             event_type_value = DagsterEventType.ASSET_OBSERVATION.value
             data_payload = AssetObservationData(asset_event)
+        elif isinstance(asset_event, FreshnessStateEvaluation):
+            event_type_value = DagsterEventType.FRESHNESS_STATE_EVALUATION.value
+            data_payload = asset_event
         else:
             raise DagsterInvariantViolationError(
                 f"Received unexpected asset event type {asset_event}, expected"
-                " AssetMaterialization, AssetObservation or AssetCheckEvaluation"
+                " AssetMaterialization, AssetObservation, AssetCheckEvaluation or FreshnessStateEvaluation"
             )
 
         return self.report_dagster_event(
diff --git a/python_modules/dagster/dagster/_core/remote_representation/external_data.py b/python_modules/dagster/dagster/_core/remote_representation/external_data.py
index 4afab3b241230..75afcd08e8140 100644
--- a/python_modules/dagster/dagster/_core/remote_representation/external_data.py
+++ b/python_modules/dagster/dagster/_core/remote_representation/external_data.py
@@ -61,6 +61,7 @@
     OpNode,
 )
 from dagster._core.definitions.events import AssetKey
+from dagster._core.definitions.freshness import InternalFreshnessPolicy
 from dagster._core.definitions.freshness_policy import FreshnessPolicy
 from dagster._core.definitions.metadata import (
     MetadataFieldSerializer,
@@ -1553,6 +1554,10 @@ def automation_condition(self) -> Optional[AutomationCondition]:
         else:
             return None
 
+    @property
+    def internal_freshness_policy(self) -> Optional[InternalFreshnessPolicy]:
+        return InternalFreshnessPolicy.from_asset_spec_metadata(self.metadata)
+
 
 ResourceJobUsageMap: TypeAlias = dict[str, list[ResourceJobUsageEntry]]
 
diff --git a/python_modules/dagster/dagster_tests/asset_defs_tests/test_internal_freshness.py b/python_modules/dagster/dagster_tests/asset_defs_tests/test_internal_freshness.py
new file mode 100644
index 0000000000000..c926df0a01b8f
--- /dev/null
+++ b/python_modules/dagster/dagster_tests/asset_defs_tests/test_internal_freshness.py
@@ -0,0 +1,153 @@
+from datetime import timedelta
+from typing import Union
+
+from dagster._core.definitions.asset_key import AssetKey
+from dagster._core.definitions.asset_spec import AssetSpec, attach_internal_freshness_policy
+from dagster._core.definitions.assets import AssetsDefinition
+from dagster._core.definitions.decorators.asset_decorator import asset
+from dagster._core.definitions.definitions_class import Definitions
+from dagster._core.definitions.freshness import (
+    INTERNAL_FRESHNESS_POLICY_METADATA_KEY,
+    InternalFreshnessPolicy,
+    TimeWindowFreshnessPolicy,
+)
+from dagster._serdes import deserialize_value
+from dagster_shared.serdes.utils import SerializableTimeDelta
+
+from dagster_tests.core_tests.host_representation_tests.test_external_data import (
+    _get_asset_node_snaps_from_definitions,
+)
+
+
+def test_asset_decorator_with_internal_freshness_policy() -> None:
+    """Can we define an asset from decorator with an internal freshness policy?"""
+
+    @asset(
+        internal_freshness_policy=TimeWindowFreshnessPolicy.from_timedeltas(
+            fail_window=timedelta(minutes=10), warn_window=timedelta(minutes=5)
+        )
+    )
+    def asset_with_internal_freshness_policy():
+        pass
+
+    spec = asset_with_internal_freshness_policy.get_asset_spec()
+    policy = spec.metadata.get(INTERNAL_FRESHNESS_POLICY_METADATA_KEY)
+    assert policy is not None
+    deserialized = deserialize_value(policy)
+    assert isinstance(deserialized, TimeWindowFreshnessPolicy)
+    assert deserialized.fail_window == SerializableTimeDelta.from_timedelta(timedelta(minutes=10))
+    assert deserialized.warn_window == SerializableTimeDelta.from_timedelta(timedelta(minutes=5))
+
+
+def test_asset_spec_with_internal_freshness_policy() -> None:
+    """Can we define an asset spec with an internal freshness policy?"""
+
+    def create_spec_and_verify_policy(asset_key: str, fail_window: timedelta, warn_window=None):
+        asset = AssetSpec(
+            key=AssetKey(asset_key),
+            internal_freshness_policy=InternalFreshnessPolicy.time_window(
+                fail_window=fail_window, warn_window=warn_window
+            ),
+        )
+
+        asset_node_snaps = _get_asset_node_snaps_from_definitions(Definitions(assets=[asset]))
+        snap = asset_node_snaps[0]
+        policy = snap.internal_freshness_policy
+        assert isinstance(policy, TimeWindowFreshnessPolicy)
+        assert policy.fail_window == SerializableTimeDelta.from_timedelta(fail_window)
+        if warn_window:
+            assert policy.warn_window == SerializableTimeDelta.from_timedelta(warn_window)
+        else:
+            assert policy.warn_window is None
+
+    # Test without warn window
+    create_spec_and_verify_policy("asset1", fail_window=timedelta(minutes=10))
+
+    # Test with optional warn window
+    create_spec_and_verify_policy(
+        "asset2", fail_window=timedelta(minutes=10), warn_window=timedelta(minutes=5)
+    )
+
+
+def test_attach_internal_freshness_policy() -> None:
+    """Can we attach an internal freshness policy to an asset spec?"""
+
+    def assert_freshness_policy(spec, expected_fail_window, expected_warn_window=None):
+        metadata = spec.metadata
+        assert INTERNAL_FRESHNESS_POLICY_METADATA_KEY in metadata
+        deserialized = deserialize_value(metadata[INTERNAL_FRESHNESS_POLICY_METADATA_KEY])
+        assert isinstance(deserialized, TimeWindowFreshnessPolicy)
+        assert deserialized.fail_window == SerializableTimeDelta.from_timedelta(
+            expected_fail_window
+        )
+        if expected_warn_window:
+            assert deserialized.warn_window == SerializableTimeDelta.from_timedelta(
+                expected_warn_window
+            )
+        else:
+            assert deserialized.warn_window is None
+
+    asset_spec = AssetSpec(key="foo")
+    asset_spec = attach_internal_freshness_policy(
+        asset_spec,
+        InternalFreshnessPolicy.time_window(
+            fail_window=timedelta(minutes=10), warn_window=timedelta(minutes=5)
+        ),
+    )
+    assert_freshness_policy(
+        asset_spec,
+        expected_fail_window=timedelta(minutes=10),
+        expected_warn_window=timedelta(minutes=5),
+    )
+
+    # Overwrite the policy with a new one
+    asset_spec = attach_internal_freshness_policy(
+        asset_spec, InternalFreshnessPolicy.time_window(fail_window=timedelta(minutes=60))
+    )
+    assert_freshness_policy(asset_spec, expected_fail_window=timedelta(minutes=60))
+
+    # Don't overwrite existing metadata
+    spec_with_metadata = AssetSpec(key="bar", metadata={"existing": "metadata"})
+    spec_with_metadata = attach_internal_freshness_policy(
+        spec_with_metadata,
+        InternalFreshnessPolicy.time_window(fail_window=timedelta(minutes=60)),
+    )
+    assert spec_with_metadata.metadata.get("existing") == "metadata"
+    assert_freshness_policy(
+        spec_with_metadata,
+        expected_fail_window=timedelta(minutes=60),
+        expected_warn_window=None,
+    )
+
+
+def test_map_asset_specs_attach_internal_freshness_policy() -> None:
+    """Can we map attach_internal_freshness_policy over a selection of assets and asset specs?"""
+
+    @asset
+    def foo_asset():
+        pass
+
+    asset_specs = [foo_asset, AssetSpec(key="bar"), AssetSpec(key="baz")]
+    defs: Definitions = Definitions(assets=asset_specs)
+
+    freshness_policy = TimeWindowFreshnessPolicy.from_timedeltas(
+        fail_window=timedelta(minutes=10), warn_window=timedelta(minutes=5)
+    )
+    mapped_defs = defs.map_asset_specs(
+        func=lambda spec: attach_internal_freshness_policy(spec, freshness_policy)
+    )
+
+    assets_and_specs = mapped_defs.assets
+    assert assets_and_specs is not None
+    for asset_or_spec in assets_and_specs:
+        assert isinstance(asset_or_spec, Union[AssetsDefinition, AssetSpec])
+        spec = (
+            asset_or_spec.get_asset_spec()
+            if isinstance(asset_or_spec, AssetsDefinition)
+            else asset_or_spec
+        )
+        assert INTERNAL_FRESHNESS_POLICY_METADATA_KEY in spec.metadata
+        policy = deserialize_value(spec.metadata[INTERNAL_FRESHNESS_POLICY_METADATA_KEY])
+        assert isinstance(policy, TimeWindowFreshnessPolicy)
+        assert policy.fail_window == SerializableTimeDelta.from_timedelta(timedelta(minutes=10))
+        assert policy.warn_window == SerializableTimeDelta.from_timedelta(timedelta(minutes=5))
diff --git a/python_modules/libraries/dagster-azure/dagster_azure/adls2/io_manager.py b/python_modules/libraries/dagster-azure/dagster_azure/adls2/io_manager.py
index e6b88b0721359..9354bb6d93903 100644
--- a/python_modules/libraries/dagster-azure/dagster_azure/adls2/io_manager.py
+++ b/python_modules/libraries/dagster-azure/dagster_azure/adls2/io_manager.py
@@ -90,7 +90,7 @@ def _acquire_lease(self, client: Any, is_rm: bool = False) -> Iterator[str]:
         lease_client = self.lease_client_constructor(client=client)
         try:
             # Unclear why this needs to be type-ignored
-            lease_client.acquire(lease_duration=self.lease_duration)  # type: ignore
+            lease_client.acquire(lease_duration=self.lease_duration)
             yield lease_client.id
         finally:
             # cannot release a lease on a file that no longer exists, so need to check
diff --git a/python_modules/libraries/dagster-shared/dagster_shared/serdes/utils.py b/python_modules/libraries/dagster-shared/dagster_shared/serdes/utils.py
index 7427ac90d49ea..2585d6ab3b351 100644
--- a/python_modules/libraries/dagster-shared/dagster_shared/serdes/utils.py
+++ b/python_modules/libraries/dagster-shared/dagster_shared/serdes/utils.py
@@ -1,7 +1,13 @@
+import datetime
 import hashlib
-from typing import Optional
+from typing import NamedTuple, Optional
 
-from dagster_shared.serdes.serdes import PackableValue, WhitelistMap, serialize_value
+from dagster_shared.serdes.serdes import (
+    PackableValue,
+    WhitelistMap,
+    serialize_value,
+    whitelist_for_serdes,
+)
 
 
 def create_snapshot_id(
@@ -20,3 +26,26 @@ def hash_str(in_str: str) -> str:
 def serialize_pp(value: PackableValue) -> str:
     """Serialize and pretty print."""
     return serialize_value(value, indent=2, separators=(",", ": "))
+
+
+@whitelist_for_serdes
+class SerializableTimeDelta(NamedTuple):
+    """A Dagster-serializable version of a datetime.timedelta. The datetime.timedelta class
+    internally stores values as an integer number of days, seconds, and microseconds. This class
+    handles converting between the in-memory and serializable formats.
+    """
+
+    days: int
+    seconds: int
+    microseconds: int
+
+    @staticmethod
+    def from_timedelta(timedelta: datetime.timedelta) -> "SerializableTimeDelta":
+        return SerializableTimeDelta(
+            days=timedelta.days, seconds=timedelta.seconds, microseconds=timedelta.microseconds
+        )
+
+    def to_timedelta(self) -> datetime.timedelta:
+        return datetime.timedelta(
+            days=self.days, seconds=self.seconds, microseconds=self.microseconds
+        )


Reviews:
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T18:42:24Z
user: login: OwenKephart
body: not all the comments here are blocking, but the main thing is that I really don't think we can add a new non-hidden argument to AssetSpec that we plan to remove in the near future, especially due to the number of APIs we end up needing to thread this ephemeral parameter through.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T21:41:20Z
user: login: OwenKephart
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T21:43:17Z
user: login: anuthebananu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T16:17:33Z
user: login: anuthebananu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T16:18:25Z
user: login: OwenKephart
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T16:24:26Z
user: login: anuthebananu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T17:07:07Z
user: login: dpeng817
body: As discussed offline, let's move forward with the asset metadata storage approach for now
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T17:15:04Z
user: login: anuthebananu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T17:31:14Z
user: login: anuthebananu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T18:07:07Z
user: login: anuthebananu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T18:08:15Z
user: login: OwenKephart
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T18:33:45Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T19:09:53Z
user: login: dpeng817
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T20:22:35Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T21:18:48Z
user: login: anuthebananu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T21:27:31Z
user: login: anuthebananu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T21:28:42Z
user: login: anuthebananu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T21:35:06Z
user: login: anuthebananu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T21:35:59Z
user: login: anuthebananu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T21:43:48Z
user: login: OwenKephart
body: Great work -- this feels way cleaner / safer. Had two extremely tiny changes, and also we should have a test that includes the `@asset` decorator.

Approving as this is definitely in a mergeable state right now, just would prefer those changes to be made first.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T21:52:53Z

Review comments:
diff_hunk: @@ -249,7 +253,7 @@ def asset(
         pool (Optional[str]): A string that identifies the concurrency pool that governs this asset's execution.
         non_argument_deps (Optional[Union[Set[AssetKey], Set[str]]]): Deprecated, use deps instead.
             Set of asset keys that are upstream dependencies, but do not pass an input to the asset.
-            Hidden parameter not exposed in the decorator signature, but passed in kwargs.
+            Hidden parameter not exposed in the decorator signature, but passed in kwargs. Currentily experimental.
path: python_modules/dagster/dagster/_core/definitions/decorators/asset_decorator.py
user: login: graphite-app[bot]
body: There's a typo in the docstring: `Currentily` should be `Currently`
```suggestion
            Hidden parameter not exposed in the decorator signature, but passed in kwargs. Currently experimental.
```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=dagster-io&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-04-08T18:42:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,30 @@
+from enum import Enum
+from typing import NamedTuple
+
+from dagster._annotations import beta
+from dagster._core.definitions.asset_key import AssetKey
+from dagster._serdes import whitelist_for_serdes
+
+
+@beta
+@whitelist_for_serdes
+class FreshnessState(str, Enum):
+    PASSING = "PASSING"
+    IN_VIOLATION = "IN_VIOLATION"
+    NEAR_VIOLATION = "NEAR_VIOLATION"
+    UNKNOWN = "UNKNOWN"
+
+
+@beta
+@whitelist_for_serdes
+class FreshnessStateEvaluation(
+    NamedTuple(
+        "_FreshnessStateEvaluation",
+        [
+            ("asset_key", AssetKey),
path: python_modules/dagster/dagster/_core/definitions/freshness.py
user: login: OwenKephart
body: can we call this argument "key" for future-proofing?

I don't think it's necessarily _that_ likely, but there's a world in which we want to expand freshness evaluations to arbitrary EntityKeys (e.g. asset keys or check keys or whatever else might live in the asset graph), and having this parameter named "key" leaves the door open for us to widen the type in the future.
created_at: 2025-04-08T20:34:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,30 @@
+from enum import Enum
+from typing import NamedTuple
+
+from dagster._annotations import beta
+from dagster._core.definitions.asset_key import AssetKey
+from dagster._serdes import whitelist_for_serdes
+
+
+@beta
+@whitelist_for_serdes
+class FreshnessState(str, Enum):
+    PASSING = "PASSING"
+    IN_VIOLATION = "IN_VIOLATION"
+    NEAR_VIOLATION = "NEAR_VIOLATION"
+    UNKNOWN = "UNKNOWN"
+
+
+@beta
+@whitelist_for_serdes
+class FreshnessStateEvaluation(
+    NamedTuple(
path: python_modules/dagster/dagster/_core/definitions/freshness.py
user: login: OwenKephart
body: can this just be an `@record`?
created_at: 2025-04-08T20:35:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -249,7 +253,7 @@ def asset(
         pool (Optional[str]): A string that identifies the concurrency pool that governs this asset's execution.
         non_argument_deps (Optional[Union[Set[AssetKey], Set[str]]]): Deprecated, use deps instead.
             Set of asset keys that are upstream dependencies, but do not pass an input to the asset.
-            Hidden parameter not exposed in the decorator signature, but passed in kwargs.
+            Hidden parameter not exposed in the decorator signature, but passed in kwargs. Currently experimental.
path: python_modules/dagster/dagster/_core/definitions/decorators/asset_decorator.py
user: login: OwenKephart
body: non_argument_deps is deprecated, not experimental -- can rm this change
created_at: 2025-04-08T20:37:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,93 @@
+from abc import ABC, abstractmethod
+from enum import Enum
+from typing import Optional
+
+from dagster import _check as check
+from dagster._annotations import beta
+from dagster._record import IHaveNew, record_custom
+from dagster._serdes import whitelist_for_serdes
+
+
+class FreshnessPolicyType(Enum):
path: python_modules/dagster/dagster/_core/definitions/new_freshness_policy.py
user: login: OwenKephart
body: it's not clear to me what the purpose of this FreshnessPolicyType enum is for now -- can we just get rid of it?
created_at: 2025-04-08T20:39:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,30 @@
+from enum import Enum
+from typing import NamedTuple
+
+from dagster._annotations import beta
+from dagster._core.definitions.asset_key import AssetKey
+from dagster._serdes import whitelist_for_serdes
+
+
+@beta
+@whitelist_for_serdes
+class FreshnessState(str, Enum):
+    PASSING = "PASSING"
+    IN_VIOLATION = "IN_VIOLATION"
+    NEAR_VIOLATION = "NEAR_VIOLATION"
+    UNKNOWN = "UNKNOWN"
path: python_modules/dagster/dagster/_core/definitions/freshness.py
user: login: OwenKephart
body: These names are going to be serialized, meaning they are at least somewhat permanent regardless of the experimental nature. Personally, I'd advocate for:

- FRESH
- DELAYED
- STALE
- UNKNOWN

but I'm not sure if there was additional conversation regarding these names that I'm out of the loop on
created_at: 2025-04-08T20:43:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,93 @@
+from abc import ABC, abstractmethod
+from enum import Enum
+from typing import Optional
+
+from dagster import _check as check
+from dagster._annotations import beta
+from dagster._record import IHaveNew, record_custom
+from dagster._serdes import whitelist_for_serdes
+
+
+class FreshnessPolicyType(Enum):
+    TIME_WINDOW = "time_window"
+    CRON = "cron"
+
+
+class NewFreshnessPolicy(ABC):
+    """A condition that delineates when an asset is considered fresh. The asset can either pass, violate, or warn on this condition.
+
+    This is intended to replace the existing, deprecated `FreshnessPolicy` construct.
+
+    `NewFreshnessPolicy` can be specified as a parameter to `@asset`, `@multi_asset`, or in `AssetSpec`.
+
+    Do not use this class directly. Instead, use one of the subclasses.
+    """
+
+    @property
+    @abstractmethod
+    def policy_type(self) -> FreshnessPolicyType: ...
+
+
+@beta
+@whitelist_for_serdes
+@record_custom
+class TimeWindowFreshnessPolicy(NewFreshnessPolicy, IHaveNew):
+    """[BETA/EXPERIMENTAL] A freshness condition that considers an asset fresh if it was materialized within a specified time window (time_window_minutes).
+
+    The asset is considered stale if its last materialization is older than time_window_minutes, and in a warning state
+    if its last materialization is older than the warning time window but newer than the critical time window.
+
+    Example usage:
+
+    .. code-block:: python
+
+        from dagster import TimeWindowFreshnessPolicy
path: python_modules/dagster/dagster/_core/definitions/new_freshness_policy.py
user: login: OwenKephart
body: A pattern that we have used elsewhere (AssetSelection, AutomationCondition) and is fairly effective is to use static constructors on the base class to instantiate these. This limits the number of exports we need from the top-level dagster package and generally results in cleaner code. It also gives us ways to provide a different user-facing interface from the raw serializable representation that we have on the actual objects. This would look like:

```python
class NewFreshenssPolicy(ABC):

    @staticmethod
    def time_window(stale_threshold: datetime.timedelta, delayed_threshold: Optional[datetime.timedelta]=None) -> "TimeWindowFreshnessPolicy":
        return TimeWindowFreshnessPolicy.from_timedeltas(stale_threshold, delayed_threshold)
```

You can see an example of this pattern here: https://sourcegraph.com/github.com/dagster-io/dagster/-/blob/python_modules/dagster/dagster/_core/definitions/declarative_automation/automation_condition.py?L461
created_at: 2025-04-08T20:49:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,93 @@
+from abc import ABC, abstractmethod
+from enum import Enum
+from typing import Optional
+
+from dagster import _check as check
+from dagster._annotations import beta
+from dagster._record import IHaveNew, record_custom
+from dagster._serdes import whitelist_for_serdes
+
+
+class FreshnessPolicyType(Enum):
+    TIME_WINDOW = "time_window"
+    CRON = "cron"
+
+
+class NewFreshnessPolicy(ABC):
+    """A condition that delineates when an asset is considered fresh. The asset can either pass, violate, or warn on this condition.
+
+    This is intended to replace the existing, deprecated `FreshnessPolicy` construct.
+
+    `NewFreshnessPolicy` can be specified as a parameter to `@asset`, `@multi_asset`, or in `AssetSpec`.
+
+    Do not use this class directly. Instead, use one of the subclasses.
+    """
+
+    @property
+    @abstractmethod
+    def policy_type(self) -> FreshnessPolicyType: ...
+
+
+@beta
+@whitelist_for_serdes
+@record_custom
+class TimeWindowFreshnessPolicy(NewFreshnessPolicy, IHaveNew):
+    """[BETA/EXPERIMENTAL] A freshness condition that considers an asset fresh if it was materialized within a specified time window (time_window_minutes).
+
+    The asset is considered stale if its last materialization is older than time_window_minutes, and in a warning state
+    if its last materialization is older than the warning time window but newer than the critical time window.
+
+    Example usage:
+
+    .. code-block:: python
+
+        from dagster import TimeWindowFreshnessPolicy
+
+        # Asset must be materialized within last 24 hours (1440 minutes)
+        @asset(new_freshness_policy=TimeWindowFreshnessPolicy(time_window_minutes=1440))
+        def my_asset():
+            ...
+
+        # Asset must be materialized within last 24 hours (1440 minutes), with warning after 12 hours
+        @asset(new_freshness_policy=TimeWindowFreshnessPolicy(time_window_minutes=1440, warning_time_window_minutes=720))
+        def my_asset():
+            ...
+
+    Args:
+        time_window_minutes (int): The maximum age in minutes for an asset to be considered fresh.
+            Must be positive.
+        warning_time_window_minutes (Optional[int]): If provided, the age in minutes at which to start
+            showing warnings about asset freshness. Must be positive and less than time_window_minutes.
+    """
+
+    time_window_minutes: int
path: python_modules/dagster/dagster/_core/definitions/new_freshness_policy.py
user: login: OwenKephart
body: Following from the above, this should be a [SerializableTimeDelta](https://sourcegraph.com/github.com/dagster-io/dagster/-/blob/python_modules/dagster/dagster/_core/definitions/declarative_automation/operands/operands.py?L288).
created_at: 2025-04-08T20:50:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,93 @@
+from abc import ABC, abstractmethod
+from enum import Enum
+from typing import Optional
+
+from dagster import _check as check
+from dagster._annotations import beta
+from dagster._record import IHaveNew, record_custom
+from dagster._serdes import whitelist_for_serdes
+
+
+class FreshnessPolicyType(Enum):
+    TIME_WINDOW = "time_window"
+    CRON = "cron"
+
+
+class NewFreshnessPolicy(ABC):
+    """A condition that delineates when an asset is considered fresh. The asset can either pass, violate, or warn on this condition.
+
+    This is intended to replace the existing, deprecated `FreshnessPolicy` construct.
+
+    `NewFreshnessPolicy` can be specified as a parameter to `@asset`, `@multi_asset`, or in `AssetSpec`.
+
+    Do not use this class directly. Instead, use one of the subclasses.
+    """
+
+    @property
+    @abstractmethod
+    def policy_type(self) -> FreshnessPolicyType: ...
+
+
+@beta
+@whitelist_for_serdes
+@record_custom
+class TimeWindowFreshnessPolicy(NewFreshnessPolicy, IHaveNew):
+    """[BETA/EXPERIMENTAL] A freshness condition that considers an asset fresh if it was materialized within a specified time window (time_window_minutes).
+
+    The asset is considered stale if its last materialization is older than time_window_minutes, and in a warning state
+    if its last materialization is older than the warning time window but newer than the critical time window.
+
+    Example usage:
+
+    .. code-block:: python
+
+        from dagster import TimeWindowFreshnessPolicy
+
+        # Asset must be materialized within last 24 hours (1440 minutes)
+        @asset(new_freshness_policy=TimeWindowFreshnessPolicy(time_window_minutes=1440))
+        def my_asset():
+            ...
+
+        # Asset must be materialized within last 24 hours (1440 minutes), with warning after 12 hours
+        @asset(new_freshness_policy=TimeWindowFreshnessPolicy(time_window_minutes=1440, warning_time_window_minutes=720))
+        def my_asset():
+            ...
+
+    Args:
+        time_window_minutes (int): The maximum age in minutes for an asset to be considered fresh.
+            Must be positive.
+        warning_time_window_minutes (Optional[int]): If provided, the age in minutes at which to start
+            showing warnings about asset freshness. Must be positive and less than time_window_minutes.
+    """
+
+    time_window_minutes: int
+    warning_time_window_minutes: Optional[int] = None
path: python_modules/dagster/dagster/_core/definitions/new_freshness_policy.py
user: login: OwenKephart
body: if the parameter here is called `warning_time_window_minutes`, then this implies that there is a state called "warning", but given the above enum, that is not a state that exists

being annoying here because this is a serialized object and so changes are harder to make after the fact. I think this is somewhat of an argument against using `IN_VIOLATION` and `NEAR_VIOLATION` as statuses (re: my above comment).

these could be called `stale_threshold` and `delayed_threshold`, for example (not tied to those specific names, but generally seems useful to not tie both names to the word "violation"
created_at: 2025-04-08T21:01:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -225,13 +231,15 @@ def to_spec(
         deps: Sequence[AssetDep],
         additional_tags: Mapping[str, str] = {},
         partitions_def: Optional[PartitionsDefinition] = ...,
+        new_freshness_policy: Optional[NewFreshnessPolicy] = ...,
     ) -> AssetSpec:
         return self._spec.replace_attributes(
             key=key,
             tags={**additional_tags, **self.tags} if self.tags else additional_tags,
             kinds=self.kinds,
             deps=[*self._spec.deps, *deps],
             partitions_def=partitions_def if partitions_def is not None else ...,
+            new_freshness_policy=new_freshness_policy if new_freshness_policy is not None else ...,
path: python_modules/dagster/dagster/_core/definitions/asset_out.py
user: login: OwenKephart
body: discussed in slack but the fact that so many of these APIs need to be modified for a parameter that we're expecting to be ephemeral is pretty suspicious.

this is one of those codepaths that can stay unaltered if we just toss this info into the metadata dictionary
created_at: 2025-04-08T21:35:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,93 @@
+from abc import ABC, abstractmethod
+from enum import Enum
+from typing import Optional
+
+from dagster import _check as check
+from dagster._annotations import beta
+from dagster._record import IHaveNew, record_custom
+from dagster._serdes import whitelist_for_serdes
+
+
+class FreshnessPolicyType(Enum):
+    TIME_WINDOW = "time_window"
+    CRON = "cron"
+
+
+class NewFreshnessPolicy(ABC):
+    """A condition that delineates when an asset is considered fresh. The asset can either pass, violate, or warn on this condition.
+
+    This is intended to replace the existing, deprecated `FreshnessPolicy` construct.
+
+    `NewFreshnessPolicy` can be specified as a parameter to `@asset`, `@multi_asset`, or in `AssetSpec`.
+
+    Do not use this class directly. Instead, use one of the subclasses.
+    """
+
+    @property
+    @abstractmethod
+    def policy_type(self) -> FreshnessPolicyType: ...
+
+
+@beta
+@whitelist_for_serdes
+@record_custom
+class TimeWindowFreshnessPolicy(NewFreshnessPolicy, IHaveNew):
+    """[BETA/EXPERIMENTAL] A freshness condition that considers an asset fresh if it was materialized within a specified time window (time_window_minutes).
+
+    The asset is considered stale if its last materialization is older than time_window_minutes, and in a warning state
+    if its last materialization is older than the warning time window but newer than the critical time window.
+
+    Example usage:
+
+    .. code-block:: python
+
+        from dagster import TimeWindowFreshnessPolicy
path: python_modules/dagster/dagster/_core/definitions/new_freshness_policy.py
user: login: OwenKephart
body: Note that this feedback isn't necessarily blocking for this v0, but definitely should refactor to this format before getting any "real" usage
created_at: 2025-04-08T21:43:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,30 @@
+from enum import Enum
+from typing import NamedTuple
+
+from dagster._annotations import beta
+from dagster._core.definitions.asset_key import AssetKey
+from dagster._serdes import whitelist_for_serdes
+
+
+@beta
+@whitelist_for_serdes
+class FreshnessState(str, Enum):
+    PASSING = "PASSING"
+    IN_VIOLATION = "IN_VIOLATION"
+    NEAR_VIOLATION = "NEAR_VIOLATION"
+    UNKNOWN = "UNKNOWN"
path: python_modules/dagster/dagster/_core/definitions/freshness.py
user: login: dpeng817
body: Probably worth a naming thread, but stale feels fairly loaded in Dagster which is I think why folks wanted to avoid it. I do not feel strongly.
created_at: 2025-04-09T15:30:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,93 @@
+from abc import ABC, abstractmethod
+from enum import Enum
+from typing import Optional
+
+from dagster import _check as check
+from dagster._annotations import beta
+from dagster._record import IHaveNew, record_custom
+from dagster._serdes import whitelist_for_serdes
+
+
+class FreshnessPolicyType(Enum):
path: python_modules/dagster/dagster/_core/definitions/new_freshness_policy.py
user: login: anuthebananu
body: This will be used in cloud to select the right evaluator to actually evaluate freshness.

```
class FreshnessEvaluator(ABC):
  @abstractmethod
  def evaluate(asset_node: AssetNode): -> FreshnessState: ...

class TimeWindowFreshnessEvaluator(FreshnessEvaluator):
  def evaluate(asset_node): ...
    # evaluate time window freshness for an asset node

# ... evaluators for other policy types

FRESHNESS_EVALUATORS_BY_POLICY_TYPE = {
  FreshnessPolicyType.TIME_WINDOW: TimeWindowFreshnessEvaluator
  ...
}
```
created_at: 2025-04-09T16:17:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,93 @@
+from abc import ABC, abstractmethod
+from enum import Enum
+from typing import Optional
+
+from dagster import _check as check
+from dagster._annotations import beta
+from dagster._record import IHaveNew, record_custom
+from dagster._serdes import whitelist_for_serdes
+
+
+class FreshnessPolicyType(Enum):
path: python_modules/dagster/dagster/_core/definitions/new_freshness_policy.py
user: login: anuthebananu
body: Planning to move the evaluators to OSS eventually when we fork the freshness daemon.

Reading this back I think it makes more sense to have the evaluation logic live with the policy, wdyt?
created_at: 2025-04-09T16:18:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,93 @@
+from abc import ABC, abstractmethod
+from enum import Enum
+from typing import Optional
+
+from dagster import _check as check
+from dagster._annotations import beta
+from dagster._record import IHaveNew, record_custom
+from dagster._serdes import whitelist_for_serdes
+
+
+class FreshnessPolicyType(Enum):
path: python_modules/dagster/dagster/_core/definitions/new_freshness_policy.py
user: login: OwenKephart
body: Ah yep, definitely think it makes more sense to have the evaluation logic live inside the policy -- eliminates a whole class of version-skew concerns
created_at: 2025-04-09T16:24:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,93 @@
+from abc import ABC, abstractmethod
+from enum import Enum
+from typing import Optional
+
+from dagster import _check as check
+from dagster._annotations import beta
+from dagster._record import IHaveNew, record_custom
+from dagster._serdes import whitelist_for_serdes
+
+
+class FreshnessPolicyType(Enum):
+    TIME_WINDOW = "time_window"
+    CRON = "cron"
+
+
+class NewFreshnessPolicy(ABC):
+    """A condition that delineates when an asset is considered fresh. The asset can either pass, violate, or warn on this condition.
+
+    This is intended to replace the existing, deprecated `FreshnessPolicy` construct.
+
+    `NewFreshnessPolicy` can be specified as a parameter to `@asset`, `@multi_asset`, or in `AssetSpec`.
+
+    Do not use this class directly. Instead, use one of the subclasses.
+    """
+
+    @property
+    @abstractmethod
+    def policy_type(self) -> FreshnessPolicyType: ...
+
+
+@beta
+@whitelist_for_serdes
+@record_custom
+class TimeWindowFreshnessPolicy(NewFreshnessPolicy, IHaveNew):
+    """[BETA/EXPERIMENTAL] A freshness condition that considers an asset fresh if it was materialized within a specified time window (time_window_minutes).
+
+    The asset is considered stale if its last materialization is older than time_window_minutes, and in a warning state
+    if its last materialization is older than the warning time window but newer than the critical time window.
+
+    Example usage:
+
+    .. code-block:: python
+
+        from dagster import TimeWindowFreshnessPolicy
path: python_modules/dagster/dagster/_core/definitions/new_freshness_policy.py
user: login: dpeng817
body: +1. Just to say what owen said in a slightly different way; having a layer of indirection between the instantiation and the user facing API allows us to do fancy/goofy stuff for backcompat easier.
created_at: 2025-04-09T17:02:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,93 @@
+from abc import ABC, abstractmethod
+from enum import Enum
+from typing import Optional
+
+from dagster import _check as check
+from dagster._annotations import beta
+from dagster._record import IHaveNew, record_custom
+from dagster._serdes import whitelist_for_serdes
+
+
+class FreshnessPolicyType(Enum):
path: python_modules/dagster/dagster/_core/definitions/new_freshness_policy.py
user: login: dpeng817
body: +1 that the evaluation logic should just live inside the policy. The "freshness policy type" stuff that I had in the POC was mostly to support yaml format rich error messages.
created_at: 2025-04-09T17:03:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,30 @@
+from enum import Enum
+from typing import NamedTuple
+
+from dagster._annotations import beta
+from dagster._core.definitions.asset_key import AssetKey
+from dagster._serdes import whitelist_for_serdes
+
+
+@beta
+@whitelist_for_serdes
+class FreshnessState(str, Enum):
+    PASSING = "PASSING"
+    IN_VIOLATION = "IN_VIOLATION"
+    NEAR_VIOLATION = "NEAR_VIOLATION"
+    UNKNOWN = "UNKNOWN"
+
+
+@beta
+@whitelist_for_serdes
+class FreshnessStateEvaluation(
+    NamedTuple(
+        "_FreshnessStateEvaluation",
+        [
+            ("asset_key", AssetKey),
path: python_modules/dagster/dagster/_core/definitions/freshness.py
user: login: anuthebananu
body: Yeah agreed - we are using `entity_key` in cloud, i think that could work here too
created_at: 2025-04-09T17:07:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,93 @@
+from abc import ABC, abstractmethod
+from enum import Enum
+from typing import Optional
+
+from dagster import _check as check
+from dagster._annotations import beta
+from dagster._record import IHaveNew, record_custom
+from dagster._serdes import whitelist_for_serdes
+
+
+class FreshnessPolicyType(Enum):
+    TIME_WINDOW = "time_window"
+    CRON = "cron"
+
+
+class NewFreshnessPolicy(ABC):
+    """A condition that delineates when an asset is considered fresh. The asset can either pass, violate, or warn on this condition.
+
+    This is intended to replace the existing, deprecated `FreshnessPolicy` construct.
+
+    `NewFreshnessPolicy` can be specified as a parameter to `@asset`, `@multi_asset`, or in `AssetSpec`.
+
+    Do not use this class directly. Instead, use one of the subclasses.
+    """
+
+    @property
+    @abstractmethod
+    def policy_type(self) -> FreshnessPolicyType: ...
+
+
+@beta
+@whitelist_for_serdes
+@record_custom
+class TimeWindowFreshnessPolicy(NewFreshnessPolicy, IHaveNew):
+    """[BETA/EXPERIMENTAL] A freshness condition that considers an asset fresh if it was materialized within a specified time window (time_window_minutes).
+
+    The asset is considered stale if its last materialization is older than time_window_minutes, and in a warning state
+    if its last materialization is older than the warning time window but newer than the critical time window.
+
+    Example usage:
+
+    .. code-block:: python
+
+        from dagster import TimeWindowFreshnessPolicy
+
+        # Asset must be materialized within last 24 hours (1440 minutes)
+        @asset(new_freshness_policy=TimeWindowFreshnessPolicy(time_window_minutes=1440))
+        def my_asset():
+            ...
+
+        # Asset must be materialized within last 24 hours (1440 minutes), with warning after 12 hours
+        @asset(new_freshness_policy=TimeWindowFreshnessPolicy(time_window_minutes=1440, warning_time_window_minutes=720))
+        def my_asset():
+            ...
+
+    Args:
+        time_window_minutes (int): The maximum age in minutes for an asset to be considered fresh.
+            Must be positive.
+        warning_time_window_minutes (Optional[int]): If provided, the age in minutes at which to start
+            showing warnings about asset freshness. Must be positive and less than time_window_minutes.
+    """
+
+    time_window_minutes: int
+    warning_time_window_minutes: Optional[int] = None
+
+    @property
+    def policy_type(self) -> FreshnessPolicyType:
+        return FreshnessPolicyType.TIME_WINDOW
path: python_modules/dagster/dagster/_core/definitions/new_freshness_policy.py
user: login: dpeng817
body: this can happen in a downstream PR (probably better it does actually, as this already has a decent bit of content), but the actual "freshness evaluation method" should definitely be part of this class. In addition to the version skew considerations owen mentioned, you can imagine wanting to allow users to call this directly from user code for example in order to define their own user-code based freshness, as in the product review.
created_at: 2025-04-09T17:08:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -127,6 +128,11 @@ def job_names(self) -> Sequence[str]:
             else []
         )
 
+    @property
+    def new_freshness_policy(self) -> Optional[NewFreshnessPolicy]:
+        """Experimental, do not use."""
+        return self.resolve_to_singular_repo_scoped_node().asset_node_snap.new_freshness_policy
path: python_modules/dagster/dagster/_core/definitions/remote_asset_graph.py
user: login: dpeng817
body: I know that we're not taking this approach; but if we were taking the approach I think for the initial alpha release you don't actually need any of these properties. You can just thread it on the asset node snap and access it directly in cloud?

In general the strategy woudl be to minimize surface area for the alpha release.
created_at: 2025-04-09T17:13:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -127,6 +128,11 @@ def job_names(self) -> Sequence[str]:
             else []
         )
 
+    @property
+    def new_freshness_policy(self) -> Optional[NewFreshnessPolicy]:
+        """Experimental, do not use."""
+        return self.resolve_to_singular_repo_scoped_node().asset_node_snap.new_freshness_policy
path: python_modules/dagster/dagster/_core/definitions/remote_asset_graph.py
user: login: anuthebananu
body: yup, getting rid of this and the other `new_freshness_policy` properties everywhere else
created_at: 2025-04-09T17:31:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,30 @@
+from enum import Enum
+from typing import NamedTuple
+
+from dagster._annotations import beta
+from dagster._core.definitions.asset_key import AssetKey
+from dagster._serdes import whitelist_for_serdes
+
+
+@beta
+@whitelist_for_serdes
+class FreshnessState(str, Enum):
+    PASSING = "PASSING"
+    IN_VIOLATION = "IN_VIOLATION"
+    NEAR_VIOLATION = "NEAR_VIOLATION"
+    UNKNOWN = "UNKNOWN"
path: python_modules/dagster/dagster/_core/definitions/freshness.py
user: login: anuthebananu
body: Naming thread is here: https://dagsterlabs.slack.com/archives/C047L6H0LF4/p1743697967200509

A few other factors to consider:
- not introducing too many new nouns/adjectives
- being consistent with states across asset freshness / larger observability work (i believe @jamiedemaria is using `HEALTHY` | `WARNING` | `DEGRADED` for asset health?)
- being (relatively) consistent with how freshness state for an asset is represented in the UI (although personally i don't think these necessarily need to match 1:1)
created_at: 2025-04-09T18:07:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,30 @@
+from enum import Enum
+from typing import NamedTuple
+
+from dagster._annotations import beta
+from dagster._core.definitions.asset_key import AssetKey
+from dagster._serdes import whitelist_for_serdes
+
+
+@beta
+@whitelist_for_serdes
+class FreshnessState(str, Enum):
+    PASSING = "PASSING"
+    IN_VIOLATION = "IN_VIOLATION"
+    NEAR_VIOLATION = "NEAR_VIOLATION"
+    UNKNOWN = "UNKNOWN"
+
+
+@beta
+@whitelist_for_serdes
+class FreshnessStateEvaluation(
+    NamedTuple(
path: python_modules/dagster/dagster/_core/definitions/freshness.py
user: login: anuthebananu
body: Dumb question - when should one use `@record` over `@whitelist_for_serdes` over combination of the two? I am not super clear on the differences and haven't had the time to dig into the code deeply. I do know we use `@whitelist_for_serdes` for classes that need to be serialized, but that's about it.
created_at: 2025-04-09T18:08:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,30 @@
+from enum import Enum
+from typing import NamedTuple
+
+from dagster._annotations import beta
+from dagster._core.definitions.asset_key import AssetKey
+from dagster._serdes import whitelist_for_serdes
+
+
+@beta
+@whitelist_for_serdes
+class FreshnessState(str, Enum):
+    PASSING = "PASSING"
+    IN_VIOLATION = "IN_VIOLATION"
+    NEAR_VIOLATION = "NEAR_VIOLATION"
+    UNKNOWN = "UNKNOWN"
+
+
+@beta
+@whitelist_for_serdes
+class FreshnessStateEvaluation(
+    NamedTuple(
path: python_modules/dagster/dagster/_core/definitions/freshness.py
user: login: OwenKephart
body: ah yeah we have a bunch of homegrown frameworks here -- my comment was actually regarding using `@record` instead of subclassing NamedTuple, as both are ways to create value classes, but `@record` has the benefit of being automatically type-checked.

`@whitelist_for_serdes` is independent of that (it works with either NamedTuples or records (or dataclasses)), and that should stick around

```python
@whitelist_for_serdes
@record
class FreshnessStateEvaluation:
    key: AssetKey
    freshness_state: FreshnessState
```

should do what you want
created_at: 2025-04-09T18:33:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3371,10 +3377,13 @@ def report_runless_asset_event(
         elif isinstance(asset_event, AssetObservation):
             event_type_value = DagsterEventType.ASSET_OBSERVATION.value
             data_payload = AssetObservationData(asset_event)
+        elif isinstance(asset_event, FreshnessStateEvaluation):
+            event_type_value = DagsterEventType.FRESHNESS_STATE_EVALUATION.value
+            data_payload = asset_event
         else:
             raise DagsterInvariantViolationError(
                 f"Received unexpected asset event type {asset_event}, expected"
-                " AssetMaterialization, AssetObservation or AssetCheckEvaluation"
+                " AssetMaterialization, AssetObservation, AssetCheckEvaluation or FreshnessEvaluation"
path: python_modules/dagster/dagster/_core/instance/__init__.py
user: login: graphite-app[bot]
body: The error message references `FreshnessEvaluation` but the class imported and used is `FreshnessStateEvaluation`. This inconsistency should be corrected to ensure error messages accurately reflect the expected types, which would help with debugging when unexpected asset event types are encountered.
```suggestion
                " AssetMaterialization, AssetObservation, AssetCheckEvaluation or FreshnessStateEvaluation"
```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=dagster-io&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-04-09T19:09:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -195,6 +204,24 @@ def __new__(
         }
         validate_kind_tags(kind_tags)
 
+        internal_freshness_policy: Optional[InternalFreshnessPolicy] = kwargs.get(
+            "internal_freshness_policy"
+        )
+        if internal_freshness_policy:
+            if metadata:
+                metadata = {
+                    **metadata,
+                    INTERNAL_FRESHNESS_POLICY_METADATA_KEY: serialize_value(
+                        internal_freshness_policy
+                    ),
+                }
+            else:
+                metadata = {
+                    INTERNAL_FRESHNESS_POLICY_METADATA_KEY: serialize_value(
+                        internal_freshness_policy
+                    )
+                }
path: python_modules/dagster/dagster/_core/definitions/asset_spec.py
user: login: dpeng817
body: giga nit: you can shrink this into 
```
metadata = {
    **(metadata or {}),
   ...,
}
```
    
created_at: 2025-04-09T20:22:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,92 @@
+from abc import ABC, abstractmethod
+from collections.abc import Mapping
+from enum import Enum
+from typing import Any, NamedTuple, Optional
+
+from dagster._annotations import beta
+from dagster._core.definitions.asset_key import AssetKey
+from dagster._record import IHaveNew, record_custom
+from dagster._serdes import deserialize_value, whitelist_for_serdes
+from dagster._utils import check
+
+
+@beta
+@whitelist_for_serdes
+class FreshnessState(str, Enum):
+    PASSING = "PASSING"
+    IN_VIOLATION = "IN_VIOLATION"
+    NEAR_VIOLATION = "NEAR_VIOLATION"
+    UNKNOWN = "UNKNOWN"
+
+
+@beta
+@whitelist_for_serdes
+class FreshnessStateEvaluation(
+    NamedTuple(
+        "_FreshnessStateEvaluation",
+        [
+            ("asset_key", AssetKey),
+            ("freshness_state", FreshnessState),
+        ],
+    )
+):
+    def __new__(cls, asset_key: AssetKey, freshness_state: FreshnessState):
+        return super().__new__(cls, asset_key=asset_key, freshness_state=freshness_state)
+
+
+INTERNAL_FRESHNESS_POLICY_METADATA_KEY = "dagster/internal_freshness_policy"
+
+
+class FreshnessPolicyType(Enum):
+    TIME_WINDOW = "time_window"
+
+
+class InternalFreshnessPolicy(ABC):
+    @property
+    @abstractmethod
+    def policy_type(self) -> FreshnessPolicyType: ...
path: python_modules/dagster/dagster/_core/definitions/freshness.py
user: login: OwenKephart
body: rm this
created_at: 2025-04-09T20:25:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -435,3 +453,13 @@ def map_asset_specs(
         obj.map_asset_specs(func) if isinstance(obj, AssetsDefinition) else func(obj)
         for obj in iterable
     ]
+
+
+def attach_internal_freshness_policy(spec: AssetSpec, policy: InternalFreshnessPolicy) -> AssetSpec:
+    """Apply a freshness policy to an asset spec, attaching it to the spec's metadata.
+
+    You can use this in Definitions.map_asset_specs to attach a freshness policy to an asset spec.
+    """
+    return spec.merge_attributes(
+        metadata={INTERNAL_FRESHNESS_POLICY_METADATA_KEY: serialize_value(policy)}
path: python_modules/dagster/dagster/_core/definitions/asset_spec.py
user: login: graphite-app[bot]
body: The `merge_attributes` method correctly merges metadata with existing values, but this function is passing a dictionary with only the freshness policy key, which will overwrite all existing metadata. To preserve existing metadata while adding the freshness policy, consider:

```python
return spec.merge_attributes(
    metadata={**spec.metadata, INTERNAL_FRESHNESS_POLICY_METADATA_KEY: serialize_value(policy)}
)
```

This ensures other metadata fields are retained when adding the freshness policy.
```suggestion
def attach_internal_freshness_policy(spec: AssetSpec, policy: InternalFreshnessPolicy) -> AssetSpec:
    """Apply a freshness policy to an asset spec, attaching it to the spec's metadata.

    You can use this in Definitions.map_asset_specs to attach a freshness policy to an asset spec.
    """
    return spec.merge_attributes(
        metadata={**spec.metadata, INTERNAL_FRESHNESS_POLICY_METADATA_KEY: serialize_value(policy)}
    )
```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=dagster-io&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-04-09T21:18:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -435,3 +453,13 @@ def map_asset_specs(
         obj.map_asset_specs(func) if isinstance(obj, AssetsDefinition) else func(obj)
         for obj in iterable
     ]
+
+
+def attach_internal_freshness_policy(spec: AssetSpec, policy: InternalFreshnessPolicy) -> AssetSpec:
+    """Apply a freshness policy to an asset spec, attaching it to the spec's metadata.
+
+    You can use this in Definitions.map_asset_specs to attach a freshness policy to an asset spec.
+    """
+    return spec.merge_attributes(
+        metadata={INTERNAL_FRESHNESS_POLICY_METADATA_KEY: serialize_value(policy)}
path: python_modules/dagster/dagster/_core/definitions/asset_spec.py
user: login: anuthebananu
body:  `merge_attributes` method already does this for us, so we can pass it metadata dict containing only the keys and values we want to merge in.

Added a test to cover this case
created_at: 2025-04-09T21:27:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,30 @@
+from enum import Enum
+from typing import NamedTuple
+
+from dagster._annotations import beta
+from dagster._core.definitions.asset_key import AssetKey
+from dagster._serdes import whitelist_for_serdes
+
+
+@beta
+@whitelist_for_serdes
+class FreshnessState(str, Enum):
+    PASSING = "PASSING"
+    IN_VIOLATION = "IN_VIOLATION"
+    NEAR_VIOLATION = "NEAR_VIOLATION"
+    UNKNOWN = "UNKNOWN"
path: python_modules/dagster/dagster/_core/definitions/freshness.py
user: login: anuthebananu
body: Per thread, landed on:

```
class FreshnessState(str, Enum):
    PASS = "PASS"
    WARN = "WARN"
    FAIL = "FAIL"
    UNKNOWN = "UNKNOWN"
```
created_at: 2025-04-09T21:28:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -225,13 +231,15 @@ def to_spec(
         deps: Sequence[AssetDep],
         additional_tags: Mapping[str, str] = {},
         partitions_def: Optional[PartitionsDefinition] = ...,
+        new_freshness_policy: Optional[NewFreshnessPolicy] = ...,
     ) -> AssetSpec:
         return self._spec.replace_attributes(
             key=key,
             tags={**additional_tags, **self.tags} if self.tags else additional_tags,
             kinds=self.kinds,
             deps=[*self._spec.deps, *deps],
             partitions_def=partitions_def if partitions_def is not None else ...,
+            new_freshness_policy=new_freshness_policy if new_freshness_policy is not None else ...,
path: python_modules/dagster/dagster/_core/definitions/asset_out.py
user: login: anuthebananu
body: looks much better indeed without `new_freshness_policy` littered everywhere!
created_at: 2025-04-09T21:35:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,93 @@
+from abc import ABC, abstractmethod
+from enum import Enum
+from typing import Optional
+
+from dagster import _check as check
+from dagster._annotations import beta
+from dagster._record import IHaveNew, record_custom
+from dagster._serdes import whitelist_for_serdes
+
+
+class FreshnessPolicyType(Enum):
+    TIME_WINDOW = "time_window"
+    CRON = "cron"
+
+
+class NewFreshnessPolicy(ABC):
+    """A condition that delineates when an asset is considered fresh. The asset can either pass, violate, or warn on this condition.
+
+    This is intended to replace the existing, deprecated `FreshnessPolicy` construct.
+
+    `NewFreshnessPolicy` can be specified as a parameter to `@asset`, `@multi_asset`, or in `AssetSpec`.
+
+    Do not use this class directly. Instead, use one of the subclasses.
+    """
+
+    @property
+    @abstractmethod
+    def policy_type(self) -> FreshnessPolicyType: ...
+
+
+@beta
+@whitelist_for_serdes
+@record_custom
+class TimeWindowFreshnessPolicy(NewFreshnessPolicy, IHaveNew):
+    """[BETA/EXPERIMENTAL] A freshness condition that considers an asset fresh if it was materialized within a specified time window (time_window_minutes).
+
+    The asset is considered stale if its last materialization is older than time_window_minutes, and in a warning state
+    if its last materialization is older than the warning time window but newer than the critical time window.
+
+    Example usage:
+
+    .. code-block:: python
+
+        from dagster import TimeWindowFreshnessPolicy
+
+        # Asset must be materialized within last 24 hours (1440 minutes)
+        @asset(new_freshness_policy=TimeWindowFreshnessPolicy(time_window_minutes=1440))
+        def my_asset():
+            ...
+
+        # Asset must be materialized within last 24 hours (1440 minutes), with warning after 12 hours
+        @asset(new_freshness_policy=TimeWindowFreshnessPolicy(time_window_minutes=1440, warning_time_window_minutes=720))
+        def my_asset():
+            ...
+
+    Args:
+        time_window_minutes (int): The maximum age in minutes for an asset to be considered fresh.
+            Must be positive.
+        warning_time_window_minutes (Optional[int]): If provided, the age in minutes at which to start
+            showing warnings about asset freshness. Must be positive and less than time_window_minutes.
+    """
+
+    time_window_minutes: int
+    warning_time_window_minutes: Optional[int] = None
path: python_modules/dagster/dagster/_core/definitions/new_freshness_policy.py
user: login: anuthebananu
body: renamed to `fail_window` and `warn_window` to align with the state names `FAIL` and `WARN`
created_at: 2025-04-09T21:35:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,93 @@
+from abc import ABC, abstractmethod
+from enum import Enum
+from typing import Optional
+
+from dagster import _check as check
+from dagster._annotations import beta
+from dagster._record import IHaveNew, record_custom
+from dagster._serdes import whitelist_for_serdes
+
+
+class FreshnessPolicyType(Enum):
+    TIME_WINDOW = "time_window"
+    CRON = "cron"
+
+
+class NewFreshnessPolicy(ABC):
+    """A condition that delineates when an asset is considered fresh. The asset can either pass, violate, or warn on this condition.
+
+    This is intended to replace the existing, deprecated `FreshnessPolicy` construct.
+
+    `NewFreshnessPolicy` can be specified as a parameter to `@asset`, `@multi_asset`, or in `AssetSpec`.
+
+    Do not use this class directly. Instead, use one of the subclasses.
+    """
+
+    @property
+    @abstractmethod
+    def policy_type(self) -> FreshnessPolicyType: ...
+
+
+@beta
+@whitelist_for_serdes
+@record_custom
+class TimeWindowFreshnessPolicy(NewFreshnessPolicy, IHaveNew):
+    """[BETA/EXPERIMENTAL] A freshness condition that considers an asset fresh if it was materialized within a specified time window (time_window_minutes).
+
+    The asset is considered stale if its last materialization is older than time_window_minutes, and in a warning state
+    if its last materialization is older than the warning time window but newer than the critical time window.
+
+    Example usage:
+
+    .. code-block:: python
+
+        from dagster import TimeWindowFreshnessPolicy
path: python_modules/dagster/dagster/_core/definitions/new_freshness_policy.py
user: login: anuthebananu
body: done!
created_at: 2025-04-09T21:43:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -667,7 +667,11 @@ def _synthesize_specs(self) -> Sequence[AssetSpec]:
                 deps = input_deps
 
             resolved_specs.append(
-                asset_out.to_spec(key, deps=deps, partitions_def=self.args.partitions_def)
+                asset_out.to_spec(
+                    key,
+                    deps=deps,
+                    partitions_def=self.args.partitions_def,
path: python_modules/dagster/dagster/_core/definitions/decorators/decorator_assets_definition_builder.py
user: login: OwenKephart
body: nit: delete comma so we don't have a change here
created_at: 2025-04-09T21:51:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -488,7 +488,6 @@ def from_graph(
             backfill_policy (Optional[BackfillPolicy]): Defines this asset's BackfillPolicy
             owners_by_key (Optional[Mapping[AssetKey, Sequence[str]]]): Defines
                 owners to be associated with each of the asset keys for this node.
-
path: python_modules/dagster/dagster/_core/definitions/assets.py
user: login: OwenKephart
body: nit: add this linebreak back so we don't have a change here
created_at: 2025-04-09T21:51:25Z
author_association: CONTRIBUTOR

PR comments:
user: login: anuthebananu
created_at: 2025-03-28T03:40:20Z
body: * **#28828** <a href="https://app.graphite.dev/github/pr/dagster-io/dagster/28828?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/dagster-io/dagster/28828?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* `master`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: CONTRIBUTOR
user: login: anuthebananu
created_at: 2025-04-09T22:00:01Z
body: knew i was forgetting something lol
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-09T22:50:35Z
body: Deploy preview for _dagit-core-storybook_ ready!

✅ Preview
https://dagit-core-storybook-nx96xrjr7-elementl.vercel.app
https://anurag-oper-1768-freshness-sla-python-api.core-storybook.dagster-docs.io

Built with commit 1c207268c8a9a034617da0ccb728f82e249075c9.
This pull request is being automatically deployed with [vercel-action](https://github.com/marketplace/actions/vercel-action)
author_association: CONTRIBUTOR
user: login: anuthebananu
created_at: 2025-04-10T00:20:32Z
body: I've ignored a couple of pyright errors, mainly stemming from the fact that we have a serdes class `TimeWindowFreshnessPolicy` that inherits from an abstract class `InternalFreshnessPolicy`, which we use as the type for  all freshness policies.

Pyright somehow cannot recognize that it is not possible to instantiate `InternalFreshnessPolicy`, so it complains when it sees code like:
```
policy = InternalFreshnessPolicy.time_window(...) # actually a `TimeWindowFreshnessPolicy`
serialize_value(policy)
```

because `InternalFreshnessPolicy` is not whitelisted for serdes. (at this time I believe we cannot whitelist abstract classes).

To me these feel safe enough to ignore for now to get this merged, if desired we can come up with a better solution in the future.
author_association: CONTRIBUTOR
user: login: anuthebananu
created_at: 2025-04-10T02:03:38Z
body: ### Merge activity

* **Apr 9, 10:03 PM EDT**: A user started a stack merge that includes this pull request via [Graphite](https://app.graphite.dev/github/pr/dagster-io/dagster/28828).
* **Apr 9, 10:03 PM EDT**: A user merged this pull request with [Graphite](https://app.graphite.dev/github/pr/dagster-io/dagster/28828).

author_association: CONTRIBUTOR