class _Completable(ABC):
-    _completed: Any
+    completed: Any
 
     @abstractmethod
     def complete(self) -> None:
-        'Set ._completed to "complete" value if possible of current state.'
+        'Set .completed to "complete" value if possible of current state.'
 
 
 class _CompletableStringsCollection(_Completable, Collection):
     _collected: Collection[str]
-    _completed: Optional[Collection[str]] = None
+    completed: Optional[Collection[str]] = None
 
     @abstractmethod
     def _copy_collected(self) -> Collection[str]:
         pass
 
     def complete(self) -> None:
-        self._completed = self._copy_collected()
+        self.completed = self._copy_collected()
 
     def __len__(self) -> int:
-        assert self._completed is not None
-        return len(self._completed)
+        assert self.completed is not None
+        return len(self.completed)
 
     def __contains__(self, item) -> bool:
-        assert self._completed is not None
+        assert self.completed is not None
         assert isinstance(item, str)
-        return item in self._completed
+        return item in self.completed
 
     def __iter__(self) -> Iterator[str]:
-        assert self._completed is not None
-        yield from self._completed
+        assert self.completed is not None
+        yield from self.completed
 
 
 class _CompletableStringsSet(_CompletableStringsCollection, Set):
     _collected: set[str]
-    _completed: Optional[set[str]] = None
+    completed: Optional[set[str]] = None
 
     def __init__(self) -> None:
         self._collected = set()
                 ) -> None:
         method = getattr(self._collected, m_name)
         if on_complete:
-            assert self._completed is not None
-            assert (item in self._completed) == exists
+            assert self.completed is not None
+            assert (item in self.completed) == exists
             assert (item in self._collected) == exists
             method(item)
             self.complete()
         else:
-            assert self._completed is None
+            assert self.completed is None
             assert (item in self._collected) == exists
             method(item)
 
 
     def intersection(self, *others: Iterable[str]) -> set[str]:
         'Compare self to other set(s).'
-        assert self._completed is not None
-        result = self._completed
+        assert self.completed is not None
+        result = self.completed
         for other in others:
             assert isinstance(other, set)
             result = result.intersection(other)
 
 class _CompletableStringsOrdered(_Clearable, _CompletableStringsCollection):
     _collected: tuple[str, ...] = tuple()
-    _completed: Optional[tuple[str, ...]] = tuple()
+    completed: Optional[tuple[str, ...]] = tuple()
 
     def _copy_collected(self) -> tuple[str, ...]:
         return tuple(self._collected)
         self.complete()
 
 
-class IntoEndnodeUpdatesMixin(AutoAttrMixin):
-    'Provides .into_endnode_updates exportable for module-external consumers.'
-    _cache: dict[tuple[str, ...], Any] = {}
-
-    def into_endnode_updates(self,
-                             client_id: str,
-                             path: tuple[str, ...]
-                             ) -> list[tuple[tuple[str, ...], Any]]:
-        'Return path-value pairs for any update-worthy sub-elements.'
-
-        def update_unless_cached(path: tuple[str, ...], val: Any):
-            steps_so_far: list[str] = []
-            for step in path[:-1]:
-                cache_path = tuple(client_id) + tuple(steps_so_far)
-                if cache_path in self._cache:
-                    del self._cache[cache_path]
-                steps_so_far += [step]
-            cache_path = tuple(client_id) + path
-            if cache_path not in self._cache or val != self._cache[cache_path]:
-                self._cache[cache_path] = val
-                return [(path, val)]
-            return []
-
-        if isinstance(self, _Completable):
-            if self._completed is not None:
-                return update_unless_cached(path, self._completed)
-            return []
-
-        if isinstance(self, Dict) and not self._dict:
-            return update_unless_cached(path, None)
-
-        updates = []
-        for key, val in (self._dict.items() if isinstance(self, Dict)
-                         else ((k, getattr(self, k))
-                               for k in self._deep_annotations())):
-            p = path + (key,)
-            if isinstance(val, IntoEndnodeUpdatesMixin):
-                updates += val.into_endnode_updates(client_id, p)
-            else:
-                updates += update_unless_cached(p, val)
-        return updates
-
-
-class _UpdatingMixin(IntoEndnodeUpdatesMixin):
+class _UpdatingMixin:
 
     def __init__(self, on_update: Callable, **kwargs) -> None:
         super().__init__(**kwargs)
         self._on_update = on_update
 
 
-class _UpdatingAttrsMixin(_UpdatingMixin):
+class _UpdatingAttrsMixin(_UpdatingMixin, AutoAttrMixin):
 
     def _make_attr(self, cls: Callable, key: str):
         return cls(on_update=lambda *steps: self._on_update(key, *steps))
         if hasattr(self, '_on_update') and key in self._deep_annotations():
             self._on_update(key)
 
+    def attr_names(self) -> tuple[str, ...]:
+        'Names of (deeply) annotated attributes.'
+        return tuple(self._deep_annotations().keys())
+
 
 class _UpdatingDict(_UpdatingMixin, _Dict[DictItem]):
     _create_if_none: Optional[dict[str, Any]] = None
 
+    def __bool__(self) -> bool:
+        return bool(self._dict)
+
     def __getitem__(self, key: str) -> DictItem:
         if key not in self._dict:
             if self._create_if_none is not None:
 
 
 class _CompletableTopic(_Completable, Topic):
-    _completed: Optional[Topic] = None
+    completed: Optional[Topic] = None
 
     def complete(self) -> None:
         assert self.who is not None
         copy = _NickUserHost.from_str(str(self.who))
-        self._completed = Topic(self.what,
-                                NickUserHost(copy.nick, copy.user, copy.host))
+        self.completed = Topic(self.what,
+                               NickUserHost(copy.nick, copy.user, copy.host))
 
 
 class _Channel(Channel):
 
 
 class _ClientDb(_Clearable, _UpdatingAttrsMixin, SharedClientDbFields):
+    _updates_cache: dict[tuple[str, ...], Any] = {}
     _keep_on_clear = set(IrcConnSetup.__annotations__.keys())
     caps: _UpdatingDict[_UpdatingServerCapability]
     channels: _UpdatingChannelsDict
             attr._create_if_none = {}
         return attr
 
+    def into_endnode_updates(self, path: tuple[str, ...]
+                             ) -> list[tuple[tuple[str, ...], Any]]:
+        'Return path-value pairs for update-worthy (sub-)elements at path.'
+
+        def update_unless_cached(path: tuple[str, ...], val: Any
+                                 ) -> list[tuple[tuple[str, ...], Any]]:
+            if path not in self._updates_cache\
+                    or val != self._updates_cache[path]:
+                self._updates_cache[path] = val
+                return [(path, val)]
+            return []
+
+        parent = self
+        cache_path: tuple[str, ...] = tuple()
+        for step in path[:-1]:
+            cache_path = cache_path + (step,)
+            if cache_path in self._updates_cache:
+                del self._updates_cache[cache_path]
+            parent = (parent[step] if isinstance(parent, Dict)
+                      else getattr(parent, step))
+        last_step = path[-1]
+        val_at_path = ((parent[last_step] if last_step in parent.keys()
+                        else None) if isinstance(parent, Dict)
+                       else getattr(parent, last_step))
+        if not isinstance(val_at_path, _UpdatingMixin):
+            return update_unless_cached(path, val_at_path)
+        if isinstance(val_at_path, _Completable):
+            if val_at_path.completed is not None:
+                return update_unless_cached(path, val_at_path.completed)
+            return []
+        if isinstance(val_at_path, Dict) and not val_at_path.keys():
+            for cache_path in [p for p in self._updates_cache
+                               if len(p) > len(path)
+                               and p[:len(path)] == path]:
+                del self._updates_cache[cache_path]
+            return update_unless_cached(path, None)
+        if isinstance(val_at_path, Dict):
+            sub_items = val_at_path.keys()
+        else:
+            assert isinstance(val_at_path, _UpdatingAttrsMixin)
+            sub_items = val_at_path.attr_names()
+        updates = []
+        for key in sub_items:
+            p = path + (key,)
+            updates += self.into_endnode_updates(p)
+        return updates
+
     def clear(self) -> None:
         for key, value in [(k, v) for k, v in self._deep_annotations().items()
                            if k not in self._keep_on_clear]: