diff --git a/src/objectstate/object_state.py b/src/objectstate/object_state.py index 5e443ff..1e7ee95 100644 --- a/src/objectstate/object_state.py +++ b/src/objectstate/object_state.py @@ -2600,20 +2600,11 @@ def _extract_all_parameters_flat( # First try from type annotation, then fall back to checking actual value nested_type = self._get_nested_dataclass_type(info.param_type) - # A registered lazy runtime value owns its raw reconstruction type. - # The enclosing annotation commonly names the concrete base config, - # but reconstructing unresolved ``None`` sentinels through that base - # would run concrete validation before inheritance resolution. - if current_value is not None: - value_type = type(current_value) - if is_dataclass(value_type): - from objectstate.lazy_factory import get_base_type_for_lazy - - if ( - nested_type is None - or get_base_type_for_lazy(value_type) is not None - ): - nested_type = value_type + # The actual dataclass owns both its flattened fields and their + # reconstruction, including concrete nominal subtypes and lazy + # overlays. An annotation supplies the owner only without a value. + if current_value is not None and is_dataclass(type(current_value)): + nested_type = type(current_value) if nested_type is not None and current_value is not None: # Store the nested config type reference at this path diff --git a/tests/test_object_state_update_instance.py b/tests/test_object_state_update_instance.py index 2db1cda..6060d25 100644 --- a/tests/test_object_state_update_instance.py +++ b/tests/test_object_state_update_instance.py @@ -70,6 +70,45 @@ class ConfigWithNestedTopology: nested: NestedTopology = field(default_factory=NestedTopology) +@dataclass(frozen=True) +class SpatialConfig: + width: int = 12 + + +@dataclass(frozen=True) +class VolumeConfig(SpatialConfig): + depth: int = 60 + + +@dataclass +class ConfigWithSpatialDomain: + spatial: SpatialConfig = field(default_factory=SpatialConfig) + + +def test_nested_nominal_subtype_survives_raw_live_and_saved_reconstruction(): + state = ObjectState(ConfigWithSpatialDomain(VolumeConfig()), scope_id="volume") + assert state.to_object().spatial == VolumeConfig() + assert state.to_saved_resolved_object().spatial == VolumeConfig() + assert state.to_resolved_object().spatial == VolumeConfig() + + state.update_parameter("spatial.depth", 30) + assert state.to_object().spatial == VolumeConfig(depth=30) + assert state.to_resolved_object().spatial == VolumeConfig(depth=30) + assert state.to_saved_resolved_object().spatial == VolumeConfig(depth=60) + + +def test_nested_nominal_owner_changes_rebuild_parameter_topology(): + state = ObjectState(ConfigWithSpatialDomain(), scope_id="spatial") + state.update_object_instance(ConfigWithSpatialDomain(VolumeConfig(width=24))) + assert state.to_saved_resolved_object().spatial == VolumeConfig(width=24) + assert "spatial.depth" in state.parameters + + state.update_object_instance(ConfigWithSpatialDomain(SpatialConfig(width=18))) + assert state.to_object().spatial == SpatialConfig(width=18) + assert state.to_saved_resolved_object().spatial == SpatialConfig(width=18) + assert "spatial.depth" not in state.parameters + + class DelegatedHost: __objectstate_delegate__ = "config"