[ty] Treat narrowing bounds as gradual outside set simplification
diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md
index 15a18c5..8591192 100644
--- a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md
+++ b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md
@@ -755,8 +755,9 @@
 
 In `relaxed` mode, narrowing to a generic class using `isinstance()` intersects with its top
 materialization, using specially tagged `object*`/`Never*` bounds for gradual type arguments. These
-bounds behave like `object`/`Never` while the intersection is simplified, retain their narrowing
-provenance afterwards, and behave like `Unknown` for assignability. For example, in the case below,
+bounds behave like `object`/`Never` while unions and intersections are simplified. Everywhere else,
+including attribute access, calls, subscripting, iteration, operators, and type relations, they
+behave like `Unknown` while retaining their narrowing provenance. For example, in the case below,
 `object & Covariant[object*]` simplifies to `Covariant[object*]`:
 
 ```py
@@ -836,12 +837,13 @@
         x.push("foo")
 ```
 
-Both tagged bounds remain gradual only for assignability. In particular, `object*` is assignable to
-and from `int`, but it is not a subtype of `int`:
+Both tagged bounds behave like `Unknown` for type relations outside union and intersection
+simplification. In particular, `object*` is assignable to and from `int`, but it is not a subtype of
+`int`, `int` is not a subtype of it, and it is not disjoint from `int`:
 
 ```py
 from ty_extensions import static_assert
-from ty_extensions._internal import TypeOf, is_assignable_to, is_subtype_of
+from ty_extensions._internal import TypeOf, is_assignable_to, is_disjoint_from, is_subtype_of
 
 def tagged_bounds_are_gradual(value: object) -> None:
     if isinstance(value, Covariant):
@@ -851,6 +853,46 @@
         static_assert(is_assignable_to(TypeOf[item], int))
         static_assert(is_assignable_to(int, TypeOf[item]))
         static_assert(not is_subtype_of(TypeOf[item], int))
+        static_assert(not is_subtype_of(int, TypeOf[item]))
+        static_assert(not is_subtype_of(TypeOf[item], TypeOf[item]))
+        static_assert(not is_disjoint_from(TypeOf[item], int))
+```
+
+Tagged bounds also support all operations supported by `Unknown`. Results retain the tag so that
+subsequent operations remain gradual as well:
+
+```py
+def tagged_bounds_support_unknown_operations(value: object, assigned: int) -> None:
+    if isinstance(value, Covariant):
+        item = value.get()
+        reveal_type(item)  # revealed: object*
+
+        reveal_type(item.missing)  # revealed: object*
+        reveal_type(item.missing(1, keyword="value"))  # revealed: object*
+        reveal_type(item())  # revealed: object*
+        reveal_type(item[0])  # revealed: object*
+        reveal_type(item["key"].nested())  # revealed: object*
+
+        reveal_type(item + 1)  # revealed: object*
+        reveal_type(1 + item)  # revealed: object*
+        reveal_type(-item)  # revealed: object*
+
+        for element in item:
+            reveal_type(element)  # revealed: object*
+            reveal_type(element.missing)  # revealed: object*
+
+        assigned = item
+
+async def tagged_bounds_are_awaitable(value: object) -> None:
+    if isinstance(value, Covariant):
+        item = value.get()
+        reveal_type(await item)  # revealed: object*
+
+def accepts_integer_bound[T: int](value: T) -> None: ...
+def tagged_bounds_satisfy_generic_bounds(value: object) -> None:
+    if isinstance(value, Covariant):
+        item = value.get()
+        accepts_integer_bound(item)
 ```
 
 The behavior of `issubclass()` is similar.
@@ -1014,8 +1056,9 @@
 
 With `analysis.strict-generic-narrowing` disabled, the positive branch is simplified using tagged
 `object*`/`Never*` bounds, which retain their narrowing provenance afterwards and behave like
-`Unknown` for assignability. The negative branch still excludes the ordinary top materialization
-because a negative `isinstance` result excludes every specialization of the class:
+`Unknown` for all operations other than union and intersection simplification. The negative branch
+still excludes the ordinary top materialization because a negative `isinstance` result excludes
+every specialization of the class:
 
 ```toml
 [analysis]
@@ -1062,7 +1105,7 @@
     if isinstance(xs, Sequence):
         reveal_type(xs)  # revealed: (OpenItem & Sequence[object*]) | Sequence[OpenItem]
         for x in xs:
-            reveal_type(x)  # revealed: object
+            reveal_type(x)  # revealed: object*
     else:
         reveal_type(xs)  # revealed: OpenItem & ~Sequence[object]
 ```
@@ -1078,6 +1121,8 @@
         reveal_type(xs.append)  # revealed: bound method Top[list[Unknown]].append(object: Never*, /) -> None
         for x in xs:
             reveal_type(x)  # revealed: object*
+            reveal_type(x.missing)  # revealed: object*
+            reveal_type(x[0])  # revealed: object*
 
         xs.append(1)
         xs.append("foo")
@@ -1124,7 +1169,7 @@
     if isinstance(xs, list):
         reveal_type(xs)  # revealed: (OpenItem & Top[list[Unknown]]) | list[OpenItem]
         for x in xs:
-            reveal_type(x)  # revealed: object
+            reveal_type(x)  # revealed: object*
     else:
         reveal_type(xs)  # revealed: OpenItem & ~Top[list[Unknown]]
 ```
diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs
index 275f143..c1d0d0c 100644
--- a/crates/ty_python_semantic/src/types.rs
+++ b/crates/ty_python_semantic/src/types.rs
@@ -1179,8 +1179,20 @@
         }
     }
 
-    fn narrowing_bound_fallback(self) -> Option<Type<'db>> {
-        let materialization_kind = self.as_nominal_instance()?.narrowing_bound_kind()?;
+    pub(crate) const fn narrowing_bound_kind(self) -> Option<MaterializationKind> {
+        match self {
+            Type::Dynamic(DynamicType::NarrowingBound(materialization_kind)) => {
+                Some(materialization_kind)
+            }
+            _ => None,
+        }
+    }
+
+    /// Return the tagged bound's top or bottom meaning during set-theoretic simplification.
+    ///
+    /// Outside union and intersection simplification, tagged bounds behave like `Unknown`.
+    pub(crate) fn narrowing_bound_fallback(self) -> Option<Type<'db>> {
+        let materialization_kind = self.narrowing_bound_kind()?;
         Some(match materialization_kind {
             MaterializationKind::Top => Type::object(),
             MaterializationKind::Bottom => Type::Never,
@@ -1215,20 +1227,20 @@
                 DynamicType::Unknown
                     | DynamicType::UnknownGeneric(_)
                     | DynamicType::AmbiguousOverload
+                    | DynamicType::NarrowingBound(_)
             )
         )
     }
 
     pub(crate) const fn is_never(&self) -> bool {
-        match self {
+        matches!(
+            self,
             Type::Never
-            | Type::Divergent(DivergentType {
-                materialization: Some(MaterializationKind::Bottom),
-                ..
-            }) => true,
-            Type::NominalInstance(instance) => instance.is_narrowing_never(),
-            _ => false,
-        }
+                | Type::Divergent(DivergentType {
+                    materialization: Some(MaterializationKind::Bottom),
+                    ..
+                })
+        )
     }
 
     /// Returns `true` if this type contains a `Self` type variable.
@@ -1356,7 +1368,8 @@
             | DynamicType::InvalidConcatenateUnknown
             | DynamicType::UnknownGeneric(_)
             | DynamicType::UnspecializedTypeVar
-            | DynamicType::AmbiguousOverload => false,
+            | DynamicType::AmbiguousOverload
+            | DynamicType::NarrowingBound(_) => false,
             DynamicType::Todo(_) => true,
         })
     }
@@ -1934,10 +1947,7 @@
 
     #[must_use]
     pub(crate) fn negate(&self, db: &'db dyn Db) -> Type<'db> {
-        if let Some(materialization_kind) = self
-            .as_nominal_instance()
-            .and_then(NominalInstanceType::narrowing_bound_kind)
-        {
+        if let Some(materialization_kind) = self.narrowing_bound_kind() {
             return Type::narrowing_bound(materialization_kind.flip());
         }
 
@@ -2109,7 +2119,8 @@
                 | DynamicType::UnspecializedTypeVar
                 | DynamicType::Todo(_)
                 | DynamicType::InvalidConcatenateUnknown
-                | DynamicType::AmbiguousOverload => false,
+                | DynamicType::AmbiguousOverload
+                | DynamicType::NarrowingBound(_) => false,
             },
         }
     }
@@ -6808,6 +6819,7 @@
                 TypeMapping::Promote(PromotionMode::On, PromotionKind::Regular) => self.promote_impl(db),
             }
 
+            Type::Dynamic(DynamicType::NarrowingBound(_)) => self,
             Type::Dynamic(_) => match type_mapping {
                 TypeMapping::ApplySpecialization(_) |
                 TypeMapping::ApplySpecializationWithMaterialization { .. } |
@@ -7356,7 +7368,8 @@
             Self::Dynamic(
                 DynamicType::Unknown
                 | DynamicType::UnknownGeneric(_)
-                | DynamicType::AmbiguousOverload,
+                | DynamicType::AmbiguousOverload
+                | DynamicType::NarrowingBound(_),
             ) => Type::SpecialForm(SpecialFormType::Unknown).definition(db),
             Self::Divergent(_) => Type::SpecialForm(SpecialFormType::Divergent).definition(db),
             Self::Dynamic(DynamicType::Todo(_)) => {
@@ -8053,6 +8066,11 @@
     Any,
     /// An unannotated value, or a dynamic type resulting from an error
     Unknown,
+    /// A gradual `isinstance` bound that is top or bottom only while simplifying type sets.
+    ///
+    /// All other type operations treat this exactly like `Unknown`, while retaining its display
+    /// provenance as `object*` or `Never*`.
+    NarrowingBound(MaterializationKind),
     /// Similar to `Unknown`, this represents a dynamic type that has been explicitly specialized
     /// with legacy typevars, e.g. `UnknownClass[T]`, where `T` is a legacy typevar. We keep track
     /// of the type variables in the generic context in case this type is later specialized again.
@@ -8105,6 +8123,8 @@
             | DynamicType::UnknownGeneric(_)
             | DynamicType::InvalidConcatenateUnknown
             | DynamicType::AmbiguousOverload => f.write_str("Unknown"),
+            DynamicType::NarrowingBound(MaterializationKind::Top) => f.write_str("object*"),
+            DynamicType::NarrowingBound(MaterializationKind::Bottom) => f.write_str("Never*"),
             DynamicType::UnspecializedTypeVar => f.write_str("UnspecializedTypeVar"),
             // `DynamicType::Todo`'s display should be explicit that is not a valid display of
             // any other type
diff --git a/crates/ty_python_semantic/src/types/class_base.rs b/crates/ty_python_semantic/src/types/class_base.rs
index d93cedf..c86da92 100644
--- a/crates/ty_python_semantic/src/types/class_base.rs
+++ b/crates/ty_python_semantic/src/types/class_base.rs
@@ -67,7 +67,8 @@
                 DynamicType::Unknown
                 | DynamicType::UnknownGeneric(_)
                 | DynamicType::InvalidConcatenateUnknown
-                | DynamicType::AmbiguousOverload,
+                | DynamicType::AmbiguousOverload
+                | DynamicType::NarrowingBound(_),
             ) => "Unknown",
             ClassBase::Dynamic(DynamicType::UnspecializedTypeVar) => "UnspecializedTypeVar",
             ClassBase::Dynamic(DynamicType::Todo(_)) => "@Todo",
diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs
index a214a56..4aa6054 100644
--- a/crates/ty_python_semantic/src/types/display.rs
+++ b/crates/ty_python_semantic/src/types/display.rs
@@ -956,16 +956,6 @@
             }
             Type::Divergent(_) => f.with_type(self.ty).write_str("Divergent"),
             Type::Never => f.with_type(self.ty).write_str("Never"),
-            Type::NominalInstance(instance)
-                if instance.narrowing_bound_kind() == Some(MaterializationKind::Top) =>
-            {
-                f.with_type(self.ty).write_str("object*")
-            }
-            Type::NominalInstance(instance)
-                if instance.narrowing_bound_kind() == Some(MaterializationKind::Bottom) =>
-            {
-                f.with_type(self.ty).write_str("Never*")
-            }
             Type::NominalInstance(instance) => {
                 let class = instance.class(self.db);
 
diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs
index c342981..fd0d946 100644
--- a/crates/ty_python_semantic/src/types/function.rs
+++ b/crates/ty_python_semantic/src/types/function.rs
@@ -2396,11 +2396,7 @@
                     .peekable();
                 let revealed_type = if let Some(first) = argument_types.next() {
                     // A singleton tagged bound must not be canonicalized to ordinary `object`.
-                    if argument_types.peek().is_none()
-                        && first
-                            .as_nominal_instance()
-                            .is_some_and(|instance| instance.narrowing_bound_kind().is_some())
-                    {
+                    if argument_types.peek().is_none() && first.narrowing_bound_kind().is_some() {
                         first
                     } else {
                         argument_types
diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs
index 79d01bf..bbce969 100644
--- a/crates/ty_python_semantic/src/types/generics.rs
+++ b/crates/ty_python_semantic/src/types/generics.rs
@@ -1496,6 +1496,7 @@
                     let vartype = if for_narrowing
                         && materialization_kind == MaterializationKind::Top
                         && vartype.is_unknown()
+                        && vartype.narrowing_bound_kind().is_none()
                         && let Some(TypeVarBoundOrConstraints::UpperBound(bound)) =
                             bound_typevar.typevar(db).bound_or_constraints(db)
                     {
diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs
index 1b13ea1..111fe2a 100644
--- a/crates/ty_python_semantic/src/types/infer/builder.rs
+++ b/crates/ty_python_semantic/src/types/infer/builder.rs
@@ -1700,7 +1700,10 @@
                         // "declared types is `Unknown` (e.g. due to a bad annotation, missing
                         // import, etc.)". Ideally we would still prefer `Unknown` declared type,
                         // but use inferred type if there is no declared type.
-                        && !matches!(declared_type, Type::Dynamic(DynamicType::Unknown))
+                        && !matches!(
+                            declared_type,
+                            Type::Dynamic(DynamicType::Unknown | DynamicType::NarrowingBound(_))
+                        )
                         && declared_type.is_assignable_to(self.db(), inferred_ty)
                     {
                         (declared_ty, declared_type)
@@ -3679,7 +3682,7 @@
             }
             // `Unknown` is likely to be the result of an unresolved import or a typo, which will
             // already get a diagnostic, so don't pile on an extra diagnostic here.
-            Type::Dynamic(DynamicType::Unknown) => return,
+            Type::Dynamic(DynamicType::Unknown | DynamicType::NarrowingBound(_)) => return,
             _ => {}
         }
         if let Some(builder) = self
@@ -4474,7 +4477,10 @@
                     && !matches!(name_expr.id.as_str(), "_ignore_" | "_value_" | "_name_")
                     // Not bare Final (bare Final is allowed on enum members)
                     && !(declared.qualifiers.contains(TypeQualifiers::FINAL)
-                        && matches!(declared.inner_type(), Type::Dynamic(DynamicType::Unknown)))
+                        && matches!(
+                            declared.inner_type(),
+                            Type::Dynamic(DynamicType::Unknown | DynamicType::NarrowingBound(_))
+                        ))
                     // Value type would be an enum member at runtime (exclude callables,
                     // which are never members)
                     && !inferred_ty.is_subtype_of(
@@ -8119,7 +8125,9 @@
         // return type as type context.
         let return_tcx = if let Some(signature) = callable_tcx {
             match signature.return_ty {
-                Type::Dynamic(DynamicType::Unknown) => TypeContext::new(None),
+                Type::Dynamic(DynamicType::Unknown | DynamicType::NarrowingBound(_)) => {
+                    TypeContext::new(None)
+                }
                 _ => TypeContext::new(Some(signature.return_ty)),
             }
         } else {
diff --git a/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs b/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs
index abb6c7d..8641879 100644
--- a/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs
+++ b/crates/ty_python_semantic/src/types/infer/builder/binary_expressions.rs
@@ -387,8 +387,16 @@
             (any @ Type::Dynamic(DynamicType::Any), _, _)
             | (_, any @ Type::Dynamic(DynamicType::Any), _) => Some(any),
 
-            (unknown @ Type::Dynamic(DynamicType::Unknown), _, _)
-            | (_, unknown @ Type::Dynamic(DynamicType::Unknown), _) => Some(unknown),
+            (
+                unknown @ Type::Dynamic(DynamicType::Unknown | DynamicType::NarrowingBound(_)),
+                _,
+                _,
+            )
+            | (
+                _,
+                unknown @ Type::Dynamic(DynamicType::Unknown | DynamicType::NarrowingBound(_)),
+                _,
+            ) => Some(unknown),
 
             (unknown @ Type::Dynamic(DynamicType::InvalidConcatenateUnknown), _, _)
             | (_, unknown @ Type::Dynamic(DynamicType::InvalidConcatenateUnknown), _) => {
diff --git a/crates/ty_python_semantic/src/types/instance.rs b/crates/ty_python_semantic/src/types/instance.rs
index dc2bf2d..9fbb75a 100644
--- a/crates/ty_python_semantic/src/types/instance.rs
+++ b/crates/ty_python_semantic/src/types/instance.rs
@@ -9,7 +9,7 @@
 
 use super::protocol_class::{ProtocolInterface, ProtocolInterfaceView};
 use super::{
-    BoundTypeVarIdentity, BoundTypeVarInstance, ClassType, DivergentType, KnownClass,
+    BoundTypeVarIdentity, BoundTypeVarInstance, ClassType, DivergentType, DynamicType, KnownClass,
     MaterializationKind, SubclassOfType, Type, TypeAliasType, TypeVarVariance,
 };
 use crate::place::PlaceAndQualifiers;
@@ -44,21 +44,17 @@
     }
 
     pub(crate) const fn narrowing_bound(materialization_kind: MaterializationKind) -> Self {
-        Type::NominalInstance(NominalInstanceType(NominalInstanceInner::NarrowingBound(
-            materialization_kind,
-        )))
+        Type::Dynamic(DynamicType::NarrowingBound(materialization_kind))
     }
 
     pub(crate) const fn is_object(&self) -> bool {
         matches!(
             self,
-            Type::NominalInstance(NominalInstanceType(
-                NominalInstanceInner::Object
-                    | NominalInstanceInner::NarrowingBound(MaterializationKind::Top)
-            )) | Type::Divergent(DivergentType {
-                materialization: Some(MaterializationKind::Top),
-                ..
-            })
+            Type::NominalInstance(NominalInstanceType(NominalInstanceInner::Object))
+                | Type::Divergent(DivergentType {
+                    materialization: Some(MaterializationKind::Top),
+                    ..
+                })
         )
     }
 
@@ -196,7 +192,7 @@
         NominalInstanceInner::ExactTuple(tuple) => {
             walk_tuple_type(db, tuple, visitor);
         }
-        NominalInstanceInner::Object | NominalInstanceInner::NarrowingBound(_) => {}
+        NominalInstanceInner::Object => {}
         NominalInstanceInner::NonTuple(class) => visitor.visit_type(db, class.class(db).into()),
         NominalInstanceInner::SysVersionInfo => {}
     }
@@ -250,9 +246,7 @@
             NominalInstanceInner::SysVersionInfo => {
                 sys_version_info_class(db).unwrap_or_else(|| ClassType::object(db))
             }
-            NominalInstanceInner::Object | NominalInstanceInner::NarrowingBound(_) => {
-                ClassType::object(db)
-            }
+            NominalInstanceInner::Object => ClassType::object(db),
         }
     }
 
@@ -268,9 +262,7 @@
             NominalInstanceInner::ExactTuple(_) => Some(KnownClass::Tuple),
             NominalInstanceInner::NonTuple(class) => class.class(db).known(db),
             NominalInstanceInner::SysVersionInfo => Some(KnownClass::VersionInfo),
-            NominalInstanceInner::Object | NominalInstanceInner::NarrowingBound(_) => {
-                Some(KnownClass::Object)
-            }
+            NominalInstanceInner::Object => Some(KnownClass::Object),
         }
     }
 
@@ -293,7 +285,7 @@
             NominalInstanceInner::SysVersionInfo => {
                 Some(Cow::Owned(TupleSpec::version_info_spec(db)))
             }
-            NominalInstanceInner::Object | NominalInstanceInner::NarrowingBound(_) => None,
+            NominalInstanceInner::Object => None,
             NominalInstanceInner::NonTuple(class) => {
                 let class = class.class(db);
                 // Avoid an expensive MRO traversal for common stdlib classes.
@@ -325,35 +317,13 @@
 
     /// Return `true` if this type represents instances of the class `builtins.object`.
     pub(super) const fn is_object(self) -> bool {
-        matches!(
-            self.0,
-            NominalInstanceInner::Object
-                | NominalInstanceInner::NarrowingBound(MaterializationKind::Top)
-        )
-    }
-
-    pub(crate) const fn narrowing_bound_kind(self) -> Option<MaterializationKind> {
-        match self.0 {
-            NominalInstanceInner::NarrowingBound(materialization_kind) => {
-                Some(materialization_kind)
-            }
-            _ => None,
-        }
-    }
-
-    pub(crate) const fn is_narrowing_never(self) -> bool {
-        matches!(
-            self.0,
-            NominalInstanceInner::NarrowingBound(MaterializationKind::Bottom)
-        )
+        matches!(self.0, NominalInstanceInner::Object)
     }
 
     pub(super) fn is_definition_generic(self, db: &'db dyn Db) -> bool {
         match self.0 {
             NominalInstanceInner::ExactTuple(_) => true,
-            NominalInstanceInner::SysVersionInfo
-            | NominalInstanceInner::Object
-            | NominalInstanceInner::NarrowingBound(_) => false,
+            NominalInstanceInner::SysVersionInfo | NominalInstanceInner::Object => false,
             NominalInstanceInner::NonTuple(class) => class.class(db).is_generic(),
         }
     }
@@ -373,8 +343,7 @@
             NominalInstanceInner::ExactTuple(tuple) => Some(Cow::Borrowed(tuple.tuple(db))),
             NominalInstanceInner::NonTuple(_)
             | NominalInstanceInner::SysVersionInfo
-            | NominalInstanceInner::Object
-            | NominalInstanceInner::NarrowingBound(_) => None,
+            | NominalInstanceInner::Object => None,
         }
     }
 
@@ -388,8 +357,7 @@
             NominalInstanceInner::NonTuple(class) => class.class(db),
             NominalInstanceInner::ExactTuple(_)
             | NominalInstanceInner::SysVersionInfo
-            | NominalInstanceInner::Object
-            | NominalInstanceInner::NarrowingBound(_) => return None,
+            | NominalInstanceInner::Object => return None,
         };
         let (class_literal, specialization) = class.static_class_literal(db)?;
         let specialization = specialization?;
@@ -436,9 +404,6 @@
                 Some(Self(NominalInstanceInner::SysVersionInfo))
             }
             NominalInstanceInner::Object => Some(Self(NominalInstanceInner::Object)),
-            NominalInstanceInner::NarrowingBound(materialization_kind) => Some(Self(
-                NominalInstanceInner::NarrowingBound(materialization_kind),
-            )),
             NominalInstanceInner::NonTuple(class) => {
                 let transformed = class
                     .class(db)
@@ -457,9 +422,7 @@
             // should not be relied on for type narrowing, so we do not treat it as one.
             // See:
             // https://docs.python.org/3/reference/expressions.html#parenthesized-forms
-            NominalInstanceInner::ExactTuple(_)
-            | NominalInstanceInner::Object
-            | NominalInstanceInner::NarrowingBound(_) => false,
+            NominalInstanceInner::ExactTuple(_) | NominalInstanceInner::Object => false,
             NominalInstanceInner::SysVersionInfo => true,
             NominalInstanceInner::NonTuple(class) => class
                 .class(db)
@@ -486,7 +449,6 @@
             }
             NominalInstanceInner::SysVersionInfo => Type::NominalInstance(self),
             NominalInstanceInner::Object => Type::object(),
-            NominalInstanceInner::NarrowingBound(_) => Type::NominalInstance(self),
             NominalInstanceInner::NonTuple(class) => {
                 let transformed =
                     class
@@ -510,9 +472,7 @@
             NominalInstanceInner::ExactTuple(tuple) => {
                 tuple.find_legacy_typevars_impl(db, binding_context, typevars, visitor);
             }
-            NominalInstanceInner::SysVersionInfo
-            | NominalInstanceInner::Object
-            | NominalInstanceInner::NarrowingBound(_) => {}
+            NominalInstanceInner::SysVersionInfo | NominalInstanceInner::Object => {}
             NominalInstanceInner::NonTuple(class) => {
                 class
                     .class(db)
@@ -985,8 +945,6 @@
     /// prevalent and foundational, and it's useful to be able to instantiate this without having
     /// to load the definition of `object` from the typeshed.
     Object,
-    /// A tagged `object` or `Never` that preserves generic-narrowing provenance.
-    NarrowingBound(MaterializationKind),
     /// A tuple type, e.g. `tuple[int, str]`.
     ///
     /// Note that the type `tuple[int, str]` includes subtypes of `tuple[int, str]`,
diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs
index ae899b1..013e951 100644
--- a/crates/ty_python_semantic/src/types/relation.rs
+++ b/crates/ty_python_semantic/src/types/relation.rs
@@ -1132,16 +1132,11 @@
         Some(self.check_type_pair(db, Type::TypeVar(source), target))
     }
 
-    /// Tagged narrowing bounds retain their top/bottom meaning for subtype checks and
-    /// simplification, but remain gradual when checking assignability.
+    /// Tagged bounds have top/bottom semantics only for pragmatic set simplification.
     fn narrowing_bound_fallback(&self, ty: Type<'db>) -> Option<Type<'db>> {
-        ty.narrowing_bound_fallback().map(|fallback| {
-            if self.relation.is_assignability() {
-                Type::unknown()
-            } else {
-                fallback
-            }
-        })
+        matches!(self.relation, TypeRelation::Redundancy { pure: false })
+            .then(|| ty.narrowing_bound_fallback())
+            .flatten()
     }
 
     /// Return a constraint set indicating the conditions under which `self.relation` holds between `source` and `target`.
@@ -2733,14 +2728,6 @@
             }
         }
 
-        if let Some(left) = left.narrowing_bound_fallback() {
-            return self.check_type_pair(db, left, right);
-        }
-
-        if let Some(right) = right.narrowing_bound_fallback() {
-            return self.check_type_pair(db, left, right);
-        }
-
         if let Some(left) = left.materialized_divergent_fallback() {
             return self.check_type_pair(db, left, right);
         }
diff --git a/crates/ty_python_semantic/src/types/set_theoretic/builder.rs b/crates/ty_python_semantic/src/types/set_theoretic/builder.rs
index 5916365..60a4560 100644
--- a/crates/ty_python_semantic/src/types/set_theoretic/builder.rs
+++ b/crates/ty_python_semantic/src/types/set_theoretic/builder.rs
@@ -40,14 +40,43 @@
 use crate::types::enums::EnumComplement;
 use crate::types::set_theoretic::expand_intersection_typevars_and_newtypes;
 use crate::types::{
-    BytesLiteralType, ClassLiteral, EnumLiteralType, IntersectionType, KnownClass,
-    KnownInstanceType, LiteralValueType, LiteralValueTypeKind, NegativeIntersectionElements,
-    StringLiteralType, SubclassOfType, Type, TypeVarBoundOrConstraints, TypeVarVariance, UnionType,
+    BytesLiteralType, ClassLiteral, DynamicType, EnumLiteralType, IntersectionType, KnownClass,
+    KnownInstanceType, LiteralValueType, LiteralValueTypeKind, MaterializationKind,
+    NegativeIntersectionElements, StringLiteralType, SubclassOfType, Type,
+    TypeVarBoundOrConstraints, TypeVarVariance, UnionType,
 };
 use crate::{Db, FxOrderMap, FxOrderSet};
 use rustc_hash::FxHashSet;
 use smallvec::SmallVec;
 
+/// Tagged narrowing bounds retain their top/bottom meaning only while simplifying type sets.
+/// Everywhere else they are ordinary gradual types.
+fn materialize_narrowing_bound_for_set_simplification(ty: Type<'_>) -> Type<'_> {
+    ty.narrowing_bound_fallback().unwrap_or(ty)
+}
+
+fn is_bottom_for_set_simplification(ty: &Type<'_>) -> bool {
+    ty.is_never() || ty.narrowing_bound_kind() == Some(MaterializationKind::Bottom)
+}
+
+fn is_top_for_set_simplification(ty: Type<'_>) -> bool {
+    ty.is_object() || ty.narrowing_bound_kind() == Some(MaterializationKind::Top)
+}
+
+fn is_subtype_for_set_simplification(db: &dyn Db, left: Type<'_>, right: Type<'_>) -> bool {
+    materialize_narrowing_bound_for_set_simplification(left).is_subtype_of(
+        db,
+        materialize_narrowing_bound_for_set_simplification(right),
+    )
+}
+
+fn are_disjoint_for_set_simplification(db: &dyn Db, left: Type<'_>, right: Type<'_>) -> bool {
+    materialize_narrowing_bound_for_set_simplification(left).is_disjoint_from(
+        db,
+        materialize_narrowing_bound_for_set_simplification(right),
+    )
+}
+
 /// Extract `(core, guard)` from truthiness-guarded intersections.
 ///
 /// e.g.
@@ -138,6 +167,7 @@
             continue;
         }
         if general_type.is_non_divergent_dynamic()
+            && general_type.narrowing_bound_kind().is_none()
             && typevar.variance(db) == TypeVarVariance::Invariant
         {
             has_dynamic_replacement = true;
@@ -707,8 +737,14 @@
                     }
                 }
             }
-            // Adding `Never` to a union is a no-op.
             Type::Never => {}
+            Type::Dynamic(DynamicType::NarrowingBound(MaterializationKind::Bottom)) => {
+                // A tagged bottom is redundant once another element exists, but must retain its
+                // gradual provenance when it is the only element.
+                if self.elements.is_empty() {
+                    self.elements.push(UnionElement::Type(ty));
+                }
+            }
             Type::TypeAlias(alias) if self.unpack_aliases => {
                 if seen_aliases.contains(&ty) {
                     // Union contains itself recursively via a type alias. This is an error, just
@@ -756,7 +792,11 @@
                                         to_remove = Some(index);
                                         continue;
                                     }
-                                    if ty_negated().is_subtype_of(self.db, *existing) {
+                                    if is_subtype_for_set_simplification(
+                                        self.db,
+                                        ty_negated(),
+                                        *existing,
+                                    ) {
                                         // The type that includes both this new element, and its negation
                                         // (or a supertype of its negation), must be simply `object`.
                                         self.collapse_to_object();
@@ -809,7 +849,11 @@
                                         to_remove = Some(index);
                                         continue;
                                     }
-                                    if ty_negated().is_subtype_of(self.db, *existing) {
+                                    if is_subtype_for_set_simplification(
+                                        self.db,
+                                        ty_negated(),
+                                        *existing,
+                                    ) {
                                         // The type that includes both this new element, and its negation
                                         // (or a supertype of its negation), must be simply `object`.
                                         self.collapse_to_object();
@@ -864,7 +908,11 @@
                                         to_remove = Some(index);
                                         continue;
                                     }
-                                    if ty_negated().is_subtype_of(self.db, *existing) {
+                                    if is_subtype_for_set_simplification(
+                                        self.db,
+                                        ty_negated(),
+                                        *existing,
+                                    ) {
                                         // The type that includes both this new element, and its negation
                                         // (or a supertype of its negation), must be simply `object`.
                                         self.collapse_to_object();
@@ -940,7 +988,11 @@
                                         to_remove = Some(index);
                                         continue;
                                     }
-                                    if ty_negated().is_subtype_of(self.db, *existing) {
+                                    if is_subtype_for_set_simplification(
+                                        self.db,
+                                        ty_negated(),
+                                        *existing,
+                                    ) {
                                         // The type that includes both this new element, and its negation
                                         // (or a supertype of its negation), must be simply `object`.
                                         self.collapse_to_object();
@@ -983,8 +1035,20 @@
                     _ => self.push_type(ty, seen_aliases),
                 }
             }
-            // Adding `object` to a union results in `object`.
-            ty if ty.is_object() && !cycle_recovery => self.collapse_to_object(),
+            // Adding a top type subsumes every existing union element. Preserve narrowing
+            // provenance unless an ordinary `object` was already present.
+            ty if is_top_for_set_simplification(ty) && !cycle_recovery => {
+                if ty.narrowing_bound_kind().is_some()
+                    && !self.elements.iter().any(|element| {
+                        matches!(element, UnionElement::Type(existing) if existing.is_object())
+                    })
+                {
+                    self.elements.clear();
+                    self.elements.push(UnionElement::Type(ty));
+                } else {
+                    self.collapse_to_object();
+                }
+            }
             _ => self.push_type(ty, seen_aliases),
         }
     }
@@ -1385,7 +1449,7 @@
 
 impl<'db> InnerIntersectionBuilder<'db> {
     fn contains_never(&self) -> bool {
-        self.positive.iter().any(Type::is_never)
+        self.positive.iter().any(is_bottom_for_set_simplification)
     }
 
     /// Return `true` when an intersection excludes every member of an enum class.
@@ -1451,12 +1515,12 @@
     /// Adds a positive type to this intersection.
     fn add_positive(&mut self, db: &'db dyn Db, mut new_positive: Type<'db>) {
         // `Never & T` -> `Never`
-        if self.positive.iter().any(Type::is_never) {
+        if self.positive.iter().any(is_bottom_for_set_simplification) {
             return;
         }
 
         // `T & Never` -> `Never`
-        if new_positive.is_never() {
+        if is_bottom_for_set_simplification(&new_positive) {
             *self = Self::default();
             self.positive.insert(new_positive);
             return;
@@ -1549,9 +1613,7 @@
             _ => {
                 let positive_as_instance = new_positive.as_nominal_instance();
 
-                if let Some(instance) = positive_as_instance
-                    && instance.is_object()
-                {
+                if is_top_for_set_simplification(new_positive) {
                     // `object & T` -> `T`; it is always redundant to add `object` to an intersection
                     return;
                 }
@@ -1639,7 +1701,7 @@
                         to_remove.push(index);
                     }
                     // A & B = Never    if A and B are disjoint
-                    if new_positive.is_disjoint_from(db, *existing_positive) {
+                    if are_disjoint_for_set_simplification(db, new_positive, *existing_positive) {
                         *self = Self::default();
                         self.positive.insert(Type::Never);
                         return;
@@ -1652,13 +1714,13 @@
                 let mut to_remove = SmallVec::<[usize; 1]>::new();
                 for (index, existing_negative) in self.negative.iter().enumerate() {
                     // S & ~T = Never    if S <: T
-                    if new_positive.is_subtype_of(db, *existing_negative) {
+                    if is_subtype_for_set_simplification(db, new_positive, *existing_negative) {
                         *self = Self::default();
                         self.positive.insert(Type::Never);
                         return;
                     }
                     // A & ~B = A    if A and B are disjoint
-                    if existing_negative.is_disjoint_from(db, new_positive) {
+                    if are_disjoint_for_set_simplification(db, *existing_negative, new_positive) {
                         to_remove.push(index);
                     }
                 }
@@ -1674,7 +1736,7 @@
     /// Adds a negative type to this intersection.
     fn add_negative(&mut self, db: &'db dyn Db, new_negative: Type<'db>) {
         // `Never & ~T` -> `Never`.
-        if self.positive.iter().any(Type::is_never) {
+        if self.positive.iter().any(is_bottom_for_set_simplification) {
             return;
         }
 
@@ -1707,10 +1769,11 @@
                     self.add_positive(db, *neg);
                 }
             }
-            Type::Never => {
+            Type::Never
+            | Type::Dynamic(DynamicType::NarrowingBound(MaterializationKind::Bottom)) => {
                 // Adding ~Never to an intersection is a no-op.
             }
-            Type::NominalInstance(instance) if instance.is_object() => {
+            ty if is_top_for_set_simplification(ty) => {
                 // Adding ~object to an intersection results in Never.
                 *self = Self::default();
                 self.positive.insert(Type::Never);
@@ -1767,7 +1830,7 @@
                         to_remove.push(index);
                     }
                     // same rule, reverse order
-                    if new_negative.is_subtype_of(db, *existing_negative) {
+                    if is_subtype_for_set_simplification(db, new_negative, *existing_negative) {
                         return;
                     }
                 }
@@ -1798,13 +1861,13 @@
                     }
 
                     // S & ~T = Never    if S <: T
-                    if existing_positive.is_subtype_of(db, new_negative) {
+                    if is_subtype_for_set_simplification(db, *existing_positive, new_negative) {
                         *self = Self::default();
                         self.positive.insert(Type::Never);
                         return;
                     }
                     // A & ~B = A    if A and B are disjoint
-                    if existing_positive.is_disjoint_from(db, new_negative) {
+                    if are_disjoint_for_set_simplification(db, *existing_positive, new_negative) {
                         return;
                     }
                 }
@@ -1847,7 +1910,7 @@
                 let matching_constraints = constraints
                     .iter()
                     .enumerate()
-                    .filter(|(_, c)| c.is_subtype_of(db, *negative));
+                    .filter(|(_, c)| is_subtype_for_set_simplification(db, **c, *negative));
                 for (constraint_index, _) in matching_constraints {
                     remaining_constraints[constraint_index] = None;
                 }
@@ -1931,7 +1994,7 @@
     use crate::place::{global_symbol, known_module_symbol};
     use crate::types::enums::enum_member_literals;
     use crate::types::type_alias::TypeAliasType;
-    use crate::types::{KnownClass, KnownInstanceType, Truthiness};
+    use crate::types::{KnownClass, KnownInstanceType, MaterializationKind, Truthiness};
 
     use ruff_db::system::DbWithWritableSystem as _;
     use ty_module_resolver::KnownModule;
@@ -1954,6 +2017,61 @@
     }
 
     #[test]
+    fn build_union_preserves_single_narrowing_bounds() {
+        let db = setup_db();
+
+        for materialization_kind in [MaterializationKind::Top, MaterializationKind::Bottom] {
+            let bound = Type::narrowing_bound(materialization_kind);
+            assert_eq!(UnionBuilder::new(&db).add(bound).build(), bound);
+        }
+    }
+
+    #[test]
+    fn build_union_simplifies_narrowing_bounds() {
+        let db = setup_db();
+        let bound = Type::narrowing_bound(MaterializationKind::Top);
+        let bottom = Type::narrowing_bound(MaterializationKind::Bottom);
+        let int = KnownClass::Int.to_instance(&db);
+
+        assert_eq!(UnionBuilder::new(&db).add(bound).add(int).build(), bound);
+        assert_eq!(UnionBuilder::new(&db).add(int).add(bound).build(), bound);
+        assert_eq!(UnionBuilder::new(&db).add(bottom).add(int).build(), int);
+        assert_eq!(UnionBuilder::new(&db).add(int).add(bottom).build(), int);
+    }
+
+    #[test]
+    fn narrowing_bounds_are_gradual_outside_set_simplification() {
+        let db = setup_db();
+        let int = KnownClass::Int.to_instance(&db);
+
+        for materialization_kind in [MaterializationKind::Top, MaterializationKind::Bottom] {
+            let bound = Type::narrowing_bound(materialization_kind);
+
+            assert!(!bound.is_never());
+            assert!(bound.is_assignable_to(&db, int));
+            assert!(int.is_assignable_to(&db, bound));
+            assert!(!bound.is_subtype_of(&db, int));
+            assert!(!int.is_subtype_of(&db, bound));
+            assert!(!bound.is_disjoint_from(&db, int));
+            assert_eq!(bound.member(&db, "missing").place.expect_type(), bound);
+        }
+    }
+
+    #[test]
+    fn build_intersection_preserves_narrowing_bottom_under_negation() {
+        let db = setup_db();
+        let bottom = Type::narrowing_bound(MaterializationKind::Bottom);
+        let int = KnownClass::Int.to_instance(&db);
+
+        let intersection = IntersectionBuilder::new(&db)
+            .add_positive(bottom)
+            .add_negative(int)
+            .build();
+
+        assert_eq!(intersection, bottom);
+    }
+
+    #[test]
     fn build_union_two_elements() {
         let db = setup_db();
 
diff --git a/crates/ty_python_semantic/src/types/subclass_of.rs b/crates/ty_python_semantic/src/types/subclass_of.rs
index 462cfd2..95112c7 100644
--- a/crates/ty_python_semantic/src/types/subclass_of.rs
+++ b/crates/ty_python_semantic/src/types/subclass_of.rs
@@ -515,8 +515,10 @@
                 ),
             },
             Type::TypeVar(bound_typevar) => SubclassOfInner::TypeVar(bound_typevar),
-            Type::Dynamic(DynamicType::Any) => SubclassOfInner::Dynamic(DynamicType::Any),
-            Type::Dynamic(DynamicType::Unknown) => SubclassOfInner::Dynamic(DynamicType::Unknown),
+            Type::Dynamic(
+                dynamic
+                @ (DynamicType::Any | DynamicType::Unknown | DynamicType::NarrowingBound(_)),
+            ) => SubclassOfInner::Dynamic(dynamic),
             _ => return None,
         })
     }
diff --git a/crates/ty_python_semantic/src/types/typevar.rs b/crates/ty_python_semantic/src/types/typevar.rs
index 537d919..2fe7c7d 100644
--- a/crates/ty_python_semantic/src/types/typevar.rs
+++ b/crates/ty_python_semantic/src/types/typevar.rs
@@ -605,7 +605,8 @@
                     | DynamicType::UnknownGeneric(_)
                     | DynamicType::UnspecializedTypeVar
                     | DynamicType::InvalidConcatenateUnknown
-                    | DynamicType::AmbiguousOverload => Parameters::unknown(),
+                    | DynamicType::AmbiguousOverload
+                    | DynamicType::NarrowingBound(_) => Parameters::unknown(),
                 },
                 Type::Divergent(_) => Parameters::unknown(),
                 Type::TypeVar(typevar) if typevar.is_paramspec(db) => {