Repository: canonical/operator (language: python)

Issue:
Fix deprecation warning for `model.Storage.id`
## Issue

Getting very noisy logs with deprecation warning for `model.Storage.id`:
```bash
unit-kafka-0: 18:05:57 INFO unit.kafka/0.juju-log Created directory at path - /var/snap/kafka/common/log/logs/10
unit-kafka-0: 18:05:58 WARNING unit.kafka/0.juju-log model.Storage.id is being replaced - please use model.Storage.index
unit-kafka-0: 18:05:58 WARNING unit.kafka/0.juju-log model.Storage.id is being replaced - please use model.Storage.index
unit-kafka-0: 18:05:58 INFO unit.kafka/0.juju-log Created directory at path - /var/snap/kafka/common/log/logs/8
unit-kafka-0: 18:05:59 WARNING unit.kafka/0.juju-log model.Storage.id is being replaced - please use model.Storage.index
unit-kafka-0: 18:05:59 WARNING unit.kafka/0.juju-log model.Storage.id is being replaced - please use model.Storage.index
unit-kafka-0: 18:05:59 INFO unit.kafka/0.juju-log Created directory at path - /var/snap/kafka/common/log/logs/4
unit-kafka-0: 18:06:00 WARNING unit.kafka/0.juju-log model.Storage.id is being replaced - please use model.Storage.index
unit-kafka-0: 18:06:00 WARNING unit.kafka/0.juju-log model.Storage.id is being replaced - please use model.Storage.index
unit-kafka-0: 18:06:00 INFO unit.kafka/0.juju-log Created directory at path - /var/snap/kafka/common/log/logs/3
unit-kafka-0: 18:06:01 WARNING unit.kafka/0.juju-log model.Storage.id is being replaced - please use model.Storage.index
unit-kafka-0: 18:06:01 INFO unit.kafka/0.juju-log Created directory at path - /var/snap/kafka/common/log/logs/0
unit-kafka-0: 18:06:02 WARNING unit.kafka/0.juju-log model.Storage.id is being replaced - please use model.Storage.index
unit-kafka-0: 18:06:02 WARNING unit.kafka/0.juju-log model.Storage.id is being replaced - please use model.Storage.index
unit-kafka-0: 18:06:02 INFO unit.kafka/0.juju-log Created directory at path - /var/snap/kafka/common/log/logs/1
unit-kafka-0: 18:06:03 WARNING unit.kafka/0.juju-log model.Storage.id is being replaced - please use model.Storage.index
unit-kafka-0: 18:06:03 WARNING unit.kafka/0.juju-log model.Storage.id is being replaced - please use model.Storage.index
unit-kafka-0: 18:06:03 WARNING unit.kafka/0.juju-log model.Storage.id is being replaced - please use model.Storage.index
unit-kafka-0: 18:06:03 WARNING unit.kafka/0.juju-log model.Storage.id is being replaced - please use model.Storage.index
unit-kafka-0: 18:06:03 INFO unit.kafka/0.juju-log Created directory at path - /var/snap/kafka/common/log/logs/2
unit-kafka-0: 18:06:03 WARNING unit.kafka/0.juju-log model.Storage.id is being replaced - please use model.Storage.index
unit-kafka-0: 18:06:03 WARNING unit.kafka/0.juju-log model.Storage.id is being replaced - please use model.Storage.index
unit-kafka-0: 18:06:03 WARNING unit.kafka/0.juju-log model.Storage.id is being replaced - please use model.Storage.index
unit-kafka-0: 18:06:03 WARNING unit.kafka/0.juju-log model.Storage.id is being replaced - please use model.Storage.index
unit-kafka-0: 18:06:03 WARNING unit.kafka/0.juju-log model.Storage.id is being replaced - please use model.Storage.index
unit-kafka-0: 18:06:03 WARNING unit.kafka/0.juju-log model.Storage.id is being replaced - please use model.Storage.index
unit-kafka-0: 18:06:03 WARNING unit.kafka/0.juju-log model.Storage.id is being replaced - please use model.Storage.index
```

## Causes

- No idea what the cause is, I can't see me referencing it in the code. Makes me wonder if `ops` is calling this warning itself?
- Places where I reference `storage` code are:
    - `if not len(self.model.storages["logs"]) == 12:`
    - `path = event.storage.location if event.storage else None`
    - `log_dirs = [storage.location for storage in self.charm.model.storages["logs"]]`


 


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 34da43622..e1f2612ec 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -5,16 +5,22 @@ on:
       - published
 
 jobs:
+  framework-tests:
+    uses: ./.github/workflows/framework-tests.yaml
+  observability-charm-tests:
+    uses: ./.github/workflows/observability-charm-tests.yaml
+  hello-charm-tests:
+    uses: ./.github/workflows/hello-charm-tests.yaml
   build-n-publish:
     name: Build and Publish to PyPI
     runs-on: ubuntu-latest
-
+    needs: [framework-tests, observability-charm-tests, hello-charm-tests]
     steps:
       - uses: actions/checkout@v2
       - name: Setup Python
         uses: actions/setup-python@v1
         with:
-          python-version: 3.9
+          python-version: "3.10"
       - name: Install wheel
         run: pip install wheel
       - name: Build
diff --git a/HACKING.md b/HACKING.md
index dc26a8965..6688c8b5f 100644
--- a/HACKING.md
+++ b/HACKING.md
@@ -121,3 +121,5 @@ This will trigger an automatic build for the Python package and publish it to Py
 See [.github/workflows/publish.yml](https://github.com/canonical/operator/blob/main/.github/workflows/publish.yml) for details. (Note that the versions in publish.yml refer to versions of the github actions, not the versions of the Operator Framework.)
 
 You can troubleshoot errors on the [Actions Tab](https://github.com/canonical/operator/actions).
+
+Announce the release on discourse.
diff --git a/ops/__init__.py b/ops/__init__.py
index 44cb77a44..5dbba5327 100644
--- a/ops/__init__.py
+++ b/ops/__init__.py
@@ -40,5 +40,5 @@
 """
 
 # Import here the bare minimum to break the circular import between modules
-from . import charm  # noqa: F401 (imported but unused)
-from .version import version as __version__  # noqa: F401 (imported but unused)
+from . import charm  # type: ignore # noqa
+from .version import version as __version__  # type: ignore # noqa
diff --git a/ops/_private/yaml.py b/ops/_private/yaml.py
index 82802d436..8729d5ce4 100644
--- a/ops/_private/yaml.py
+++ b/ops/_private/yaml.py
@@ -14,6 +14,8 @@
 
 """Internal YAML helpers."""
 
+from typing import Any, Optional, TextIO, Union, overload
+
 import yaml
 
 # Use C speedups if available
@@ -21,11 +23,21 @@
 _safe_dumper = getattr(yaml, 'CSafeDumper', yaml.SafeDumper)
 
 
-def safe_load(stream):
+def safe_load(stream: Union[str, TextIO]):
     """Same as yaml.safe_load, but use fast C loader if available."""
     return yaml.load(stream, Loader=_safe_loader)
 
 
-def safe_dump(data, stream=None, **kwargs):
-    """Same as yaml.safe_dump, but use fast C dumper if available."""
+@overload
+def safe_dump(data: Any, *args: Any, encoding: None = None, **kwargs: Any) -> str: ...  # noqa
+@overload
+def safe_dump(data: Any, *args: Any, encoding: str = "", **kwargs: Any) -> bytes: ...  # noqa
+
+
+def safe_dump(data: Any, stream: Optional[Union[str, TextIO]] = None, **kwargs: Any  # noqa
+              ) -> Union[str, bytes]:
+    """Same as yaml.safe_dump, but use fast C dumper if available.
+
+    If `encoding:str` is provided, return bytes. Else, return str.
+    """
     return yaml.dump(data, stream=stream, Dumper=_safe_dumper, **kwargs)
diff --git a/ops/charm.py b/ops/charm.py
index eb1a5b757..a4cce35ee 100755
--- a/ops/charm.py
+++ b/ops/charm.py
@@ -17,12 +17,115 @@
 import enum
 import os
 import pathlib
-import typing
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    Dict,
+    List,
+    Mapping,
+    Optional,
+    TextIO,
+    Union,
+    cast,
+)
 
 from ops import model
 from ops._private import yaml
 from ops.framework import EventBase, EventSource, Framework, Object, ObjectEvents
 
+if TYPE_CHECKING:
+    from typing_extensions import Literal, Required, TypedDict
+
+    from ops.framework import Handle, JsonObject, _SerializedData
+    from ops.model import Container, Numerical, Relation, Storage
+
+    # CharmMeta also needs these.
+    _ActionParam = Dict[str, 'JsonObject']  # <JSON Schema definition>
+    _ActionMetaDict = TypedDict(
+        '_ActionMetaDict', {
+            'title': str,
+            'description': str,
+            'params': Dict[str, _ActionParam],
+            'required': List[str]},
+        total=False)
+
+    _Scopes = Literal['global', 'container']
+    _RelationMetaDict = TypedDict(
+        '_RelationMetaDict', {
+            'interface': Required[str],
+            'limit': int,
+            'scope': _Scopes},
+        total=False)
+
+    _MultipleRange = TypedDict('_MultipleRange', {'range': str})
+    _StorageMetaDict = TypedDict('_StorageMetaDict', {
+        'type': Required[str],
+        'description': int,
+        'shared': bool,
+        'read-only': bool,
+        'minimum-size': str,
+        'location': str,
+        'multiple-range': str,
+        'multiple': _MultipleRange
+    })
+
+    _ResourceMetaDict = TypedDict(
+        '_ResourceMetaDict', {
+            'type': Required[str],
+            'filename': str,
+            'description': str},
+        total=False)
+
+    _PayloadMetaDict = TypedDict('_PayloadMetaDict', {'type': str})
+
+    _MountDict = TypedDict(
+        '_MountDict', {'storage': Required[str],
+                       'location': str},
+        total=False)
+    _ContainerMetaDict = TypedDict(
+        '_ContainerMetaDict', {'mounts': List[_MountDict]})
+
+    _CharmMetaDict = TypedDict(
+        '_CharmMetaDict', {  # all are optional
+            'name': Required[str],
+            'summary': Required[str],
+            'description': Required[str],
+            'maintainer': str,
+            'maintainers': List[str],
+            'tags': List[str],
+            'terms': List[str],
+            'series': List[str],
+            'subordinate': bool,
+            'min-juju-version': str,
+            'requires': Dict[str, '_RelationMetaDict'],
+            'provides': Dict[str, '_RelationMetaDict'],
+            'peers': Dict[str, '_RelationMetaDict'],
+            'storage': Dict[str, '_StorageMetaDict'],
+            'resources': Dict[str, '_ResourceMetaDict'],
+            'payloads': Dict[str, '_PayloadMetaDict'],
+            'extra-bindings': Dict[str, Any],  # fixme: _BindingDict?
+            'containers': Dict[str, '_ContainerMetaDict']
+        }, total=False)
+
+    # can't put in *Event because *Event.snapshot needs it.
+    _WorkloadEventSnapshot = TypedDict('_WorkloadEventSnapshot', {
+        'container_name': str
+    }, total=False)
+
+    _RelationDepartedEventSnapshot = TypedDict('_RelationDepartedEventSnapshot', {
+        'relation_name': str,
+        'relation_id': int,
+        'app_name': Optional[str],
+        'unit_name': Optional[str],
+        'departing_unit': Optional[str]
+    }, total=False)
+
+    _StorageEventSnapshot = TypedDict('_StorageEventSnapshot', {
+        'storage_name': str,
+        'storage_index': int,
+        'storage_location': str,
+    }, total=False)
+
 
 class HookEvent(EventBase):
     """Events raised by Juju to progress a charm's lifecycle.
@@ -68,7 +171,7 @@ def defer(self):
         """
         raise RuntimeError('cannot defer action events')
 
-    def restore(self, snapshot: dict) -> None:
+    def restore(self, snapshot: 'JsonObject'):
         """Used by the operator framework to record the action.
 
         Not meant to be called directly by charm code.
@@ -77,34 +180,57 @@ def restore(self, snapshot: dict) -> None:
         event_action_name = self.handle.kind[:-len('_action')].replace('_', '-')
         if event_action_name != env_action_name:
             # This could only happen if the dev manually emits the action, or from a bug.
-            raise RuntimeError('action event kind does not match current action')
+            raise RuntimeError('action event kind ({}) does not match current '
+                               'action ({})'.format(event_action_name, env_action_name))
         # Params are loaded at restore rather than __init__ because
         # the model is not available in __init__.
-        self.params = self.framework.model._backend.action_get()
+        self.params = self.framework.model._backend.action_get()  # pyright: reportPrivateUsage=false  # noqa
 
-    def set_results(self, results: typing.Mapping) -> None:
+    def set_results(self, results: '_SerializedData'):
         """Report the result of the action.
 
         Args:
             results: The result of the action as a Dict
+            Juju eventually only accepts a str:str mapping, so we will attempt
+            to flatten any more complex data structure like so:
+            >>> {'a': 'b'} # becomes: 'a'='b'
+            >>> {'a': {'b': 'c'}} # becomes: 'a.b'='c'
+            >>> {'a': {'b': 'c', 'd': 'e'}} # becomes: 'a.b'='c', 'a.d' = 'e'
+            >>> {'a.b': 'c', 'a.d': 'e'} # equivalent to previous
+            Note that duplicate keys are not allowed, so
+            >>> {'a': {'b': 'c'}, 'a.b': 'c'} # invalid!
+
+            Note that the resulting keys must start and end with lowercase
+            alphanumeric, and can only contain lowercase alphanumeric, hyphens
+            and periods.
+
+            If any exceptions occur whilst the action is being handled, juju will
+            gather any stdout/stderr data (and the return code) and inject them into the
+            results object. Thus, the results object might contain the following keys,
+            additionally to those specified by the charm code:
+             - Stdout
+             - Stderr
+             - Stdout-encoding
+             - Stderr-encoding
+             - ReturnCode
         """
-        self.framework.model._backend.action_set(results)
+        self.framework.model._backend.action_set(results)   # pyright: reportPrivateUsage=false
 
-    def log(self, message: str) -> None:
+    def log(self, message: str):
         """Send a message that a user will see while the action is running.
 
         Args:
             message: The message for the user.
         """
-        self.framework.model._backend.action_log(message)
+        self.framework.model._backend.action_log(message)  # pyright: reportPrivateUsage=false
 
-    def fail(self, message: str = '') -> None:
+    def fail(self, message: str = ''):
         """Report that this action has failed.
 
         Args:
             message: Optional message to record why it has failed.
         """
-        self.framework.model._backend.action_fail(message)
+        self.framework.model._backend.action_fail(message)  # pyright: reportPrivateUsage=false
 
 
 class InstallEvent(HookEvent):
@@ -147,18 +273,23 @@ class RemoveEvent(HookEvent):
 
 
 class ConfigChangedEvent(HookEvent):
-    """Event triggered when a configuration change is requested.
-
-    This event fires in several different situations.
-
-    - immediately after the :class:`install <InstallEvent>` event.
-    - after a :class:`relation is created <RelationCreatedEvent>`.
-    - after a :class:`leader is elected <LeaderElectedEvent>`.
-    - after changing charm configuration using the GUI or command line
-      interface
-    - when the charm :class:`starts <StartEvent>`.
-    - when a new unit :class:`joins a relation <RelationJoinedEvent>`.
-    - when there is a :class:`change to an existing relation <RelationChangedEvent>`.
+    """Event triggered when a configuration change occurs.
+
+    This event can fire in several situations:
+
+    - Right after the unit starts up for the first time.
+      This event notifies the charm of its initial configuration.
+      Typically, this event will fire between a :class:`install <InstallEvent>`
+      and a :class:`starts <StartEvent>` during the startup sequence
+      (when you first deploy a unit), but more in general it will fire whenever
+      the unit is (re)started, e.g. after pod churn on kubernetes, on unit
+      rescheduling, on unit upgrade/refresh, etc...
+    - As a specific instance of the above point: when networking changes
+      (if the machine reboots and comes up with a different IP).
+    - When the cloud admin reconfigures the charm via the juju CLI, i.e.
+      `juju config my-charm foo=bar`. This event notifies the charm of
+      its new configuration. (The event itself, however, is not aware of *what*
+      specifically has changed in the config).
 
     Any callback method bound to this event cannot assume that the
     software has already been started; it should not start stopped
@@ -261,7 +392,8 @@ class CollectMetricsEvent(HookEvent):
     how they can interact with Juju.
     """
 
-    def add_metrics(self, metrics: typing.Mapping, labels: typing.Mapping = None) -> None:
+    def add_metrics(self, metrics: Mapping[str, 'Numerical'],
+                    labels: Optional[Mapping[str, str]] = None):
         """Record metrics that have been gathered by the charm for this unit.
 
         Args:
@@ -270,7 +402,7 @@ def add_metrics(self, metrics: typing.Mapping, labels: typing.Mapping = None) ->
             labels: {key:value} strings that can be applied to the
                 metrics that are being gathered
         """
-        self.framework.model._backend.add_metrics(metrics, labels)
+        self.framework.model._backend.add_metrics(metrics, labels)  # type:ignore
 
 
 class RelationEvent(HookEvent):
@@ -292,8 +424,17 @@ class RelationEvent(HookEvent):
               :class:`~ops.model.Application` level event
 
     """
-
-    def __init__(self, handle, relation, app=None, unit=None):
+    if TYPE_CHECKING:
+        _RelationEventSnapshot = TypedDict('_RelationEventSnapshot', {
+            'relation_name': Required[str],
+            'relation_id': Required[int],
+            'app_name': Optional[str],
+            'unit_name': Optional[str]
+        }, total=False)
+
+    def __init__(self, handle: 'Handle', relation: 'Relation',
+                 app: Optional[model.Application] = None,
+                 unit: Optional[model.Unit] = None):
         super().__init__(handle)
 
         if unit is not None and unit.app != app:
@@ -304,7 +445,7 @@ def __init__(self, handle, relation, app=None, unit=None):
         self.app = app
         self.unit = unit
 
-    def snapshot(self) -> dict:
+    def snapshot(self) -> '_RelationEventSnapshot':
         """Used by the framework to serialize the event to disk.
 
         Not meant to be called by charm code.
@@ -312,20 +453,25 @@ def snapshot(self) -> dict:
         snapshot = {
             'relation_name': self.relation.name,
             'relation_id': self.relation.id,
-        }
+        }  # type: 'RelationEvent._RelationEventSnapshot'
         if self.app:
             snapshot['app_name'] = self.app.name
         if self.unit:
             snapshot['unit_name'] = self.unit.name
         return snapshot
 
-    def restore(self, snapshot: dict) -> None:
+    def restore(self, snapshot: '_RelationEventSnapshot'):
         """Used by the framework to deserialize the event from disk.
 
         Not meant to be called by charm code.
         """
-        self.relation = self.framework.model.get_relation(
+        relation = self.framework.model.get_relation(
             snapshot['relation_name'], snapshot['relation_id'])
+        if relation is None:
+            raise ValueError(
+                'Unable to restore {}: relation {} (id={}) not found.'.format(
+                    self, snapshot['relation_name'], snapshot['relation_id']))
+        self.relation = relation
 
         app_name = snapshot.get('app_name')
         if app_name:
@@ -406,14 +552,26 @@ class RelationDepartedEvent(RelationEvent):
             unit.
     """
 
-    def __init__(self, handle, relation, app=None, unit=None,
-                 departing_unit_name=None):
+    def __init__(self, handle: 'Handle', relation: 'Relation',
+                 app: Optional[model.Application] = None,
+                 unit: Optional[model.Unit] = None,
+                 departing_unit_name: Optional[str] = None):
         super().__init__(handle, relation, app=app, unit=unit)
 
         self._departing_unit_name = departing_unit_name
 
+    def snapshot(self) -> '_RelationDepartedEventSnapshot':
+        """Used by the framework to serialize the event to disk.
+
+        Not meant to be called by charm code.
+        """
+        snapshot = cast('_RelationDepartedEventSnapshot', super().snapshot())
+        if self._departing_unit_name:
+            snapshot['departing_unit'] = self._departing_unit_name
+        return snapshot
+
     @property
-    def departing_unit(self) -> typing.Optional[model.Unit]:
+    def departing_unit(self) -> Optional[model.Unit]:
         """The `ops.model.Unit` that is departing, if any."""
         # doing this on init would fail because `framework` gets patched in
         # post-init
@@ -421,22 +579,12 @@ def departing_unit(self) -> typing.Optional[model.Unit]:
             return None
         return self.framework.model.get_unit(self._departing_unit_name)
 
-    def snapshot(self) -> dict:
-        """Used by the framework to serialize the event to disk.
-
-        Not meant to be called by charm code.
-        """
-        snapshot = super().snapshot()
-        if self._departing_unit_name:
-            snapshot['departing_unit'] = self.departing_unit.name
-        return snapshot
-
-    def restore(self, snapshot: dict) -> None:
+    def restore(self, snapshot: '_RelationDepartedEventSnapshot'):
         """Used by the framework to deserialize the event from disk.
 
         Not meant to be called by charm code.
         """
-        super().restore(snapshot)
+        super().restore(snapshot)  # type: ignore
 
         self._departing_unit_name = snapshot.get('departing_unit')
 
@@ -464,26 +612,28 @@ class StorageEvent(HookEvent):
     charms can define several different types of storage that are
     allocated from Juju. Changes in state of storage trigger sub-types
     of :class:`StorageEvent`.
+
+    Attributes:
+        storage: The :class:`~ops.model.Storage` instance this event is about.
     """
 
-    def __init__(self, handle, storage):
+    def __init__(self, handle: 'Handle', storage: 'Storage'):
         super().__init__(handle)
         self.storage = storage
 
-    def snapshot(self) -> dict:
+    def snapshot(self) -> '_StorageEventSnapshot':
         """Used by the framework to serialize the event to disk.
 
         Not meant to be called by charm code.
         """
-        snapshot = {}
-
+        snapshot = {}  # type: '_StorageEventSnapshot'
         if isinstance(self.storage, model.Storage):
             snapshot["storage_name"] = self.storage.name
             snapshot["storage_index"] = self.storage.index
             snapshot["storage_location"] = str(self.storage.location)
         return snapshot
 
-    def restore(self, snapshot: dict) -> None:
+    def restore(self, snapshot: '_StorageEventSnapshot'):
         """Used by the framework to deserialize the event from disk.
 
         Not meant to be called by charm code.
@@ -499,6 +649,12 @@ def restore(self, snapshot: dict) -> None:
                 msg = 'failed loading storage (name={!r}, index={!r}) from snapshot' \
                     .format(storage_name, storage_index)
                 raise RuntimeError(msg)
+            if storage_location is None:
+                raise RuntimeError(
+                    'failed loading storage location from snapshot.'
+                    '(name={!r}, index={!r}, storage_location=None)'
+                    .format(storage_name, storage_index))
+
             self.storage.location = storage_location
 
 
@@ -546,22 +702,22 @@ class WorkloadEvent(HookEvent):
                   a Machine.
     """
 
-    def __init__(self, handle, workload):
+    def __init__(self, handle: 'Handle', workload: 'Container'):
         super().__init__(handle)
 
         self.workload = workload
 
-    def snapshot(self) -> dict:
+    def snapshot(self) -> '_WorkloadEventSnapshot':
         """Used by the framework to serialize the event to disk.
 
         Not meant to be called by charm code.
         """
-        snapshot = {}
+        snapshot = {}  # type: "_WorkloadEventSnapshot"
         if isinstance(self.workload, model.Container):
             snapshot['container_name'] = self.workload.name
         return snapshot
 
-    def restore(self, snapshot: dict) -> None:
+    def restore(self, snapshot: '_WorkloadEventSnapshot'):
         """Used by the framework to deserialize the event from disk.
 
         Not meant to be called by charm code.
@@ -669,10 +825,14 @@ def __init__(self, *args):
 
     # note that without the #: below, sphinx will copy the whole of CharmEvents
     # docstring inline which is less than ideal.
-    #: Used to set up event handlers; see :class:`CharmEvents`.
-    on = CharmEvents()
-
-    def __init__(self, framework: Framework, key: typing.Optional = None):
+    # Used to set up event handlers; see :class:`CharmEvents`.
+    on = CharmEvents()  # type: ignore
+    if TYPE_CHECKING:
+        # to help the type checker and IDEs:
+        @property
+        def on(self) -> CharmEvents: ...  # noqa
+
+    def __init__(self, framework: Framework, key: Optional[str] = None):
         super().__init__(framework, None)
 
         for relation_name in self.framework.meta.relations:
@@ -775,15 +935,21 @@ class is mostly for the framework to understand what the charm has defined.
         actions_raw: a mapping containing the contents of actions.yaml
 
     """
+    if TYPE_CHECKING:
+        # avoid long line in init
+        _ActionsRaw = Optional[Dict[str, '_ActionMetaDict']]
 
-    def __init__(self, raw: dict = None, actions_raw: dict = None):
-        raw = raw or {}
-        actions_raw = actions_raw or {}
+    def __init__(self,
+                 raw: Optional['_CharmMetaDict'] = None,  # type: ignore
+                 actions_raw: '_ActionsRaw' = None  # type: ignore
+                 ):
+        raw = raw or cast('_CharmMetaDict', {})  # type: _CharmMetaDict
+        actions_raw = actions_raw or {}  # type: Dict[str, _ActionMetaDict]
 
         self.name = raw.get('name', '')
         self.summary = raw.get('summary', '')
         self.description = raw.get('description', '')
-        self.maintainers = []
+        self.maintainers = []  # type: List[str]
         if 'maintainer' in raw:
             self.maintainers.append(raw['maintainer'])
         if 'maintainers' in raw:
@@ -799,7 +965,7 @@ def __init__(self, raw: dict = None, actions_raw: dict = None):
                          for name, rel in raw.get('provides', {}).items()}
         self.peers = {name: RelationMeta(RelationRole.peer, name, rel)
                       for name, rel in raw.get('peers', {}).items()}
-        self.relations = {}
+        self.relations = {}  # type: Dict[str, RelationMeta]
         self.relations.update(self.requires)
         self.relations.update(self.provides)
         self.relations.update(self.peers)
@@ -819,8 +985,8 @@ def __init__(self, raw: dict = None, actions_raw: dict = None):
 
     @classmethod
     def from_yaml(
-            cls, metadata: typing.Union[str, typing.TextIO],
-            actions: typing.Optional[typing.Union[str, typing.TextIO]] = None):
+            cls, metadata: Union[str, TextIO],
+            actions: Optional[Union[str, TextIO]] = None):
         """Instantiate a CharmMeta from a YAML description of metadata.yaml.
 
         Args:
@@ -828,10 +994,10 @@ def from_yaml(
                 This can be a simple string, or a file-like object. (passed to `yaml.safe_load`).
             actions: YAML description of Actions for this charm (eg actions.yaml)
         """
-        meta = yaml.safe_load(metadata)
+        meta = cast('_CharmMetaDict', yaml.safe_load(metadata))
         raw_actions = {}
         if actions is not None:
-            raw_actions = yaml.safe_load(actions)
+            raw_actions = cast(Dict[str, '_ActionMetaDict'], yaml.safe_load(actions))
             if raw_actions is None:
                 raw_actions = {}
         return cls(meta, raw_actions)
@@ -875,17 +1041,16 @@ class RelationMeta:
 
     VALID_SCOPES = ['global', 'container']
 
-    def __init__(self, role: RelationRole, relation_name: str, raw: dict):
-        if not isinstance(role, RelationRole):
-            raise TypeError("role should be a Role, not {!r}".format(role))
+    def __init__(self, role: RelationRole, relation_name: str, raw: '_RelationMetaDict'):
+        assert isinstance(role, RelationRole), "role should be one of {!r}, not {!r}".format(list(RelationRole), role)  # noqa
         self._default_scope = self.VALID_SCOPES[0]
         self.role = role
         self.relation_name = relation_name
         self.interface_name = raw['interface']
 
-        self.limit = raw.get('limit')
-        if self.limit and not isinstance(self.limit, int):
-            raise TypeError("limit should be an int, not {}".format(type(self.limit)))
+        self.limit = limit = raw.get('limit', None)
+        if limit is not None and not isinstance(limit, int):  # type: ignore  # noqa
+            raise TypeError("limit should be an int, not {}".format(type(limit)))
 
         self.scope = raw.get('scope') or self._default_scope
         if self.scope not in self.VALID_SCOPES:
@@ -906,7 +1071,7 @@ class StorageMeta:
         multiple_range: Range of numeric qualifiers when multiple storage units are used
     """
 
-    def __init__(self, name, raw):
+    def __init__(self, name: str, raw: '_StorageMetaDict'):
         self.storage_name = name
         self.type = raw['type']
         self.description = raw.get('description', '')
@@ -933,7 +1098,7 @@ class ResourceMeta:
         description: A text description of resource
     """
 
-    def __init__(self, name, raw):
+    def __init__(self, name: str, raw: '_ResourceMetaDict'):
         self.resource_name = name
         self.type = raw['type']
         self.filename = raw.get('filename', None)
@@ -948,7 +1113,7 @@ class PayloadMeta:
         type: Payload type
     """
 
-    def __init__(self, name, raw):
+    def __init__(self, name: str, raw: '_PayloadMetaDict'):
         self.payload_name = name
         self.type = raw['type']
 
@@ -956,7 +1121,7 @@ def __init__(self, name, raw):
 class ActionMeta:
     """Object containing metadata about an action's definition."""
 
-    def __init__(self, name, raw=None):
+    def __init__(self, name: str, raw: Optional['_ActionMetaDict'] = None):
         raw = raw or {}
         self.name = name
         self.title = raw.get('title', '')
@@ -976,16 +1141,16 @@ class ContainerMeta:
         mounts: :class:`ContainerStorageMeta` mounts available to the container
     """
 
-    def __init__(self, name, raw):
+    def __init__(self, name: str, raw: '_ContainerMetaDict'):
         self.name = name
-        self._mounts = {}
+        self._mounts = {}  # type: Dict[str, ContainerStorageMeta]
 
         # This is not guaranteed to be populated/is not enforced yet
         if raw:
             self._populate_mounts(raw.get('mounts', []))
 
     @property
-    def mounts(self) -> typing.Dict:
+    def mounts(self) -> Dict[str, 'ContainerStorageMeta']:
         """An accessor for the mounts in a container.
 
         Dict keys match key name in :class:`StorageMeta`
@@ -1004,7 +1169,7 @@ def mounts(self) -> typing.Dict:
         """
         return self._mounts
 
-    def _populate_mounts(self, mounts: typing.List):
+    def _populate_mounts(self, mounts: List['_MountDict']):
         """Populate a list of container mountpoints.
 
         Since Charm Metadata v2 specifies the mounts as a List, do a little data manipulation
@@ -1038,20 +1203,20 @@ class ContainerStorageMeta:
     which mount point was desired, and `locations` should be iterated over.
     """
 
-    def __init__(self, storage, location):
+    def __init__(self, storage: str, location: str):
         self.storage = storage
-        self._locations = [location]
+        self._locations = [location]  # type: List[str]
 
-    def add_location(self, location):
+    def add_location(self, location: str):
         """Add an additional mountpoint to a known storage."""
         self._locations.append(location)
 
     @property
-    def locations(self) -> typing.List:
+    def locations(self) -> List[str]:
         """An accessor for the list of locations for a mount."""
         return self._locations
 
-    def __getattr__(self, name):
+    def __getattr__(self, name: str):
         if name == "location":
             if len(self._locations) == 1:
                 return self._locations[0]
diff --git a/ops/framework.py b/ops/framework.py
index 5cb9ad035..ed4130054 100755
--- a/ops/framework.py
+++ b/ops/framework.py
@@ -28,40 +28,75 @@
 import types
 import typing
 import weakref
+from contextlib import contextmanager
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    Callable,
+    Dict,
+    Generic,
+    Hashable,
+    Iterable,
+    List,
+    Optional,
+    Set,
+    Tuple,
+    TypeVar,
+    Union,
+)
 
 from ops import charm
-from ops.storage import NoSnapshotError, SQLiteStorage
+from ops.storage import JujuStorage, NoSnapshotError, SQLiteStorage
 
-if typing.TYPE_CHECKING:
+if TYPE_CHECKING:
     from pathlib import Path
 
-    from typing_extensions import Protocol, Type
+    from typing_extensions import Literal, Protocol, Type
 
     from ops.charm import CharmMeta
-    from ops.model import Model
+    from ops.model import JsonObject, Model, _ModelBackend
 
     class _Serializable(Protocol):
-        handle = None  # type: Handle
-        def snapshot(self) -> dict: ...  # noqa: E704
-        def restore(self, snapshot: dict) -> "Object": ...  # noqa: E704
+        handle_kind = ''
+        @property
+        def handle(self) -> 'Handle': ...  # noqa
+        @handle.setter
+        def handle(self, val: 'Handle'): ...  # noqa
+        def snapshot(self) -> Dict[str, '_StorableType']: ...  # noqa
+        def restore(self, snapshot: Dict[str, '_StorableType']) -> None: ...  # noqa
 
-    _ObjectType = typing.TypeVar("_ObjectType", bound="Object")
-    _EventType = typing.TypeVar("_EventType", bound=Type["EventBase"])
-    _ObserverCallback = typing.Callable[[typing.Any], None]
-    _Path = _Kind = str
+    class _StoredObject(Protocol):
+        _under = None  # type: Any  # noqa
+
+    # serialized data structure
+    _SerializedData = Dict[str, 'JsonObject']
+
+    _ObserverCallback = Callable[[Any], None]
+
+    # types that can be stored natively
+    _StorableType = Union[int, bool, float, str, bytes, Literal[None],
+                          List['_StorableType'],
+                          Dict[str, '_StorableType'],
+                          Set['_StorableType']]
+
+    StoredObject = Union['StoredList', 'StoredSet', 'StoredDict']
 
     # This type is used to denote either a Handle instance or an instance of
     # an Object (or subclass). This is used by methods and classes which can be
     # called with either of those (they need a Handle, but will accept an Object
     # from which they will then extract the Handle).
-    _ParentHandle = typing.Union["Handle", _ObjectType]
+    _ParentHandle = Union['Handle', 'Object']
 
+    _Path = _Kind = _MethodName = _EventKey = str
     # used to type Framework Attributes
-    _ObserverPath = typing.List[typing.Tuple['_Path', str, '_Path', str]]
-    _ObjectPath = typing.Tuple[typing.Optional['_Path'], '_Kind']
-    _PathToObserverMapping = typing.Dict[str, '_ObserverCallback']
-    _PathToObjectMapping = typing.Dict[str, 'Object']
+    _ObserverPath = List[Tuple[_Path, _MethodName, _Path, _EventKey]]
+    _ObjectPath = Tuple[Optional[_Path], _Kind]
+    _PathToObjectMapping = Dict[_Path, 'Object']
+    _PathToSerializableMapping = Dict[_Path, _Serializable]
 
+_T = TypeVar("_T")
+_EventType = TypeVar('_EventType', bound='EventBase')
+_ObjectType = TypeVar("_ObjectType", bound="Object")
 
 logger = logging.getLogger(__name__)
 
@@ -80,11 +115,11 @@ class Handle:
     under the same parent and kind may have the same key.
     """
 
-    def __init__(self, parent: typing.Optional["_ParentHandle"], kind: str, key: str):
+    def __init__(self, parent: Optional[Union['Handle', 'Object']], kind: str, key: str):
         if isinstance(parent, Object):
             # if it's not an Object, it will be either a Handle (good) or None (no parent)
             parent = parent.handle
-        self._parent = parent
+        self._parent = parent  # type: Optional[Handle]
         self._kind = kind
         self._key = key
         if parent:
@@ -98,31 +133,31 @@ def __init__(self, parent: typing.Optional["_ParentHandle"], kind: str, key: str
             else:
                 self._path = "{}".format(kind)
 
-    def nest(self, kind: str, key: str):
+    def nest(self, kind: str, key: str) -> 'Handle':
         """Create a new handle as child of the current one."""
         return Handle(self, kind, key)
 
     def __hash__(self):
         return hash((self.parent, self.kind, self.key))
 
-    def __eq__(self, other):
+    def __eq__(self, other: 'Handle'):
         return (self.parent, self.kind, self.key) == (other.parent, other.kind, other.key)
 
     def __str__(self):
         return self.path
 
     @property
-    def parent(self):
+    def parent(self) -> Optional['Handle']:
         """Return own parent handle."""
         return self._parent
 
     @property
-    def kind(self):
+    def kind(self) -> str:
         """Return the handle's kind."""
         return self._kind
 
     @property
-    def key(self):
+    def key(self) -> str:
         """Return the handle's key."""
         return self._key
 
@@ -132,7 +167,7 @@ def path(self):
         return self._path
 
     @classmethod
-    def from_path(cls, path):
+    def from_path(cls, path: str) -> 'Handle':
         """Build a handle from the indicated path."""
         handle = None
         for pair in path.split("/"):
@@ -148,7 +183,7 @@ def from_path(cls, path):
                     good = True
             if not good:
                 raise RuntimeError("attempted to restore invalid handle path {}".format(path))
-            handle = Handle(handle, kind, key)  # noqa
+            handle = Handle(handle, kind, key)  # pyright: reportUnboundVariable=false
         return handle
 
 
@@ -224,14 +259,14 @@ def defer(self):
         logger.debug("Deferring %s.", self)
         self.deferred = True
 
-    def snapshot(self) -> dict:
+    def snapshot(self) -> '_SerializedData':
         """Return the snapshot data that should be persisted.
 
         Subclasses must override to save any custom state.
         """
-        return None
+        return {}
 
-    def restore(self, snapshot):
+    def restore(self, snapshot: '_SerializedData'):
         """Restore the value state from the given snapshot.
 
         Subclasses must override to restore their custom state.
@@ -239,7 +274,7 @@ def restore(self, snapshot):
         self.deferred = False
 
 
-class EventSource:
+class EventSource(Generic[_EventType]):
     """EventSource wraps an event type with a descriptor to facilitate observing and emitting.
 
     It is generally used as:
@@ -254,20 +289,21 @@ class SomeObject(Object):
     attribute which is a BoundEvent and may be used to emit and observe the event.
     """
 
-    def __init__(self, event_type):
+    def __init__(self, event_type: 'Type[_EventType]'):
         if not isinstance(event_type, type) or not issubclass(event_type, EventBase):
             raise RuntimeError(
                 'Event requires a subclass of EventBase as an argument, got {}'.format(event_type))
-        self.event_type = event_type
-        self.event_kind = None
-        self.emitter_type = None
+        self.event_type = event_type  # type: Type[_EventType]
+        self.event_kind = None  # type: Optional[str]  # noqa
+        self.emitter_type = None  # type: Optional[Type[Object]]  # noqa
 
-    def _set_name(self, emitter_type, event_kind):
+    def _set_name(self, emitter_type: 'Type[Object]', event_kind: str):
         if self.event_kind is not None:
             raise RuntimeError(
                 'EventSource({}) reused as {}.{} and {}.{}'.format(
                     self.event_type.__name__,
-                    self.emitter_type.__name__,
+                    # emitter_type could still be None
+                    getattr(self.emitter_type, '__name__', self.emitter_type),
                     self.event_kind,
                     emitter_type.__name__,
                     event_kind,
@@ -275,9 +311,11 @@ def _set_name(self, emitter_type, event_kind):
         self.event_kind = event_kind
         self.emitter_type = emitter_type
 
-    def __get__(self, emitter, emitter_type=None):
+    def __get__(self, emitter: Optional['Object'],
+                emitter_type: 'Type[Object]'
+                ) -> 'BoundEvent[_EventType]':
         if emitter is None:
-            return self
+            return self  # type: ignore
         # Framework might not be available if accessed as CharmClass.on.event
         # rather than charm_instance.on.event, but in that case it couldn't be
         # emitted anyway, so there's no point to registering it.
@@ -287,7 +325,7 @@ def __get__(self, emitter, emitter_type=None):
         return BoundEvent(emitter, self.event_type, self.event_kind)
 
 
-class BoundEvent:
+class BoundEvent(Generic[_EventType]):
     """Event bound to an Object."""
 
     def __repr__(self):
@@ -298,22 +336,23 @@ def __repr__(self):
             hex(id(self)),
         )
 
-    def __init__(self, emitter: "_ObjectType",
-                 event_type: "_EventType", event_kind: str):
+    def __init__(self, emitter: 'Object',
+                 event_type: 'Type[EventBase]',
+                 event_kind: str):
         self.emitter = emitter
         self.event_type = event_type
         self.event_kind = event_kind
 
-    def emit(self, *args, **kwargs):
+    def emit(self, *args: Any, **kwargs: Any):
         """Emit event to all registered observers.
 
         The current storage state is committed before and after each observer is notified.
         """
         framework = self.emitter.framework
-        key = framework._next_event_key()
+        key = framework._next_event_key()  # noqa
         event = self.event_type(Handle(self.emitter, self.event_kind, key), *args, **kwargs)
         event.framework = framework
-        framework._emit(event)
+        framework._emit(event)  # noqa
 
 
 class HandleKind:
@@ -323,8 +362,8 @@ class HandleKind:
     be explicitly overridden if desired.
     """
 
-    def __get__(self, obj, obj_type):
-        kind = obj_type.__dict__.get("handle_kind")
+    def __get__(self, obj: 'Object', obj_type: 'Type[Object]') -> str:
+        kind = typing.cast(str, obj_type.__dict__.get("handle_kind"))
         if kind:
             return kind
         return obj_type.__name__
@@ -350,15 +389,15 @@ class SomeObject(Object):
 
     """
 
-    def __new__(cls, *a, **kw):
-        k = super().__new__(cls, *a, **kw)
+    def __new__(cls, *a, **kw):  # type: ignore
+        k = super().__new__(cls, *a, **kw)  # type: ignore
         # k is now the Object-derived class; loop over its class attributes
         for n, v in vars(k).items():
             # we could do duck typing here if we want to support
             # non-EventSource-derived shenanigans. We don't.
             if isinstance(v, EventSource):
                 # this is what 3.6+ does automatically for us:
-                v._set_name(k, n)
+                v._set_name(k, n)  # noqa
         return k
 
 
@@ -381,11 +420,18 @@ class Object(metaclass=_Metaclass):
     been created.
 
     """
-    framework = None  # type: Framework
-    handle = None  # type: Handle
     handle_kind = HandleKind()  # type: str
 
-    def __init__(self, parent, key):
+    if TYPE_CHECKING:
+        # to help the type checker and IDEs:
+        # all these are guaranteed to be set at runtime.
+        @property
+        def on(self) -> 'ObjectEvents': ...  # noqa
+
+    def __init__(self, parent: Union['Framework', 'Object'], key: Optional[str]):
+        self.framework = None  # type: Framework # noqa
+        self.handle = None  # type: Handle # noqa
+
         kind = self.handle_kind
         if isinstance(parent, Framework):
             self.framework = parent
@@ -396,12 +442,12 @@ def __init__(self, parent, key):
         else:
             self.framework = parent.framework
             self.handle = Handle(parent, kind, key)
-        self.framework._track(self)
+        self.framework._track(self)  # noqa
 
         # TODO Detect conflicting handles here.
 
     @property
-    def model(self) -> "Model":
+    def model(self) -> 'Model':
         """Shortcut for more simple access the model."""
         return self.framework.model
 
@@ -411,13 +457,12 @@ class ObjectEvents(Object):
 
     handle_kind = "on"
 
-    def __init__(self, parent=None, key=None):
+    def __init__(self, parent: Optional[Object] = None, key: Optional[str] = None):
         if parent is not None:
             super().__init__(parent, key)
-        else:
-            self._cache = weakref.WeakKeyDictionary()
+        self._cache = weakref.WeakKeyDictionary()  # type: weakref.WeakKeyDictionary[Object, 'ObjectEvents']  # noqa
 
-    def __get__(self, emitter, emitter_type):
+    def __get__(self, emitter: Object, emitter_type: 'Type[Object]'):
         if emitter is None:
             return self
         instance = self._cache.get(emitter)
@@ -428,7 +473,7 @@ def __get__(self, emitter, emitter_type):
         return instance
 
     @classmethod
-    def define_event(cls, event_kind, event_type):
+    def define_event(cls, event_kind: str, event_type: 'Type[EventBase]'):
         """Define an event on this type at runtime.
 
         cls: a type to define an event on.
@@ -439,6 +484,15 @@ def define_event(cls, event_kind, event_type):
 
         event_type: a type of the event to define.
 
+        Note that attempting to define the same event kind more than once will
+        raise a 'overlaps with existing type' runtime error. Ops uses a
+        labeling system to track and reconstruct events between hook executions
+        (each time a hook runs, the Juju Agent invokes a fresh instance of ops;
+        there is no ops process that persists on the host between hooks).
+        Having duplicate Python objects creates duplicate labels. Overwriting a
+        previously created label means that only the latter code path will be
+        run when the current event, if it does get deferred, is reemitted. This
+        is usually not what is desired, and is error-prone and ambigous.
         """
         prefix = 'unable to define an event with event_kind that '
         if not event_kind.isidentifier():
@@ -453,11 +507,11 @@ def define_event(cls, event_kind, event_type):
             pass
 
         event_descriptor = EventSource(event_type)
-        event_descriptor._set_name(cls, event_kind)
+        event_descriptor._set_name(cls, event_kind)  # noqa
         setattr(cls, event_kind, event_descriptor)
 
-    def _event_kinds(self):
-        event_kinds = []
+    def _event_kinds(self) -> List[str]:
+        event_kinds = []  # type: List[str]
         # We have to iterate over the class rather than instance to allow for properties which
         # might call this method (e.g., event views), leading to infinite recursion.
         for attr_name, attr_value in inspect.getmembers(type(self)):
@@ -467,11 +521,11 @@ def _event_kinds(self):
                 event_kinds.append(attr_name)
         return event_kinds
 
-    def events(self):
+    def events(self) -> Dict[str, EventSource[EventBase]]:
         """Return a mapping of event_kinds to bound_events for all available events."""
         return {event_kind: getattr(self, event_kind) for event_kind in self._event_kinds()}
 
-    def __getitem__(self, key):
+    def __getitem__(self, key: str) -> 'PrefixedEvents':
         return PrefixedEvents(self, key)
 
     def __repr__(self):
@@ -483,11 +537,11 @@ def __repr__(self):
 class PrefixedEvents:
     """Events to be found in all events using a specific prefix."""
 
-    def __init__(self, emitter, key):
+    def __init__(self, emitter: Object, key: str):
         self._emitter = emitter
         self._prefix = key.replace("-", "_") + '_'
 
-    def __getattr__(self, name):
+    def __getattr__(self, name: str) -> BoundEvent[Any]:
         return getattr(self._emitter, self._prefix + name)
 
 
@@ -508,7 +562,7 @@ class FrameworkEvents(ObjectEvents):
 class NoTypeError(Exception):
     """No class to hold it was found when restoring an event."""
 
-    def __init__(self, handle_path):
+    def __init__(self, handle_path: str):
         self.handle_path = handle_path
 
     def __str__(self):
@@ -533,32 +587,43 @@ class Framework(Object):
     on = FrameworkEvents()
 
     # Override properties from Object so that we can set them in __init__.
-    model = None  # type: 'Model'
-    meta = None  # type: 'CharmMeta'
-    charm_dir = None  # type: 'Path'
+    model = None  # type: 'Model' # pyright: reportGeneralTypeIssues=false
+    meta = None  # type: 'CharmMeta' # pyright: reportGeneralTypeIssues=false
+    charm_dir = None  # type: Path # pyright: reportGeneralTypeIssues=false
 
-    if typing.TYPE_CHECKING:
-        # to help the type checker and IDEs:
+    # to help the type checker and IDEs:
+
+    if TYPE_CHECKING:
         _stored = None  # type: 'StoredStateData'
+        @property
+        def on(self) -> 'FrameworkEvents': ...  # noqa
 
-    def __init__(self, storage: SQLiteStorage, charm_dir: "Path",
-                 meta: "CharmMeta", model: "Model"):
+    def __init__(self, storage: Union[SQLiteStorage, JujuStorage],
+                 charm_dir: Union[str, pathlib.Path],
+                 meta: 'CharmMeta', model: 'Model'):
         super().__init__(self, None)
 
-        self.charm_dir = charm_dir
+        # an old, deprecated __init__ interface accepted an Optional charm_dir,
+        #  so we have to keep supporting it:
+        if charm_dir is None:
+            logger.warning('Framework should not be initialized with `charm_dir` set to None.')
+            self.charm_dir = None  # type: ignore
+        else:
+            self.charm_dir = pathlib.Path(charm_dir)
+
         self.meta = meta
         self.model = model
         # [(observer_path, method_name, parent_path, event_key)]
         self._observers = []  # type: _ObserverPath
-        # {observer_path: observer}
-        self._observer = weakref.WeakValueDictionary()  # type: _PathToObserverMapping
+        # {observer_path: observing Object}
+        self._observer = weakref.WeakValueDictionary()  # type: _PathToObjectMapping  # noqa
         # {object_path: object}
-        self._objects = weakref.WeakValueDictionary()  # type: _PathToObjectMapping
+        self._objects = weakref.WeakValueDictionary()  # type: _PathToSerializableMapping  # noqa
         # {(parent_path, kind): cls}
         # (parent_path, kind) is the address of _this_ object: the parent path
         # plus a 'kind' string that is the name of this object.
-        self._type_registry = {}  # type: typing.Dict[_ObjectPath, 'Type']
-        self._type_known = set()  # type: typing.Set['Type']
+        self._type_registry = {}  # type: Dict[_ObjectPath, Type[_Serializable]]
+        self._type_known = set()  # type: Set[Type[_Serializable]]
 
         if isinstance(storage, (str, pathlib.Path)):
             logger.warning(
@@ -570,9 +635,9 @@ def __init__(self, storage: SQLiteStorage, charm_dir: "Path",
         self.register_type(StoredStateData, None, StoredStateData.handle_kind)
         stored_handle = Handle(None, StoredStateData.handle_kind, '_stored')
         try:
-            self._stored = self.load_snapshot(stored_handle)
+            self._stored = typing.cast(StoredStateData, self.load_snapshot(stored_handle))  # noqa
         except NoSnapshotError:
-            self._stored = StoredStateData(self, '_stored')
+            self._stored = StoredStateData(self, '_stored')  # noqa
             self._stored['event_count'] = 0
 
         # Flag to indicate that we already presented the welcome message in a debugger breakpoint
@@ -581,7 +646,7 @@ def __init__(self, storage: SQLiteStorage, charm_dir: "Path",
         # Parse the env var once, which may be used multiple times later
         debug_at = os.environ.get('JUJU_DEBUG_AT')
         self._juju_debug_at = (set(x.strip() for x in debug_at.split(','))
-                               if debug_at else set())  # type: typing.Set[str]
+                               if debug_at else set())  # type: Set[str]
 
     def set_breakpointhook(self):
         """Hook into sys.breakpointhook so the builtin breakpoint() works as expected.
@@ -608,7 +673,7 @@ def close(self):
         """Close the underlying backends."""
         self._storage.close()
 
-    def _track(self, obj):
+    def _track(self, obj: '_Serializable'):
         """Track object and ensure it is the only object created using its handle path."""
         if obj is self:
             # Framework objects don't track themselves
@@ -618,7 +683,7 @@ def _track(self, obj):
                 'two objects claiming to be {} have been created'.format(obj.handle.path))
         self._objects[obj.handle.path] = obj
 
-    def _forget(self, obj):
+    def _forget(self, obj: '_Serializable'):
         """Stop tracking the given object. See also _track."""
         self._objects.pop(obj.handle.path, None)
 
@@ -633,20 +698,20 @@ def commit(self):
         self.save_snapshot(self._stored)
         self._storage.commit()
 
-    def register_type(self, cls, parent: typing.Optional["_ParentHandle"], kind=None):
+    def register_type(self, cls: 'Type[_Serializable]', parent: Optional['_ParentHandle'],
+                      kind: str = None):
         """Register a type to a handle."""
-        if parent is not None and not isinstance(parent, Handle):
-            parent = parent.handle
-        if parent:
+        parent_path = None  # type: Optional[str]
+        if isinstance(parent, Object):
+            parent_path = parent.handle.path
+        elif isinstance(parent, Handle):
             parent_path = parent.path
-        else:
-            parent_path = None
-        if not kind:
-            kind = cls.handle_kind
+
+        kind = kind or cls.handle_kind  # type: str
         self._type_registry[(parent_path, kind)] = cls
         self._type_known.add(cls)
 
-    def save_snapshot(self, value: "_Serializable"):
+    def save_snapshot(self, value: Union["StoredStateData", "EventBase"]):
         """Save a persistent snapshot of the provided value."""
         if type(value) not in self._type_known:
             raise RuntimeError(
@@ -666,12 +731,12 @@ def save_snapshot(self, value: "_Serializable"):
 
         self._storage.save_snapshot(value.handle.path, data)
 
-    def load_snapshot(self, handle: Handle) -> '_ObjectType':
+    def load_snapshot(self, handle: Handle) -> '_Serializable':
         """Load a persistent snapshot."""
         parent_path = None
         if handle.parent:
             parent_path = handle.parent.path
-        cls = self._type_registry.get((parent_path, handle.kind))
+        cls = self._type_registry.get((parent_path, handle.kind))  # type: Type[_Serializable]
         if not cls:
             raise NoTypeError(handle.path)
         data = self._storage.load_snapshot(handle.path)
@@ -686,8 +751,7 @@ def drop_snapshot(self, handle: Handle):
         """Discard a persistent snapshot."""
         self._storage.drop_snapshot(handle.path)
 
-    def observe(self, bound_event: BoundEvent,
-                observer: "_ObserverCallback"):
+    def observe(self, bound_event: BoundEvent[Any], observer: "_ObserverCallback"):
         """Register observer to be called when bound_event is emitted.
 
         The bound_event is generally provided as an attribute of the object that emits
@@ -738,26 +802,32 @@ class SomeObject:
         extra_params = list(sig.parameters.values())[1:]
 
         method_name = observer.__name__
-        observer = observer.__self__
+
+        assert isinstance(observer.__self__, Object), "can't register observers " \
+                                                      "that aren't `Object`s"
+        observer_obj = observer.__self__
         if not sig.parameters:
             raise TypeError(
-                '{}.{} must accept event parameter'.format(type(observer).__name__, method_name))
+                '{}.{} must accept event parameter'.format(
+                    type(observer_obj).__name__, method_name))
         elif any(param.default is inspect.Parameter.empty for param in extra_params):
             # Allow for additional optional params, since there's no reason to exclude them, but
             # required params will break.
             raise TypeError(
-                '{}.{} has extra required parameter'.format(type(observer).__name__, method_name))
+                '{}.{} has extra required parameter'.format(
+                    type(observer_obj).__name__, method_name))
 
         # TODO Prevent the exact same parameters from being registered more than once.
 
-        self._observer[observer.handle.path] = observer
-        self._observers.append((observer.handle.path, method_name, emitter_path, event_kind))
+        self._observer[observer_obj.handle.path] = observer_obj
+        self._observers.append((observer_obj.handle.path,
+                                method_name, emitter_path, event_kind))
 
-    def _next_event_key(self):
+    def _next_event_key(self) -> str:
         """Return the next event key that should be used, incrementing the internal counter."""
         # Increment the count first; this means the keys will start at 1, and 0
         # means no events have been emitted.
-        self._stored['event_count'] += 1
+        self._stored['event_count'] += 1  # type: ignore  #(we know event_count holds an int)
         return str(self._stored['event_count'])
 
     def _emit(self, event: EventBase):
@@ -765,9 +835,11 @@ def _emit(self, event: EventBase):
         saved = False
         event_path = event.handle.path
         event_kind = event.handle.kind
-        parent_path = event.handle.parent.path
+        parent = event.handle.parent
+        assert isinstance(parent, Handle), "event handle must have a parent"
+        parent_path = parent.path
         # TODO Track observers by (parent_path, event_kind) rather than as a list of
-        # all observers. Avoiding linear search through all observers for every event
+        #  all observers. Avoiding linear search through all observers for every event
         for observer_path, method_name, _parent_path, _event_kind in self._observers:
             if _parent_path != parent_path:
                 continue
@@ -793,32 +865,33 @@ def reemit(self):
         """
         self._reemit()
 
-    def _reemit(self, single_event_path=None):
-
-        class EventContext:
-            """Handles toggling the hook-is-running state in backends.
+    @contextmanager
+    def _event_context(self, event_name: str):
+        """Handles toggling the hook-is-running state in backends.
 
-            This allows e.g. harness logic to know if it is executing within a running hook context
-            or not.  It sets backend._hook_is_running equal to the name of the currently running
-            hook (e.g. "set-leader") and reverts back to the empty string when the hook execution
-            is completed.
-            """
+        This allows e.g. harness logic to know if it is executing within a running hook context
+        or not.  It sets backend._hook_is_running equal to the name of the currently running
+        hook (e.g. "set-leader") and reverts back to the empty string when the hook execution
+        is completed.
 
-            def __init__(self, framework, event_name):
-                self._event = event_name
-                self._backend = None
-                if framework.model is not None:
-                    self._backend = framework.model._backend
+        Usage:
+            >>> with harness._event_context('db-relation-changed'):
+            >>>     print('Harness thinks it is running an event hook.')
+            >>> with harness._event_context(''):
+            >>>     print('harness thinks it is not running an event hook.')
+        """
+        backend = self.model._backend if self.model else None  # type: Optional[_ModelBackend]
 
-            def __enter__(self):
-                if self._backend:
-                    self._backend._hook_is_running = self._event
-                return self
+        if not backend:
+            yield  # context does nothing in this case
+            return
 
-            def __exit__(self, exception_type, exception, traceback):
-                if self._backend:
-                    self._backend._hook_is_running = ''
+        old = backend._hook_is_running
+        backend._hook_is_running = event_name
+        yield
+        backend._hook_is_running = old
 
+    def _reemit(self, single_event_path: str = None):
         last_event_path = None
         deferred = True
         for event_path, observer_path, method_name in self._storage.notices(single_event_path):
@@ -836,6 +909,7 @@ def __exit__(self, exception_type, exception, traceback):
                 self._storage.drop_notice(event_path, observer_path, method_name)
                 continue
 
+            event = typing.cast(EventBase, event)
             event.deferred = False
             observer = self._observer.get(observer_path)
             if observer:
@@ -845,7 +919,7 @@ def __exit__(self, exception_type, exception, traceback):
                 if custom_handler:
                     event_is_from_juju = isinstance(event, charm.HookEvent)
                     event_is_action = isinstance(event, charm.ActionEvent)
-                    with EventContext(self, event_handle.kind):
+                    with self._event_context(event_handle.kind):
                         if (
                             event_is_from_juju or event_is_action
                         ) and self._juju_debug_at.intersection({'all', 'hook'}):
@@ -873,7 +947,7 @@ def _show_debug_code_message(self):
             self._breakpoint_welcomed = True
             print(_BREAKPOINT_WELCOME_MESSAGE, file=sys.stderr, end='')
 
-    def breakpoint(self, name=None):
+    def breakpoint(self, name: Optional[str] = None):
         """Add breakpoint, optionally named, at the place where this method is called.
 
         For the breakpoint to be activated the JUJU_DEBUG_AT environment variable
@@ -887,7 +961,7 @@ def breakpoint(self, name=None):
         """
         # If given, validate the name comply with all the rules
         if name is not None:
-            if not isinstance(name, str):
+            if not isinstance(name, str):  # pyright: reportUnnecessaryIsInstance=false
                 raise TypeError('breakpoint names must be strings')
             if name in ('hook', 'all'):
                 raise ValueError('breakpoint names "all" and "hook" are reserved')
@@ -903,7 +977,7 @@ def breakpoint(self, name=None):
 
             # If we call set_trace() directly it will open the debugger *here*, so indicating
             # it to use our caller's frame
-            code_frame = inspect.currentframe().f_back
+            code_frame = inspect.currentframe().f_back  # type: ignore
             pdb.Pdb().set_trace(code_frame)
         else:
             logger.warning(
@@ -918,7 +992,7 @@ def remove_unreferenced_events(self):
         database.
         """
         event_regex = re.compile(_event_regex)
-        to_remove = []
+        to_remove = []  # type: List[str]
         for handle_path in self._storage.list_snapshots():
             if event_regex.match(handle_path):
                 notices = self._storage.notices(handle_path)
@@ -932,31 +1006,31 @@ def remove_unreferenced_events(self):
 class StoredStateData(Object):
     """Manager of the stored data."""
 
-    def __init__(self, parent, attr_name):
+    def __init__(self, parent: Object, attr_name: str):
         super().__init__(parent, attr_name)
-        self._cache = {}
-        self.dirty = False
+        self._cache = {}  # type: Dict[str, '_StorableType']
+        self.dirty = False  # type: bool
 
-    def __getitem__(self, key):
+    def __getitem__(self, key: str) -> '_StorableType':
         return self._cache.get(key)
 
-    def __setitem__(self, key, value):
+    def __setitem__(self, key: str, value: '_StorableType'):
         self._cache[key] = value
         self.dirty = True
 
-    def __contains__(self, key):
+    def __contains__(self, key: str):
         return key in self._cache
 
-    def snapshot(self):
+    def snapshot(self) -> Dict[str, '_StorableType']:
         """Return the current state."""
         return self._cache
 
-    def restore(self, snapshot):
+    def restore(self, snapshot: Dict[str, '_StorableType']):
         """Restore current state to the given snapshot."""
         self._cache = snapshot
         self.dirty = False
 
-    def on_commit(self, event):
+    def on_commit(self, event: EventBase) -> None:
         """Save changes to the storage backend."""
         if self.dirty:
             self.framework.save_snapshot(self)
@@ -965,8 +1039,17 @@ def on_commit(self, event):
 
 class BoundStoredState:
     """Stored state data bound to a specific Object."""
+    if TYPE_CHECKING:
+        # to help the type checker and IDEs:
+        @property
+        def _data(self) -> StoredStateData:  # noqa
+            pass  # pyright: reportGeneralTypeIssues=false
 
-    def __init__(self, parent, attr_name):
+        @property  # noqa
+        def _attr_name(self) -> str:  # noqa
+            pass  # pyright: reportGeneralTypeIssues=false
+
+    def __init__(self, parent: Object, attr_name: str):
         parent.framework.register_type(StoredStateData, parent)
 
         handle = Handle(parent, StoredStateData.handle_kind, attr_name)
@@ -979,30 +1062,35 @@ def __init__(self, parent, attr_name):
         self.__dict__["_data"] = data
         self.__dict__["_attr_name"] = attr_name
 
-        parent.framework.observe(parent.framework.on.commit, self._data.on_commit)
+        parent.framework.observe(parent.framework.on.commit, self._data.on_commit)  # type: ignore
+
+    if TYPE_CHECKING:
+        @typing.overload
+        def __getattr__(self, key: Literal['on']) -> ObjectEvents:
+            pass
 
-    def __getattr__(self, key):
+    def __getattr__(self, key: str) -> Union['_StorableType', 'StoredObject', ObjectEvents]:
         # "on" is the only reserved key that can't be used in the data map.
         if key == "on":
-            return self._data.on
+            return self._data.on  # type: ignore  # casting won't work for some reason
         if key not in self._data:
             raise AttributeError("attribute '{}' is not stored".format(key))
         return _wrap_stored(self._data, self._data[key])
 
-    def __setattr__(self, key, value):
+    def __setattr__(self, key: str, value: Union['_StorableType', '_StoredObject']):
         if key == "on":
             raise AttributeError("attribute 'on' is reserved and cannot be set")
 
-        value = _unwrap_stored(self._data, value)
+        unwrapped = _unwrap_stored(self._data, value)
 
-        if not isinstance(value, (type(None), int, float, str, bytes, list, dict, set)):
+        if not isinstance(unwrapped, (type(None), int, float, str, bytes, list, dict, set)):
             raise AttributeError(
                 'attribute {!r} cannot be a {}: must be int/float/dict/list/etc'.format(
-                    key, type(value).__name__))
+                    key, type(unwrapped).__name__))
 
-        self._data[key] = _unwrap_stored(self._data, value)
+        self._data[key] = unwrapped
 
-    def set_default(self, **kwargs):
+    def set_default(self, **kwargs: '_StorableType'):
         """Set the value of any given key if it has not already been set."""
         for k, v in kwargs.items():
             if k not in self._data:
@@ -1035,13 +1123,31 @@ def _on_seen(self, event):
     """
 
     def __init__(self):
-        self.parent_type = None
-        self.attr_name = None
+        self.parent_type = None  # type: Optional[Type[Any]]
+        self.attr_name = None  # type: Optional[str]
+
+    if TYPE_CHECKING:
+        @typing.overload
+        def __get__(
+                self,
+                parent: Literal[None],
+                parent_type: 'Type[_ObjectType]') -> 'StoredState':
+            pass
 
-    def __get__(self, parent, parent_type=None):
+        @typing.overload
+        def __get__(
+                self,
+                parent: '_ObjectType',
+                parent_type: 'Type[_ObjectType]') -> BoundStoredState:
+            pass
+
+    def __get__(self,
+                parent: '_ObjectType',
+                parent_type: 'Type[_ObjectType]') -> Union['StoredState',
+                                                           BoundStoredState]:
         if self.parent_type is not None and self.parent_type not in parent_type.mro():
             # the StoredState instance is being shared between two unrelated classes
-            # -> unclear what is exepcted of us -> bail out
+            # -> unclear what is expected of us -> bail out
             raise RuntimeError(
                 'StoredState shared by {} and {}'.format(
                     self.parent_type.__name__, parent_type.__name__))
@@ -1076,6 +1182,10 @@ def __get__(self, parent, parent_type=None):
         if bound is not None:
             # cache the bound object to avoid the expensive lookup the next time
             # (don't use setattr, to keep things symmetric with the fast-path lookup above)
+
+            # attr_name is optional at descriptor level, but we're bound now: it's
+            # guaranteed to be a string. We need to help the type checker:
+            assert isinstance(self.attr_name, str)
             parent.__dict__[self.attr_name] = bound
             return bound
 
@@ -1084,47 +1194,49 @@ def __get__(self, parent, parent_type=None):
                 self.__class__.__name__, parent_type.__name__))
 
 
-def _wrap_stored(parent_data, value):
-    t = type(value)
-    if t is dict:
+def _wrap_stored(parent_data: StoredStateData, value: '_StorableType'
+                 ) -> Union['StoredDict', 'StoredList', 'StoredSet', '_StorableType']:
+    if isinstance(value, dict):
         return StoredDict(parent_data, value)
-    if t is list:
+    if isinstance(value, list):
         return StoredList(parent_data, value)
-    if t is set:
+    if isinstance(value, set):
         return StoredSet(parent_data, value)
     return value
 
 
-def _unwrap_stored(parent_data, value):
-    t = type(value)
-    if t is StoredDict or t is StoredList or t is StoredSet:
-        return value._under
-    return value
+def _unwrap_stored(parent_data: StoredStateData,
+                   value: Union['_StoredObject', '_StorableType']
+                   ) -> '_StorableType':
+    if isinstance(value, (StoredDict, StoredList, StoredSet)):
+        return value._under  # pyright: reportPrivateUsage=false
+    return typing.cast('_StorableType', value)
 
 
-def _wrapped_repr(obj):
+def _wrapped_repr(obj: '_StoredObject') -> str:
     t = type(obj)
-    if obj._under:
-        return "{}.{}({!r})".format(t.__module__, t.__name__, obj._under)
+    if obj._under:  # pyright: reportPrivateUsage=false  # noqa
+        return "{}.{}({!r})".format(
+            t.__module__, t.__name__, obj._under)  # type: ignore # noqa
     else:
         return "{}.{}()".format(t.__module__, t.__name__)
 
 
-class StoredDict(collections.abc.MutableMapping):
+class StoredDict(typing.MutableMapping[Hashable, '_StorableType']):
     """A dict-like object that uses the StoredState as backend."""
 
-    def __init__(self, stored_data, under):
+    def __init__(self, stored_data: StoredStateData, under: Dict[Any, Any]):
         self._stored_data = stored_data
         self._under = under
 
-    def __getitem__(self, key):
+    def __getitem__(self, key: Hashable):
         return _wrap_stored(self._stored_data, self._under[key])
 
-    def __setitem__(self, key, value):
+    def __setitem__(self, key: Hashable, value: Any):
         self._under[key] = _unwrap_stored(self._stored_data, value)
         self._stored_data.dirty = True
 
-    def __delitem__(self, key):
+    def __delitem__(self, key: Hashable):
         del self._under[key]
         self._stored_data.dirty = True
 
@@ -1134,7 +1246,7 @@ def __iter__(self):
     def __len__(self):
         return len(self._under)
 
-    def __eq__(self, other):
+    def __eq__(self, other: Any):
         if isinstance(other, StoredDict):
             return self._under == other._under
         elif isinstance(other, collections.abc.Mapping):
@@ -1142,91 +1254,91 @@ def __eq__(self, other):
         else:
             return NotImplemented
 
-    __repr__ = _wrapped_repr
+    __repr__ = _wrapped_repr  # type: ignore
 
 
-class StoredList(collections.abc.MutableSequence):
+class StoredList(typing.MutableSequence['_StorableType']):
     """A list-like object that uses the StoredState as backend."""
 
-    def __init__(self, stored_data, under):
+    def __init__(self, stored_data: StoredStateData, under: List[Any]):
         self._stored_data = stored_data
         self._under = under
 
-    def __getitem__(self, index):
+    def __getitem__(self, index: int):
         return _wrap_stored(self._stored_data, self._under[index])
 
-    def __setitem__(self, index, value):
+    def __setitem__(self, index: int, value: Any):
         self._under[index] = _unwrap_stored(self._stored_data, value)
         self._stored_data.dirty = True
 
-    def __delitem__(self, index):
+    def __delitem__(self, index: int):
         del self._under[index]
         self._stored_data.dirty = True
 
     def __len__(self):
         return len(self._under)
 
-    def insert(self, index, value):
+    def insert(self, index: int, value: Any):
         """Insert value before index."""
         self._under.insert(index, value)
         self._stored_data.dirty = True
 
-    def append(self, value):
+    def append(self, value: Any):
         """Append value to the end of the list."""
         self._under.append(value)
         self._stored_data.dirty = True
 
-    def __eq__(self, other):
+    def __eq__(self, other: Any):
         if isinstance(other, StoredList):
             return self._under == other._under
-        elif isinstance(other, collections.abc.Sequence):
+        elif isinstance(other, list):
             return self._under == other
         else:
             return NotImplemented
 
-    def __lt__(self, other):
+    def __lt__(self, other: Any):
         if isinstance(other, StoredList):
             return self._under < other._under
-        elif isinstance(other, collections.abc.Sequence):
+        elif isinstance(other, list):
             return self._under < other
         else:
             return NotImplemented
 
-    def __le__(self, other):
+    def __le__(self, other: Any):
         if isinstance(other, StoredList):
             return self._under <= other._under
-        elif isinstance(other, collections.abc.Sequence):
+        elif isinstance(other, list):
             return self._under <= other
         else:
             return NotImplemented
 
-    def __gt__(self, other):
+    def __gt__(self, other: Any):
         if isinstance(other, StoredList):
             return self._under > other._under
-        elif isinstance(other, collections.abc.Sequence):
+        elif isinstance(other, list):
             return self._under > other
         else:
             return NotImplemented
 
-    def __ge__(self, other):
+    def __ge__(self, other: Any):
         if isinstance(other, StoredList):
             return self._under >= other._under
-        elif isinstance(other, collections.abc.Sequence):
+        elif isinstance(other, list):
             return self._under >= other
         else:
             return NotImplemented
 
-    __repr__ = _wrapped_repr
+    __repr__ = _wrapped_repr  # type: ignore
 
 
-class StoredSet(collections.abc.MutableSet):
+class StoredSet(typing.MutableSet['_StorableType']):
     """A set-like object that uses the StoredState as backend."""
 
-    def __init__(self, stored_data, under):
+    def __init__(self, stored_data: StoredStateData, under: Set[Any]):
         self._stored_data = stored_data
         self._under = under
 
-    def add(self, key):
+    def add(self, key: Any):
         """Add a key to a set.
 
         This has no effect if the key is already present.
@@ -1234,7 +1346,7 @@ def add(self, key):
         self._under.add(key)
         self._stored_data.dirty = True
 
-    def discard(self, key):
+    def discard(self, key: Any):
         """Remove a key from a set if it is a member.
 
         If the key is not a member, do nothing.
@@ -1242,7 +1354,7 @@ def discard(self, key):
         self._under.discard(key)
         self._stored_data.dirty = True
 
-    def __contains__(self, key):
+    def __contains__(self, key: Any):
         return key in self._under
 
     def __iter__(self):
@@ -1252,7 +1364,7 @@ def __len__(self):
         return len(self._under)
 
     @classmethod
-    def _from_iterable(cls, it):
+    def _from_iterable(cls, it: Iterable[_T]) -> Set[_T]:
         """Construct an instance of the class from any iterable input.
 
         Per https://docs.python.org/3/library/collections.abc.html
@@ -1262,28 +1374,28 @@ def _from_iterable(cls, it):
         """
         return set(it)
 
-    def __le__(self, other):
+    def __le__(self, other: Any):
         if isinstance(other, StoredSet):
             return self._under <= other._under
-        elif isinstance(other, collections.abc.Set):
+        elif isinstance(other, collections.abc.Set):  # pyright: reportUnnecessaryIsInstance=false  # noqa
             return self._under <= other
         else:
             return NotImplemented
 
-    def __ge__(self, other):
+    def __ge__(self, other: Any):
         if isinstance(other, StoredSet):
             return self._under >= other._under
-        elif isinstance(other, collections.abc.Set):
+        elif isinstance(other, collections.abc.Set):  # pyright: reportUnnecessaryIsInstance=false  # noqa
             return self._under >= other
         else:
             return NotImplemented
 
-    def __eq__(self, other):
+    def __eq__(self, other: Any):
         if isinstance(other, StoredSet):
             return self._under == other._under
-        elif isinstance(other, collections.abc.Set):
+        elif isinstance(other, collections.abc.Set):  # pyright: reportUnnecessaryIsInstance=false  # noqa
             return self._under == other
         else:
             return NotImplemented
 
-    __repr__ = _wrapped_repr
+    __repr__ = _wrapped_repr  # type: ignore
diff --git a/ops/jujuversion.py b/ops/jujuversion.py
index e1f83d3a3..7ee7f45b3 100755
--- a/ops/jujuversion.py
+++ b/ops/jujuversion.py
@@ -112,3 +112,8 @@ def is_dispatch_aware(self) -> bool:
     def has_controller_storage(self) -> bool:
         """Determine whether this juju version supports controller-side storage."""
         return (self.major, self.minor, self.patch) >= (2, 8, 0)
+
+    @property
+    def has_secrets(self) -> bool:
+        """Determine whether this Juju version supports the `secrets` feature."""
+        return (self.major, self.minor, self.patch) >= (3, 0, 2)
diff --git a/ops/main.py b/ops/main.py
index 691f7b324..1fe895669 100755
--- a/ops/main.py
+++ b/ops/main.py
@@ -20,26 +20,30 @@
 import shutil
 import subprocess
 import sys
-import typing
 import warnings
 from pathlib import Path
-
-import yaml
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Type, Union, cast
 
 import ops.charm
 import ops.framework
 import ops.model
 import ops.storage
+from ops.charm import CharmMeta
 from ops.jujuversion import JujuVersion
 from ops.log import setup_root_logging
 
+if TYPE_CHECKING:
+    from ops.charm import CharmBase
+    from ops.framework import BoundEvent, EventSource
+    from ops.model import Relation
+
 CHARM_STATE_FILE = '.unit-state.db'
 
 
 logger = logging.getLogger()
 
 
-def _exe_path(path: Path) -> typing.Optional[Path]:
+def _exe_path(path: Path) -> Optional[Path]:
     """Find and return the full path to the given binary.
 
     Here path is the absolute path to a binary, but might be missing an extension.
@@ -60,7 +64,8 @@ def _get_charm_dir():
     return charm_dir
 
 
-def _create_event_link(charm, bound_event, link_to):
+def _create_event_link(charm: 'CharmBase', bound_event: 'EventSource[Any]',
+                       link_to: Union[str, Path]):
     """Create a symlink for a particular event.
 
     Args:
@@ -68,6 +73,9 @@ def _create_event_link(charm, bound_event, link_to):
         bound_event: An event for which to create a symlink.
         link_to: What the event link should point to
     """
+    # type guard
+    assert bound_event.event_kind, "unbound EventSource {}".format(bound_event)
+
     if issubclass(bound_event.event_type, ops.charm.HookEvent):
         event_dir = charm.framework.charm_dir / 'hooks'
         event_path = event_dir / bound_event.event_kind.replace('_', '-')
@@ -94,7 +102,7 @@ def _create_event_link(charm, bound_event, link_to):
         event_path.symlink_to(target_path)
 
 
-def _setup_event_links(charm_dir, charm):
+def _setup_event_links(charm_dir: Path, charm: 'CharmBase'):
     """Set up links for supported events that originate from Juju.
 
     Whether a charm can handle an event or not can be determined by
@@ -121,7 +129,7 @@ def _setup_event_links(charm_dir, charm):
             _create_event_link(charm, bound_event, link_to)
 
 
-def _emit_charm_event(charm, event_name):
+def _emit_charm_event(charm: 'CharmBase', event_name: str):
     """Emits a charm event based on a Juju event name.
 
     Args:
@@ -142,7 +150,8 @@ def _emit_charm_event(charm, event_name):
         event_to_emit.emit(*args, **kwargs)
 
 
-def _get_event_args(charm, bound_event):
+def _get_event_args(charm: 'CharmBase',
+                    bound_event: 'BoundEvent[Any]') -> Tuple[List[Any], Dict[str, Any]]:
     event_type = bound_event.event_type
     model = charm.framework.model
 
@@ -161,18 +170,19 @@ def _get_event_args(charm, bound_event):
             storage_name = "-".join(bound_event.event_kind.split("_")[:-2])
 
         storages = model.storages[storage_name]
-        id, storage_location = model._backend._storage_event_details()
+        index, storage_location = model._backend._storage_event_details()
         if len(storages) == 1:
             storage = storages[0]
         else:
             # If there's more than one value, pick the right one. We'll realize the key on lookup
-            storage = next((s for s in storages if s.id == id), None)
-        storage.location = storage_location
+            storage = next((s for s in storages if s.index == index), None)
+        storage = cast(Union[ops.storage.JujuStorage, ops.storage.SQLiteStorage], storage)
+        storage.location = storage_location  # type: ignore
         return [storage], {}
     elif issubclass(event_type, ops.charm.RelationEvent):
         relation_name = os.environ['JUJU_RELATION']
         relation_id = int(os.environ['JUJU_RELATION_ID'].split(':')[-1])
-        relation = model.get_relation(relation_name, relation_id)
+        relation = model.get_relation(relation_name, relation_id)  # type: Optional[Relation]
 
     remote_app_name = os.environ.get('JUJU_REMOTE_APP', '')
     remote_unit_name = os.environ.get('JUJU_REMOTE_UNIT', '')
@@ -183,7 +193,7 @@ def _get_event_args(charm, bound_event):
             raise RuntimeError('invalid remote unit name: {}'.format(remote_unit_name))
         remote_app_name = remote_unit_name.split('/')[0]
 
-    kwargs = {}
+    kwargs = {}  # type: Dict[str, Any]
     if remote_app_name:
         kwargs['app'] = model.get_app(remote_app_name)
     if remote_unit_name:
@@ -193,7 +203,7 @@ def _get_event_args(charm, bound_event):
 
     if relation:
         return [relation], kwargs
-    return [], {}
+    return [], kwargs
 
 
 class _Dispatcher:
@@ -222,7 +232,7 @@ def __init__(self, charm_dir: Path):
         else:
             self._init_legacy()
 
-    def ensure_event_links(self, charm):
+    def ensure_event_links(self, charm: 'CharmBase'):
         """Make sure necessary symlinks are present on disk."""
         if self.is_dispatch_aware:
             # links aren't needed
@@ -251,7 +261,6 @@ def run_any_legacy_hook(self):
 
         dispatch_path = _exe_path(self._charm_dir / self._dispatch_path)
         if dispatch_path is None:
-            logger.debug("Legacy %s does not exist.", self._dispatch_path)
             return
 
         # super strange that there isn't an is_executable
@@ -322,11 +331,10 @@ def is_restricted_context(self):
         return self.event_name in ('collect_metrics',)
 
 
-def _should_use_controller_storage(db_path: Path, meta: ops.charm.CharmMeta) -> bool:
+def _should_use_controller_storage(db_path: Path, meta: CharmMeta) -> bool:
     """Figure out whether we want to use controller storage or not."""
     # if you've previously used local state, carry on using that
     if db_path.exists():
-        logger.debug("Using local storage: %s already exists", db_path)
         return False
 
     # if you're not in k8s you don't need controller storage
@@ -345,7 +353,8 @@ def _should_use_controller_storage(db_path: Path, meta: ops.charm.CharmMeta) ->
         return False
 
 
-def main(charm_class: typing.Type[ops.charm.CharmBase], use_juju_for_storage: bool = None):
+def main(charm_class: Type[ops.charm.CharmBase],
+         use_juju_for_storage: Optional[bool] = None):
     """Setup the charm and dispatch the observed event.
 
     The event name is based on the way this executable was called (argv[0]).
@@ -359,10 +368,10 @@ def main(charm_class: typing.Type[ops.charm.CharmBase], use_juju_for_storage: bo
     """
     charm_dir = _get_charm_dir()
 
-    model_backend = ops.model._ModelBackend()
+    model_backend = ops.model._ModelBackend()  # pyright: reportPrivateUsage=false
     debug = ('JUJU_DEBUG' in os.environ)
     setup_root_logging(model_backend, debug=debug)
-    logger.debug("Operator Framework %s up and running.", ops.__version__)
+    logger.debug("Operator Framework %s up and running.", ops.__version__)  # type:ignore
 
     dispatcher = _Dispatcher(charm_dir)
     dispatcher.run_any_legacy_hook()
@@ -374,9 +383,7 @@ def main(charm_class: typing.Type[ops.charm.CharmBase], use_juju_for_storage: bo
     else:
         actions_metadata = None
 
-    if not yaml.__with_libyaml__:
-        logger.debug('yaml does not have libyaml extensions, using slower pure Python yaml loader')
-    meta = ops.charm.CharmMeta.from_yaml(metadata, actions_metadata)
+    meta = CharmMeta.from_yaml(metadata, actions_metadata)
     model = ops.model.Model(meta, model_backend)
 
     charm_state_path = charm_dir / CHARM_STATE_FILE
diff --git a/ops/model.py b/ops/model.py
index e30bbd231..9412ae989 100644
--- a/ops/model.py
+++ b/ops/model.py
@@ -61,11 +61,15 @@
         Client,
         ExecProcess,
         FileInfo,
+        LayerDict,
         Plan,
         ServiceInfo,
     )
     from typing_extensions import TypedDict
 
+    from ops.framework import _SerializedData
+    from ops.testing import _ConfigOption
+
     _StorageDictType = Dict[str, Optional[List['Storage']]]
     _BindingDictType = Dict[Union[str, 'Relation'], 'Binding']
     Numerical = Union[int, float]
@@ -77,14 +81,13 @@
                        Tuple['JsonObject', ...]]
 
     # a k8s spec is a mapping from names/"types" to json/yaml spec objects
-    _K8sSpec = Mapping[str, JsonObject]
+    # public since it is used in ops.testing
+    K8sSpec = Mapping[str, JsonObject]
 
     _StatusDict = TypedDict('_StatusDict', {'status': str, 'message': str})
 
     # the data structure we can use to initialize pebble layers with.
-    # todo: replace with pebble._LayerDict (a TypedDict) when pebble.py is typed
-    _LayerDict = Dict[str, '_LayerDict']
-    _Layer = Union[str, _LayerDict, pebble.Layer]
+    _Layer = Union[str, LayerDict, pebble.Layer]
 
     # mapping from relation name to a list of relation objects
     _RelationMapping_Raw = Dict[str, Optional[List['Relation']]]
@@ -92,7 +95,7 @@
     _RelationsMeta_Raw = Dict[str, ops.charm.RelationMeta]
     # mapping from container name to container metadata
     _ContainerMeta_Raw = Dict[str, ops.charm.ContainerMeta]
-    _IPAddress = Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
+    _NetworkAddress = Union[ipaddress.IPv4Address, ipaddress.IPv6Address, str]
     _Network = Union[ipaddress.IPv4Network, ipaddress.IPv6Network]
 
     _ServiceInfoMapping = Mapping[str, pebble.ServiceInfo]
@@ -137,16 +140,13 @@ def __init__(self, meta: 'ops.charm.CharmMeta', backend: '_ModelBackend'):
         self._cache = _ModelCache(meta, backend)
         self._backend = backend
         self._unit = self.get_unit(self._backend.unit_name)
-        # fixme: remove cast after typing charm.py
-        relations = typing.cast('_RelationsMeta_Raw', meta.relations)  # type: ignore
+        relations = meta.relations  # type: _RelationsMeta_Raw
         self._relations = RelationMapping(relations, self.unit, self._backend, self._cache)
         self._config = ConfigData(self._backend)
-        # fixme: remove cast after typing charm.py
-        resources = typing.cast(Iterable[str], meta.resources)  # type: ignore
+        resources = meta.resources  # type: Iterable[str]
         self._resources = Resources(list(resources), self._backend)
         self._pod = Pod(self._backend)
-        # fixme: remove cast after typing charm.py
-        storages = typing.cast(Iterable[str], meta.storages)  # type: ignore
+        storages = meta.storages  # type: Iterable[str]
         self._storages = StorageMapping(list(storages), self._backend)
         self._bindings = BindingMapping(self._backend)
 
@@ -413,8 +413,7 @@ def __init__(self, name: str, meta: 'ops.charm.CharmMeta',
         self._status = None
 
         if self._is_our_unit and hasattr(meta, "containers"):
-            # fixme: remove cast when charm.py is typed
-            containers = typing.cast('_ContainerMeta_Raw', meta.containers)  # type: ignore
+            containers = meta.containers  # type: _ContainerMeta_Raw
             self._containers = ContainerMapping(iter(containers), backend)
 
     def _invalidate(self):
@@ -695,6 +694,17 @@ def network(self) -> 'Network':
         return self._network
 
 
+def _cast_network_address(raw: str) -> '_NetworkAddress':
+    # fields marked as network addresses need not be IPs; they could be
+    # hostnames that juju failed to resolve. In that case, we'll log a
+    # debug message and leave it as-is.
+    try:
+        return ipaddress.ip_address(raw)
+    except ValueError:
+        logger.debug("could not cast {} to IPv4/v6 address".format(raw))
+        return raw
+
+
 class Network:
     """Network space details.
 
@@ -728,15 +738,15 @@ def __init__(self, network_info: '_NetworkDict'):
             if addrs is not None:
                 for address_info in addrs:
                     self.interfaces.append(NetworkInterface(interface_name, address_info))
-        self.ingress_addresses = []  # type: List[_IPAddress]
+        self.ingress_addresses = []  # type: List[_NetworkAddress]
         for address in network_info.get('ingress-addresses', []):
-            self.ingress_addresses.append(ipaddress.ip_address(address))
+            self.ingress_addresses.append(_cast_network_address(address))
         self.egress_subnets = []  # type: List[_Network]
         for subnet in network_info.get('egress-subnets', []):
             self.egress_subnets.append(ipaddress.ip_network(subnet))
 
     @property
-    def bind_address(self) -> Optional['_IPAddress']:
+    def bind_address(self) -> Optional['_NetworkAddress']:
         """A single address that your application should bind() to.
 
         For the common case where there is a single answer. This represents a single
@@ -749,7 +759,7 @@ def bind_address(self) -> Optional['_IPAddress']:
             return None
 
     @property
-    def ingress_address(self):
+    def ingress_address(self) -> Optional['_NetworkAddress']:
         """The address other applications should use to connect to your unit.
 
         Due to things like public/private addresses, NAT and tunneling, the address you bind()
@@ -785,8 +795,8 @@ def __init__(self, name: str, address_info: '_AddressDict'):
             address = address_info.get('address')
 
         # The value field may be empty.
-        address_ = ipaddress.ip_address(address) if address else None
-        self.address = address_  # type: Optional[_IPAddress]
+        address_ = _cast_network_address(address) if address else None
+        self.address = address_  # type: Optional[_NetworkAddress]
         cidr = address_info.get('cidr')  # type: str
         # The cidr field may be empty, see LP: #1864102.
         if cidr:
@@ -896,6 +906,15 @@ def __iter__(self):
         return iter(self._data)
 
     def __getitem__(self, key: 'UnitOrApplication'):
+        if key is None and self.relation.app is None:
+            # NOTE: if juju gets fixed to set JUJU_REMOTE_APP for relation-broken events, then that
+            # should fix the only case in which we expect key to be None - potentially removing the
+            # need for this error in future ops versions (i.e. if relation.app is guaranteed to not
+            # be None. See https://bugs.launchpad.net/juju/+bug/1960934.
+            raise KeyError(
+                'Cannot index relation data with "None".'
+                ' Are you trying to access remote app data during a relation-broken event?'
+                ' This is not allowed.')
         return self._data[key]
 
     def __repr__(self):
@@ -914,6 +933,13 @@ def __init__(self, relation: 'Relation', entity: 'UnitOrApplication',
         self._backend = backend
         self._is_app = isinstance(entity, Application)  # type: bool
 
+    @property
+    def _hook_is_running(self) -> bool:
+        # this flag controls whether the access we have to RelationDataContent
+        # is 'strict' aka the same as a deployed charm would have, or whether it is
+        # unrestricted, allowing test code to read/write databags at will.
+        return bool(self._backend._hook_is_running)  # pyright: reportPrivateUsage=false
+
     def _load(self) -> '_RelationDataContent_Raw':
         """Load the data from the current entity / relation."""
         try:
@@ -922,30 +948,110 @@ def _load(self) -> '_RelationDataContent_Raw':
             # Dead relations tell no tales (and have no data).
             return {}
 
-    def _is_mutable(self):
-        """Return if the data content can be modified."""
+    def _validate_read(self):
+        """Return if the data content can be read."""
+        # if we're not in production (we're testing): we skip access control rules
+        if not self._hook_is_running:
+            return
+
+        # Only remote units (and the leader unit) can read *this* app databag.
+
+        # is this an app databag?
+        if not self._is_app:
+            # all unit databags are publicly readable
+            return
+
+        # Am I leader?
+        if self._backend.is_leader():
+            # leaders have no read restrictions
+            return
+
+        # type guard; we should not be accessing relation data
+        # if the remote app does not exist.
+        app = self.relation.app
+        if app is None:
+            raise RelationDataAccessError(
+                "Remote application instance cannot be retrieved for {}.".format(
+                    self.relation
+                )
+            )
+
+        # is this a peer relation?
+        if app.name == self._entity.name:
+            # peer relation data is always publicly readable
+            return
+
+        # if we're here it means: this is not a peer relation,
+        # this is an app databag, and we don't have leadership.
+
+        # is this a LOCAL app databag?
+        if self._backend.app_name == self._entity.name:
+            # minions can't read local app databags
+            raise RelationDataAccessError(
+                "{} is not leader and cannot read its own application databag".format(
+                    self._backend.unit_name
+                )
+            )
+
+        return True
+
+    def _validate_write(self, key: str, value: str):
+        """Validate writing key:value to this databag.
+
+        1) that key: value is a valid str:str pair
+        2) that we have write access to this databag
+        """
+        # firstly, we validate WHAT we're trying to write.
+        # this is independent of whether we're in testing code or production.
+        if not isinstance(key, str):
+            raise RelationDataTypeError(
+                'relation data keys must be strings, not {}'.format(type(key)))
+        if not isinstance(value, str):
+            raise RelationDataTypeError(
+                'relation data values must be strings, not {}'.format(type(value)))
+
+        # if we're not in production (we're testing): we skip access control rules
+        if not self._hook_is_running:
+            return
+
+        # finally, we check whether we have permissions to write this databag
         if self._is_app:
             is_our_app = self._backend.app_name == self._entity.name  # type: bool
             if not is_our_app:
-                return False
+                raise RelationDataAccessError(
+                    "{} cannot write the data of remote application {}".format(
+                        self._backend.app_name, self._entity.name
+                    ))
             # Whether the application data bag is mutable or not depends on
             # whether this unit is a leader or not, but this is not guaranteed
             # to be always true during the same hook execution.
-            return self._backend.is_leader()
+            if self._backend.is_leader():
+                return  # all good
+            raise RelationDataAccessError(
+                "{} is not leader and cannot write application data.".format(
+                    self._backend.unit_name
+                )
+            )
         else:
-            is_our_unit = self._backend.unit_name == self._entity.name
-            if is_our_unit:
-                return True
-        return False
+            # we are attempting to write a unit databag
+            # is it OUR UNIT's?
+            if self._backend.unit_name != self._entity.name:
+                raise RelationDataAccessError(
+                    "{} cannot write databag of {}: not the same unit.".format(
+                        self._backend.unit_name, self._entity.name
+                    )
+                )
 
     def __setitem__(self, key: str, value: str):
-        if not self._is_mutable():
-            raise RelationDataError('cannot set relation data for {}'.format(self._entity.name))
-        if not isinstance(value, str):
-            raise RelationDataError('relation data values must be strings')
+        self._validate_write(key, value)
+        self._commit(key, value)
+        self._update(key, value)
 
-        self._backend.relation_set(self.relation.id, key, value, self._is_app)
+    def _commit(self, key: str, value: str):
+        self._backend.update_relation_data(self.relation.id, self._entity, key, value)
 
+    def _update(self, key: str, value: str):
+        """Cache key:value in our local lazy data."""
         # Don't load data unnecessarily if we're only updating.
         if self._lazy_data is not None:
             if value == '':
@@ -955,11 +1061,23 @@ def __setitem__(self, key: str, value: str):
             else:
                 self._data[key] = value
 
+    def __getitem__(self, key: str) -> str:
+        self._validate_read()
+        return super().__getitem__(key)
+
     def __delitem__(self, key: str):
+        self._validate_write(key, '')
         # Match the behavior of Juju, which is that setting the value to an empty
         # string will remove the key entirely from the relation data.
         self.__setitem__(key, '')
 
+    def __repr__(self):
+        try:
+            self._validate_read()
+        except RelationDataAccessError:
+            return '<n/a>'
+        return super().__repr__()
+
 
 class ConfigData(LazyMapping):
     """Configuration data.
@@ -1113,7 +1231,7 @@ class Pod:
     def __init__(self, backend: '_ModelBackend'):
         self._backend = backend
 
-    def set_spec(self, spec: '_K8sSpec', k8s_resources: Optional['_K8sSpec'] = None):
+    def set_spec(self, spec: 'K8sSpec', k8s_resources: Optional['K8sSpec'] = None):
         """Set the specification for pods that Juju should start in kubernetes.
 
         See `juju help-tool pod-spec-set` for details of what should be passed.
@@ -1148,6 +1266,10 @@ def __iter__(self):
         return iter(self._storage_map)
 
     def __getitem__(self, storage_name: str) -> List['Storage']:
+        if storage_name not in self._storage_map:
+            meant = ', or '.join(['{!r}'.format(k) for k in self._storage_map.keys()])
+            raise KeyError(
+                'Storage {!r} not found. Did you mean {}?'.format(storage_name, meant))
         storage_list = self._storage_map[storage_name]
         if storage_list is None:
             storage_list = self._storage_map[storage_name] = []
@@ -1323,8 +1445,7 @@ def start(self, *service_names: str):
         if not service_names:
             raise TypeError('start expected at least 1 argument, got 0')
 
-        # fixme: remove on pebble.exec signature fix
-        self._pebble.start_services(service_names)   # type: ignore
+        self._pebble.start_services(service_names)
 
     def restart(self, *service_names: str):
         """Restart the given service(s) by name."""
@@ -1332,8 +1453,7 @@ def restart(self, *service_names: str):
             raise TypeError('restart expected at least 1 argument, got 0')
 
         try:
-            # fixme: remove on pebble.exec signature fix
-            self._pebble.restart_services(service_names)  # type: ignore
+            self._pebble.restart_services(service_names)
         except pebble.APIError as e:
             if e.code != 400:
                 raise e
@@ -1341,18 +1461,15 @@ def restart(self, *service_names: str):
             stop = tuple(s.name for s in self.get_services(*service_names).values(
             ) if s.is_running())  # type: Tuple[str, ...]
             if stop:
-                # fixme: remove on pebble.exec signature fix
-                self._pebble.stop_services(stop)   # type: ignore
-            # fixme: remove on pebble.exec signature fix
-            self._pebble.start_services(service_names)  # type: ignore
+                self._pebble.stop_services(stop)
+            self._pebble.start_services(service_names)
 
     def stop(self, *service_names: str):
         """Stop given service(s) by name."""
         if not service_names:
             raise TypeError('stop expected at least 1 argument, got 0')
 
-        # fixme: remove on pebble.exec signature fix
-        self._pebble.stop_services(service_names)  # type: ignore
+        self._pebble.stop_services(service_names)
 
     def add_layer(self, label: str, layer: '_Layer', *, combine: bool = False):
         """Dynamically add a new layer onto the Pebble configuration layers.
@@ -1368,8 +1485,7 @@ def add_layer(self, label: str, layer: '_Layer', *, combine: bool = False):
                 are combined into a single one considering the layer override
                 rules; if the layer doesn't exist, it is added as usual.
         """
-        # fixme: remove ignore once pebble.py is typed
-        self._pebble.add_layer(label, layer, combine=combine)  # type: ignore
+        self._pebble.add_layer(label, layer, combine=combine)
 
     def get_plan(self) -> 'Plan':
         """Get the current effective pebble configuration."""
@@ -1382,8 +1498,7 @@ def get_services(self, *service_names: str) -> '_ServiceInfoMapping':
         services, otherwise return information for only the given services.
         """
         names = service_names or None
-        # fixme: remove on pebble.exec signature fix
-        services = self._pebble.get_services(names)   # type: ignore
+        services = self._pebble.get_services(names)
         return ServiceInfoMapping(services)
 
     def get_service(self, service_name: str) -> 'ServiceInfo':
@@ -1410,8 +1525,7 @@ def get_checks(
             level: Optional check level to query for. If not specified, fetch
                 checks with any level.
         """
-        # fixme: remove on pebble.exec signature fix
-        checks = self._pebble.get_checks(names=check_names or None, level=level)  # type: ignore
+        checks = self._pebble.get_checks(names=check_names or None, level=level)
         return CheckInfoMapping(checks)
 
     def get_check(self, check_name: str) -> 'CheckInfo':
@@ -1447,7 +1561,7 @@ def push(self,
              source: Union[bytes, str, BinaryIO, TextIO],
              *,
              encoding: str = 'utf-8',
-             make_dirs: Optional[bool] = False,
+             make_dirs: bool = False,
              permissions: Optional[int] = None,
              user_id: Optional[int] = None,
              user: Optional[str] = None,
@@ -1473,11 +1587,10 @@ def push(self,
                 both are specified.
         """
         self._pebble.push(str(path), source, encoding=encoding,
-                          # fixme: remove these ignores on pebble.exec signature fix
-                          make_dirs=make_dirs,  # type: ignore
-                          permissions=permissions,   # type: ignore
-                          user_id=user_id, user=user,  # type: ignore
-                          group_id=group_id, group=group)   # type: ignore
+                          make_dirs=make_dirs,
+                          permissions=permissions,
+                          user_id=user_id, user=user,
+                          group_id=group_id, group=group)
 
     def list_files(self, path: StrOrPath, *, pattern: Optional[str] = None,
                    itself: bool = False) -> List['FileInfo']:
@@ -1495,8 +1608,7 @@ def list_files(self, path: StrOrPath, *, pattern: Optional[str] = None,
                 directory itself, rather than its contents.
         """
         return self._pebble.list_files(str(path),
-                                       # fixme: remove on pebble.exec signature fix
-                                       pattern=pattern, itself=itself)  # type: ignore
+                                       pattern=pattern, itself=itself)
 
     def push_path(self,
                   source_path: Union[StrOrPath, Iterable[StrOrPath]],
@@ -1673,7 +1785,7 @@ def _build_fileinfo(path: StrOrPath) -> 'FileInfo':
             name=path.name,
             type=ftype,
             size=info.st_size,
-            permissions=stat.S_IMODE(info.st_mode),  # type: ignore
+            permissions=typing.cast(int, stat.S_IMODE(info.st_mode)),  # type: ignore
             last_modified=datetime.datetime.fromtimestamp(info.st_mtime),
             user_id=info.st_uid,
             user=pwd.getpwuid(info.st_uid).pw_name,
@@ -1766,11 +1878,10 @@ def make_dir(
             group: Group name for directory. Group's GID must match group_id
                 if both are specified.
         """
-        # fixme: remove ignores on pebble.exec signature fix
         self._pebble.make_dir(path, make_parents=make_parents,
-                              permissions=permissions,  # type: ignore
-                              user_id=user_id, user=user,  # type: ignore
-                              group_id=group_id, group=group)  # type: ignore
+                              permissions=permissions,
+                              user_id=user_id, user=user,
+                              group_id=group_id, group=group)
 
     def remove_path(self, path: str, *, recursive: bool = False):
         """Remove a file or directory on the remote system.
@@ -1805,19 +1916,18 @@ def exec(
         """
         return self._pebble.exec(
             command,
-            # fixme: remove ignores on pebble.py typing fix
-            environment=environment,  # type: ignore
-            working_dir=working_dir,  # type: ignore
-            timeout=timeout,  # type: ignore
-            user_id=user_id,  # type: ignore
-            user=user,  # type: ignore
-            group_id=group_id,  # type: ignore
-            group=group,  # type: ignore
-            stdin=stdin,  # type: ignore
-            stdout=stdout,  # type: ignore
-            stderr=stderr,  # type: ignore
-            encoding=encoding,  # type: ignore
-            combine_stderr=combine_stderr,  # type: ignore
+            environment=environment,
+            working_dir=working_dir,
+            timeout=timeout,
+            user_id=user_id,
+            user=user,
+            group_id=group_id,
+            group=group,
+            stdin=stdin,
+            stdout=stdout,
+            stderr=stderr,
+            encoding=encoding,
+            combine_stderr=combine_stderr,
         )
 
     def send_signal(self, sig: Union[int, str], *service_names: str):
@@ -1835,8 +1945,7 @@ def send_signal(self, sig: Union[int, str], *service_names: str):
         if not service_names:
             raise TypeError('send_signal expected at least 1 service name, got 0')
 
-        # fixme: remove ignore once pebble.send_signature signature is fixed
-        self._pebble.send_signal(sig, service_names)  # type: ignore
+        self._pebble.send_signal(sig, service_names)
 
 
 class ContainerMapping(Mapping[str, Container]):
@@ -1925,11 +2034,25 @@ def __init__(self, relation_name: str, num_related: int, max_supported: int):
 
 
 class RelationDataError(ModelError):
-    """Raised by ``Relation.data[entity][key] = 'foo'`` if the data is invalid.
+    """Raised when a relation data read/write is invalid.
 
     This is raised if you're either trying to set a value to something that isn't a string,
     or if you are trying to set a value in a bucket that you don't have access to. (eg,
     another application/unit or setting your application data but you aren't the leader.)
+    Also raised when you attempt to read a databag you don't have access to
+    (i.e. a local app databag if you're not the leader).
+    """
+
+
+class RelationDataTypeError(RelationDataError):
+    """Raised by ``Relation.data[entity][key] = value`` if `key` or `value` are not strings."""
+
+
+class RelationDataAccessError(RelationDataError):
+    """Raised by ``Relation.data[entity][key] = value`` if you don't have access.
+
+    This typically means that you don't have permission to write read/write the databag,
+    but in some cases it is raised when attempting to read/write from a deceased remote entity.
     """
 
 
@@ -1979,6 +2102,10 @@ def _format_action_result_dict(input: Dict[str, 'JsonObject'],
 
     for key, value in input.items():
         # Ensure the key is of a valid format, and raise a ValueError if not
+        if not isinstance(key, str):
+            # technically a type error, but for consistency with the
+            # other exceptions raised on key validation...
+            raise ValueError('invalid key {!r}; must be a string'.format(key))
         if not _ACTION_RESULT_KEY_REGEX.match(key):
             raise ValueError("key '{!r}' is invalid: must be similar to 'key', 'some-key2', or "
                              "'some.key'".format(key))
@@ -2093,7 +2220,7 @@ def relation_remote_app_name(self, relation_id: int) -> Optional[str]:
             event_relation_id = int(os.environ['JUJU_RELATION_ID'].split(':')[-1])
             if relation_id == event_relation_id:
                 # JUJU_RELATION_ID is this relation, use JUJU_REMOTE_APP.
-                return os.environ['JUJU_REMOTE_APP']
+                return os.getenv('JUJU_REMOTE_APP') or None
 
         # If caller is asking for information about another relation, use
         # "relation-list --app" to get it.
@@ -2114,7 +2241,7 @@ def relation_remote_app_name(self, relation_id: int) -> Optional[str]:
 
     def relation_get(self, relation_id: int, member_name: str, is_app: bool
                      ) -> '_RelationDataContent_Raw':
-        if not isinstance(is_app, bool):   # pyright:
+        if not isinstance(is_app, bool):
             raise TypeError('is_app parameter to relation_get must be a boolean')
 
         if is_app:
@@ -2135,7 +2262,7 @@ def relation_get(self, relation_id: int, member_name: str, is_app: bool
                 raise RelationNotFoundError() from e
             raise
 
-    def relation_set(self, relation_id: int, key: str, value: str, is_app: bool):
+    def relation_set(self, relation_id: int, key: str, value: str, is_app: bool) -> None:
         if not isinstance(is_app, bool):
             raise TypeError('is_app parameter to relation_set must be a boolean')
 
@@ -2151,15 +2278,16 @@ def relation_set(self, relation_id: int, key: str, value: str, is_app: bool):
         args.extend(["--file", "-"])
 
         try:
-            content = yaml.safe_dump({key: value}, encoding='utf8')  # type: ignore
-            return self._run(*args, input_stream=content)
+            content = yaml.safe_dump({key: value}, encoding='utf8')
+            self._run(*args, input_stream=content)
         except ModelError as e:
             if self._is_relation_not_found(e):
                 raise RelationNotFoundError() from e
             raise
 
-    def config_get(self):
-        return self._run('config-get', return_output=True, use_json=True)
+    def config_get(self) -> Dict[str, '_ConfigOption']:
+        out = self._run('config-get', return_output=True, use_json=True)
+        return typing.cast(Dict[str, '_ConfigOption'], out)
 
     def is_leader(self) -> bool:
         """Obtain the current leadership status for the unit the charm code is executing on.
@@ -2192,12 +2320,12 @@ def pod_spec_set(self, spec: Mapping[str, 'JsonObject'],
         try:
             spec_path = tmpdir / 'spec.yaml'
             with spec_path.open("wt", encoding="utf8") as f:
-                yaml.safe_dump(spec, stream=f)  # type: ignore
+                yaml.safe_dump(spec, stream=f)
             args = ['--file', str(spec_path)]
             if k8s_resources:
                 k8s_res_path = tmpdir / 'k8s-resources.yaml'
                 with k8s_res_path.open("wt", encoding="utf8") as f:
-                    yaml.safe_dump(k8s_resources, stream=f)  # type: ignore
+                    yaml.safe_dump(k8s_resources, stream=f)
                 args.extend(['--k8s-resources', str(k8s_res_path)])
             self._run('pod-spec-set', *args)
         finally:
@@ -2237,7 +2365,7 @@ def status_get(self, *, is_app: bool = False) -> '_StatusDict':
         else:
             return typing.cast('_StatusDict', content)
 
-    def status_set(self, status: str, message: str = '', *, is_app: bool = False):
+    def status_set(self, status: str, message: str = '', *, is_app: bool = False) -> None:
         """Set a status of a unit or an application.
 
         Args:
@@ -2248,9 +2376,9 @@ def status_set(self, status: str, message: str = '', *, is_app: bool = False):
         """
         if not isinstance(is_app, bool):
             raise TypeError('is_app parameter must be boolean')
-        return self._run('status-set', '--application={}'.format(is_app), status, message)
+        self._run('status-set', '--application={}'.format(is_app), status, message)
 
-    def storage_list(self, name: str):
+    def storage_list(self, name: str) -> List[int]:
         storages = self._run('storage-list', name, return_output=True, use_json=True)
         storages = typing.cast(List[str], storages)
         return [int(s.split('/')[1]) for s in storages]
@@ -2264,41 +2392,46 @@ def _storage_event_details(self) -> Tuple[int, str]:
             raise RuntimeError('unable to find storage key in {output!r}'.format(output=output))
         key = match.groupdict()["storage_key"]
 
-        id = int(key.split("/")[1])
+        index = int(key.split("/")[1])
         location = self.storage_get(key, "location")
-        return id, location
+        return index, location
 
     def storage_get(self, storage_name_id: str, attribute: str) -> str:
+        if not len(attribute) > 0:  # assume it's an empty string.
+            raise RuntimeError('calling storage_get with `attribute=""` will return a dict '
+                               'and not a string. This usage is not supported.')
         out = self._run('storage-get', '-s', storage_name_id, attribute,
                         return_output=True, use_json=True)
         return typing.cast(str, out)
 
-    def storage_add(self, name: str, count: int = 1):
+    def storage_add(self, name: str, count: int = 1) -> None:
         if not isinstance(count, int) or isinstance(count, bool):
             raise TypeError('storage count must be integer, got: {} ({})'.format(count,
                                                                                  type(count)))
         self._run('storage-add', '{}={}'.format(name, count))
 
-    def action_get(self):
-        return self._run('action-get', return_output=True, use_json=True)
+    def action_get(self) -> Dict[str, str]:  # todo: what do we know about this dict?
+        out = self._run('action-get', return_output=True, use_json=True)
+        return typing.cast(Dict[str, str], out)
 
-    def action_set(self, results: Dict[str, 'JsonObject']):
+    def action_set(self, results: '_SerializedData') -> None:
         # The Juju action-set hook tool cannot interpret nested dicts, so we use a helper to
         # flatten out any nested dict structures into a dotted notation, and validate keys.
         flat_results = _format_action_result_dict(results)
         self._run('action-set', *["{}={}".format(k, v) for k, v in flat_results.items()])
 
-    def action_log(self, message: str):
+    def action_log(self, message: str) -> None:
         self._run('action-log', message)
 
-    def action_fail(self, message: str = ''):
+    def action_fail(self, message: str = '') -> None:
         self._run('action-fail', message)
 
-    def application_version_set(self, version: str):
+    def application_version_set(self, version: str) -> None:
         self._run('application-version-set', '--', version)
 
     @classmethod
-    def log_split(cls, message: str, max_len: int = MAX_LOG_LINE_LEN):
+    def log_split(cls, message: str, max_len: int = MAX_LOG_LINE_LEN
+                  ) -> Generator[str, None, None]:
         """Helper to handle log messages that are potentially too long.
 
         This is a generator that splits a message string into multiple chunks if it is too long
@@ -2311,7 +2444,7 @@ def log_split(cls, message: str, max_len: int = MAX_LOG_LINE_LEN):
             yield message[:max_len]
             message = message[max_len:]
 
-    def juju_log(self, level: str, message: str):
+    def juju_log(self, level: str, message: str) -> None:
         """Pass a log message on to the juju logger."""
         for line in self.log_split(message):
             self._run('juju-log', '--log-level', level, "--", line)
@@ -2335,7 +2468,7 @@ def network_get(self, binding_name: str, relation_id: Optional[int] = None) -> '
             raise
 
     def add_metrics(self, metrics: Mapping[str, 'Numerical'],
-                    labels: Optional[Mapping[str, str]] = None):
+                    labels: Optional[Mapping[str, str]] = None) -> None:
         cmd = ['add-metric']  # type: List[str]
         if labels:
             label_args = []  # type: List[str]
@@ -2371,6 +2504,10 @@ def planned_units(self) -> int:
         # Planned units can be zero. We don't need to do error checking here.
         return len(app_state.get('units', []))
 
+    def update_relation_data(self, relation_id: int, _entity: 'UnitOrApplication',
+                             key: str, value: str):
+        self.relation_set(relation_id, key, value, isinstance(_entity, Application))
+
 
 class _ModelBackendValidator:
     """Provides facilities for validating inputs and formatting them for model backends."""
diff --git a/ops/pebble.py b/ops/pebble.py
index f1fb06b4f..71de6fb85 100644
--- a/ops/pebble.py
+++ b/ops/pebble.py
@@ -43,22 +43,198 @@
 import urllib.parse
 import urllib.request
 import warnings
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    BinaryIO,
+    Callable,
+    Dict,
+    Generator,
+    Iterable,
+    List,
+    Optional,
+    Sequence,
+    TextIO,
+    Tuple,
+    Union,
+)
 
 from ops._private import yaml
 from ops._vendor import websocket
 
+if TYPE_CHECKING:
+    from email.message import Message
+
+    from typing_extensions import Literal, Protocol, TypedDict
+
+    # callback types for _MultiParser header and body handlers
+    class _BodyHandler(Protocol):
+        def __call__(self, data: bytes, done: bool = False) -> None: ...  # noqa
+
+    _HeaderHandler = Callable[[bytes], None]
+    _StrOrBytes = Union[str, bytes]
+
+    # tempfile.NamedTemporaryFile has an odd interface because of that
+    # 'name' attribute, so we need to make a Protocol for it.
+    class _Tempfile(Protocol):
+        name = ''
+        def write(self, data: bytes): ...  # noqa
+        def close(self): ...  # noqa
+
+    class _FileLikeIO(Protocol[typing.AnyStr]):  # That also covers TextIO and BytesIO
+        def read(self, __n: int = ...) -> typing.AnyStr: ...  # for BinaryIO  # noqa
+        def write(self, __s: typing.AnyStr) -> int: ...  # noqa
+        def __enter__(self) -> typing.IO[typing.AnyStr]: ...  # noqa
+
+    class _Readable(Protocol):
+        def read(self, n: int = -1) -> _StrOrBytes: ...  # noqa
+
+    class _Writeable(Protocol):
+        # We'd need something like io.ReadableBuffer here,
+        # but we can't import that type
+        def write(self, buf: Union[bytes, str, bytearray]) -> int: ...  # noqa
+
+    _AnyStrFileLikeIO = Union[_FileLikeIO[bytes], _FileLikeIO[str]]
+    _TextOrBinaryIO = Union[TextIO, BinaryIO]
+    _IOSource = Union[str, bytes, _AnyStrFileLikeIO]
+
+    _SystemInfoDict = TypedDict('_SystemInfoDict', {'version': str})
+    _InfoDict = TypedDict('_InfoDict',
+                          {"name": str,
+                           "level": Optional[Union['CheckLevel', str]],
+                           "status": Union['CheckStatus', str],
+                           "failures": int,
+                           "threshold": int})
+    _FileInfoDict = TypedDict('_FileInfoDict',
+                              {"path": str,
+                               "name": str,
+                               "size": Optional[int],
+                               "permissions": str,
+                               "last-modified": str,
+                               "user-id": Optional[int],
+                               "user": Optional[str],
+                               "group-id": Optional[int],
+                               "group": Optional[str],
+                               "type": Union['FileType', str]})
+    _HttpDict = TypedDict('_HttpDict', {'url': str})
+    _TcpDict = TypedDict('_TcpDict', {'port': int})
+    _ExecDict = TypedDict('_ExecDict', {'command': str})
+    _CheckDict = TypedDict('_CheckDict',
+                           {'override': str,
+                            'level': Union['CheckLevel', str],
+                            'period': Optional[str],
+                            'timeout': Optional[str],
+                            'http': Optional[_HttpDict],
+                            'tcp': Optional[_TcpDict],
+                            'exec': Optional[_ExecDict],
+                            'threshold': Optional[int]},
+                           total=False)
+
+    _AuthDict = TypedDict('_AuthDict',
+                          {'permissions': Optional[str],
+                           'user-id': Optional[int],
+                           'user': Optional[str],
+                           'group-id': Optional[int],
+                           'group': Optional[str],
+                           'path': Optional[str],
+                           'make-dirs': Optional[bool],
+                           'make-parents': Optional[bool],
+                           }, total=False)
+    _ServiceInfoDict = TypedDict('_ServiceInfoDict',
+                                 {'startup': Union['ServiceStartup', str],
+                                  'current': Union['ServiceStatus', str],
+                                  'name': str})
+    _ServiceDict = TypedDict('_ServiceDict',
+                             {'summary': str,
+                              'description': str,
+                              'startup': str,
+                              'override': str,
+                              'command': str,
+                              'after': Sequence[str],
+                              'before': Sequence[str],
+                              'requires': Sequence[str],
+                              'environment': Dict[str, str],
+                              'user': str,
+                              'user-id': Optional[int],
+                              'group': str,
+                              'group-id': Optional[int],
+                              'on-success': str,
+                              'on-failure': str,
+                              'on-check-failure': Dict[str, Any],
+                              'backoff-delay': str,
+                              'backoff-factor': Optional[int],
+                              'backoff-limit': str,
+                              },
+                             total=False)
+
+    _ProgressDict = TypedDict('_ProgressDict',
+                              {'label': str,
+                               'done': int,
+                               'total': int})
+    _TaskData = Dict[str, Any]
+    _TaskDict = TypedDict('_TaskDict',
+                          {'id': 'TaskID',
+                           'kind': str,
+                           'summary': str,
+                           'status': str,
+                           'log': Optional[List[str]],
+                           'progress': _ProgressDict,
+                           'spawn-time': str,
+                           'ready-time': str,
+                           'data': Optional[_TaskData]})
+    _ChangeData = TypedDict('_ChangeData', {})
+    _ChangeDict = TypedDict('_ChangeDict',
+                            {'id': str,
+                             'kind': str,
+                             'summary': str,
+                             'status': str,
+                             'ready': bool,
+                             'spawn-time': str,
+                             'tasks': Optional[List[_TaskDict]],
+                             'err': Optional[str],
+                             'ready-time': Optional[str],
+                             'data': Optional[_ChangeData]})
+
+    _PlanDict = TypedDict('_PlanDict',
+                          {'services': Dict[str, _ServiceDict],
+                           'checks': Dict[str, _CheckDict]},
+                          total=False)
+    # public as it is accessed by ops.testing
+    LayerDict = TypedDict('LayerDict',
+                          {'summary': str,
+                           'description': str,
+                           'services': Dict[str, _ServiceDict],
+                           'checks': Dict[str, _CheckDict]},
+                          total=False)
+
+    _Error = TypedDict('_Error',
+                       {'kind': str,
+                        'message': str})
+    _Item = TypedDict('_Item',
+                      {'path': str,
+                       'error': _Error})
+    _FilesResponse = TypedDict('_FilesResponse',
+                               {'result': List[_Item]})
+
 logger = logging.getLogger(__name__)
 
-_not_provided = object()
+
+class _NotProvidedFlag:
+    pass
+
+
+_not_provided = _NotProvidedFlag()
 
 
 class _UnixSocketConnection(http.client.HTTPConnection):
     """Implementation of HTTPConnection that connects to a named Unix socket."""
 
-    def __init__(self, host, timeout=_not_provided, socket_path=None):
+    def __init__(self, host: str, socket_path: str,
+                 timeout: Union[_NotProvidedFlag, float] = _not_provided):
         if timeout is _not_provided:
             super().__init__(host)
         else:
+            assert isinstance(timeout, (int, float)), timeout  # type guard for pyright
             super().__init__(host, timeout=timeout)
         self.socket_path = socket_path
 
@@ -75,13 +251,14 @@ def connect(self):
 class _UnixSocketHandler(urllib.request.AbstractHTTPHandler):
     """Implementation of HTTPHandler that uses a named Unix socket."""
 
-    def __init__(self, socket_path):
+    def __init__(self, socket_path: str):
         super().__init__()
         self.socket_path = socket_path
 
-    def http_open(self, req):
+    def http_open(self, req: urllib.request.Request):
         """Override http_open to use a Unix socket connection (instead of TCP)."""
-        return self.do_open(_UnixSocketConnection, req, socket_path=self.socket_path)
+        return self.do_open(_UnixSocketConnection, req,  # type:ignore
+                            socket_path=self.socket_path)
 
 
 # Matches yyyy-mm-ddTHH:MM:SS(.sss)ZZZ
@@ -92,7 +269,7 @@ def http_open(self, req):
 _TIMEOFFSET_RE = re.compile(r'([-+])(\d{2}):(\d{2})')
 
 
-def _parse_timestamp(s):
+def _parse_timestamp(s: str):
     """Parse timestamp from Go-encoded JSON.
 
     This parses RFC3339 timestamps (which are a subset of ISO8601 timestamps)
@@ -123,7 +300,7 @@ def _parse_timestamp(s):
                              microsecond=microsecond, tzinfo=tz)
 
 
-def _format_timeout(timeout: float):
+def _format_timeout(timeout: float) -> str:
     """Format timeout for use in the Pebble API.
 
     The format is in seconds with a millisecond resolution and an 's' suffix,
@@ -132,7 +309,7 @@ def _format_timeout(timeout: float):
     return '{:.3f}s'.format(timeout)
 
 
-def _json_loads(s: typing.Union[str, bytes]) -> typing.Dict:
+def _json_loads(s: '_StrOrBytes') -> Dict[Any, Any]:
     """Like json.loads(), but handle str or bytes.
 
     This is needed because an HTTP response's read() method returns bytes on
@@ -143,7 +320,7 @@ def _json_loads(s: typing.Union[str, bytes]) -> typing.Dict:
     return json.loads(s)
 
 
-def _start_thread(target, *args, **kwargs) -> threading.Thread:
+def _start_thread(target: Callable[..., Any], *args: Any, **kwargs: Any) -> threading.Thread:
     """Helper to simplify starting a thread."""
     thread = threading.Thread(target=target, args=args, kwargs=kwargs)
     thread.start()
@@ -183,7 +360,9 @@ class PathError(Error):
     def __init__(self, kind: str, message: str):
         """This shouldn't be instantiated directly."""
         self.kind = kind
-        self.message = message
+        # FIXME: pyright rightfully complains that super().message is a method
+        #  see: https://github.com/canonical/operator/issues/777
+        self.message = message  # type: ignore
 
     def __str__(self):
         return '{} - {}'.format(self.kind, self.message)
@@ -195,13 +374,14 @@ def __repr__(self):
 class APIError(Error):
     """Raised when an HTTP API error occurs talking to the Pebble server."""
 
-    def __init__(self, body: typing.Dict, code: int, status: str, message: str):
+    def __init__(self, body: Dict[str, Any], code: int, status: str, message: str):
         """This shouldn't be instantiated directly."""
         super().__init__(message)  # Makes str(e) return message
         self.body = body
         self.code = code
         self.status = status
-        self.message = message
+        # FIXME: pyright rightfully complains that super().message is a method
+        self.message = message  # type: ignore
 
     def __repr__(self):
         return 'APIError({!r}, {!r}, {!r}, {!r})'.format(
@@ -261,10 +441,10 @@ class ExecError(Error):
 
     def __init__(
         self,
-        command: typing.List[str],
+        command: List[str],
         exit_code: int,
-        stdout: typing.Optional[typing.AnyStr],
-        stderr: typing.Optional[typing.AnyStr],
+        stdout: Optional['_StrOrBytes'],
+        stderr: Optional['_StrOrBytes'],
     ):
         self.command = command
         self.exit_code = exit_code
@@ -307,7 +487,7 @@ def __init__(self, version: str):
         self.version = version
 
     @classmethod
-    def from_dict(cls, d: typing.Dict) -> 'SystemInfo':
+    def from_dict(cls, d: '_SystemInfoDict') -> 'SystemInfo':
         """Create new SystemInfo object from dict parsed from JSON."""
         return cls(version=d['version'])
 
@@ -317,13 +497,21 @@ def __repr__(self):
 
 class Warning:
     """Warning object."""
+    if typing.TYPE_CHECKING:
+        _WarningDict = TypedDict('_WarningDict',
+                                 {'message': str,
+                                  'first-added': str,
+                                  'last-added': str,
+                                  'last-shown': Optional[str],
+                                  'expire-after': str,
+                                  'repeat-after': str})
 
     def __init__(
         self,
         message: str,
         first_added: datetime.datetime,
         last_added: datetime.datetime,
-        last_shown: typing.Optional[datetime.datetime],
+        last_shown: Optional[datetime.datetime],
         expire_after: str,
         repeat_after: str,
     ):
@@ -335,13 +523,14 @@ def __init__(
         self.repeat_after = repeat_after
 
     @classmethod
-    def from_dict(cls, d: typing.Dict) -> 'Warning':
+    def from_dict(cls, d: '_WarningDict') -> 'Warning':
         """Create new Warning object from dict parsed from JSON."""
         return cls(
             message=d['message'],
             first_added=_parse_timestamp(d['first-added']),
             last_added=_parse_timestamp(d['last-added']),
-            last_shown=_parse_timestamp(d['last-shown']) if d.get('last-shown') else None,
+            last_shown=(_parse_timestamp(d['last-shown'])  # type: ignore
+                        if d.get('last-shown') else None),
             expire_after=d['expire-after'],
             repeat_after=d['repeat-after'],
         )
@@ -371,7 +560,7 @@ def __init__(
         self.total = total
 
     @classmethod
-    def from_dict(cls, d: typing.Dict) -> 'TaskProgress':
+    def from_dict(cls, d: '_ProgressDict') -> 'TaskProgress':
         """Create new TaskProgress object from dict parsed from JSON."""
         return cls(
             label=d['label'],
@@ -403,11 +592,11 @@ def __init__(
         kind: str,
         summary: str,
         status: str,
-        log: typing.List[str],
+        log: List[str],
         progress: TaskProgress,
         spawn_time: datetime.datetime,
-        ready_time: typing.Optional[datetime.datetime],
-        data: typing.Dict[str, typing.Any] = None,
+        ready_time: Optional[datetime.datetime],
+        data: Optional['_TaskData'] = None,
     ):
         self.id = id
         self.kind = kind
@@ -420,7 +609,7 @@ def __init__(
         self.data = data or {}
 
     @classmethod
-    def from_dict(cls, d: typing.Dict) -> 'Task':
+    def from_dict(cls, d: '_TaskDict') -> 'Task':
         """Create new Task object from dict parsed from JSON."""
         return cls(
             id=TaskID(d['id']),
@@ -464,12 +653,12 @@ def __init__(
         kind: str,
         summary: str,
         status: str,
-        tasks: typing.List[Task],
+        tasks: List[Task],
         ready: bool,
-        err: typing.Optional[str],
+        err: Optional[str],
         spawn_time: datetime.datetime,
-        ready_time: typing.Optional[datetime.datetime],
-        data: typing.Dict[str, typing.Any] = None,
+        ready_time: Optional[datetime.datetime],
+        data: Optional['_ChangeData'] = None,
     ):
         self.id = id
         self.kind = kind
@@ -483,7 +672,7 @@ def __init__(
         self.data = data or {}
 
     @classmethod
-    def from_dict(cls, d: typing.Dict) -> 'Change':
+    def from_dict(cls, d: '_ChangeDict') -> 'Change':
         """Create new Change object from dict parsed from JSON."""
         return cls(
             id=ChangeID(d['id']),
@@ -494,7 +683,8 @@ def from_dict(cls, d: typing.Dict) -> 'Change':
             ready=d['ready'],
             err=d.get('err'),
             spawn_time=_parse_timestamp(d['spawn-time']),
-            ready_time=_parse_timestamp(d['ready-time']) if d.get('ready-time') else None,
+            ready_time=(_parse_timestamp(d['ready-time'])  # type: ignore
+                        if d.get('ready-time') else None),
             data=d.get('data') or {},
         )
 
@@ -521,15 +711,19 @@ class Plan:
     """
 
     def __init__(self, raw: str):
-        d = yaml.safe_load(raw) or {}
+        d = yaml.safe_load(raw) or {}  # type: ignore
+        d = typing.cast('_PlanDict', d)
+
         self._raw = raw
         self._services = {name: Service(name, service)
-                          for name, service in d.get('services', {}).items()}
+                          for name, service in d.get('services', {}).items()
+                          }  # type: Dict[str, Service]
         self._checks = {name: Check(name, check)
-                        for name, check in d.get('checks', {}).items()}
+                        for name, check in d.get('checks', {}).items()
+                        }  # type: Dict[str, Check]
 
     @property
-    def services(self):
+    def services(self) -> Dict[str, 'Service']:
         """This plan's services mapping (maps service name to Service).
 
         This property is currently read-only.
@@ -537,20 +731,21 @@ def services(self):
         return self._services
 
     @property
-    def checks(self):
+    def checks(self) -> Dict[str, 'Check']:
         """This plan's checks mapping (maps check name to :class:`Check`).
 
         This property is currently read-only.
         """
         return self._checks
 
-    def to_dict(self) -> typing.Dict[str, typing.Any]:
+    def to_dict(self) -> '_PlanDict':
         """Convert this plan to its dict representation."""
         fields = [
             ('services', {name: service.to_dict() for name, service in self._services.items()}),
             ('checks', {name: check.to_dict() for name, check in self._checks.items()}),
         ]
-        return {name: value for name, value in fields if value}
+        dct = {name: value for name, value in fields if value}
+        return typing.cast('_PlanDict', dct)
 
     def to_yaml(self) -> str:
         """Return this plan's YAML representation."""
@@ -575,25 +770,29 @@ class Layer:
     # This is how you do type annotations, but it is not supported by Python 3.5
     # summary: str
     # description: str
-    # services: typing.Mapping[str, 'Service']
+    # services: Mapping[str, 'Service']
 
-    def __init__(self, raw: typing.Union[str, typing.Dict] = None):
+    def __init__(self, raw: Optional[Union[str, 'LayerDict']] = None):
         if isinstance(raw, str):
-            d = yaml.safe_load(raw) or {}
+            d = yaml.safe_load(raw) or {}  # type: ignore # (Any 'raw' type)
         else:
             d = raw or {}
-        self.summary = d.get('summary', '')
-        self.description = d.get('description', '')
+        d = typing.cast('LayerDict', d)
+
+        self.summary = d.get('summary', '')  # type: str
+        self.description = d.get('description', '')  # type: str
         self.services = {name: Service(name, service)
-                         for name, service in d.get('services', {}).items()}
+                         for name, service in d.get('services', {}).items()
+                         }  # type: Dict[str, Service]
         self.checks = {name: Check(name, check)
-                       for name, check in d.get('checks', {}).items()}
+                       for name, check in d.get('checks', {}).items()
+                       }  # type: Dict[str, Check]
 
     def to_yaml(self) -> str:
         """Convert this layer to its YAML representation."""
         return yaml.safe_dump(self.to_dict())
 
-    def to_dict(self) -> typing.Dict[str, typing.Any]:
+    def to_dict(self) -> 'LayerDict':
         """Convert this layer to its dict representation."""
         fields = [
             ('summary', self.summary),
@@ -601,7 +800,8 @@ def to_dict(self) -> typing.Dict[str, typing.Any]:
             ('services', {name: service.to_dict() for name, service in self.services.items()}),
             ('checks', {name: check.to_dict() for name, check in self.checks.items()}),
         ]
-        return {name: value for name, value in fields if value}
+        dct = {name: value for name, value in fields if value}
+        return typing.cast('LayerDict', dct)
 
     def __repr__(self) -> str:
         return 'Layer({!r})'.format(self.to_dict())
@@ -612,30 +812,30 @@ def __repr__(self) -> str:
 class Service:
     """Represents a service description in a Pebble configuration layer."""
 
-    def __init__(self, name: str, raw: typing.Dict = None):
+    def __init__(self, name: str, raw: Optional['_ServiceDict'] = None):
         self.name = name
-        raw = raw or {}
-        self.summary = raw.get('summary', '')
-        self.description = raw.get('description', '')
-        self.startup = raw.get('startup', '')
-        self.override = raw.get('override', '')
-        self.command = raw.get('command', '')
-        self.after = list(raw.get('after', []))
-        self.before = list(raw.get('before', []))
-        self.requires = list(raw.get('requires', []))
-        self.environment = dict(raw.get('environment', {}))
-        self.user = raw.get('user', '')
-        self.user_id = raw.get('user-id')
-        self.group = raw.get('group', '')
-        self.group_id = raw.get('group-id')
-        self.on_success = raw.get('on-success', '')
-        self.on_failure = raw.get('on-failure', '')
-        self.on_check_failure = dict(raw.get('on-check-failure', {}))
-        self.backoff_delay = raw.get('backoff-delay', '')
-        self.backoff_factor = raw.get('backoff-factor')
-        self.backoff_limit = raw.get('backoff-limit', '')
-
-    def to_dict(self) -> typing.Dict[str, typing.Any]:
+        dct = raw or {}  # type: _ServiceDict
+        self.summary = dct.get('summary', '')
+        self.description = dct.get('description', '')
+        self.startup = dct.get('startup', '')
+        self.override = dct.get('override', '')
+        self.command = dct.get('command', '')
+        self.after = list(dct.get('after', []))
+        self.before = list(dct.get('before', []))
+        self.requires = list(dct.get('requires', []))
+        self.environment = dict(dct.get('environment', {}))
+        self.user = dct.get('user', '')
+        self.user_id = dct.get('user-id')
+        self.group = dct.get('group', '')
+        self.group_id = dct.get('group-id')
+        self.on_success = dct.get('on-success', '')
+        self.on_failure = dct.get('on-failure', '')
+        self.on_check_failure = dict(dct.get('on-check-failure', {}))
+        self.backoff_delay = dct.get('backoff-delay', '')
+        self.backoff_factor = dct.get('backoff-factor')
+        self.backoff_limit = dct.get('backoff-limit', '')
+
+    def to_dict(self) -> '_ServiceDict':
         """Convert this service object to its dict representation."""
         fields = [
             ('summary', self.summary),
@@ -658,7 +858,8 @@ def to_dict(self) -> typing.Dict[str, typing.Any]:
             ('backoff-factor', self.backoff_factor),
             ('backoff-limit', self.backoff_limit),
         ]
-        return {name: value for name, value in fields if value}
+        dct = {name: value for name, value in fields if value}
+        return typing.cast('_ServiceDict', dct)
 
     def _merge(self, other: 'Service'):
         """Merges this service object with another service definition.
@@ -679,7 +880,7 @@ def _merge(self, other: 'Service'):
     def __repr__(self) -> str:
         return 'Service({!r})'.format(self.to_dict())
 
-    def __eq__(self, other: typing.Union[typing.Dict, 'Service']) -> bool:
+    def __eq__(self, other: Union['_ServiceDict', 'Service']) -> bool:
         """Compare this service description to another."""
         if isinstance(other, dict):
             return self.to_dict() == other
@@ -712,8 +913,8 @@ class ServiceInfo:
     def __init__(
         self,
         name: str,
-        startup: typing.Union[ServiceStartup, str],
-        current: typing.Union[ServiceStatus, str],
+        startup: Union[ServiceStartup, str],
+        current: Union[ServiceStatus, str],
     ):
         self.name = name
         self.startup = startup
@@ -724,7 +925,7 @@ def is_running(self) -> bool:
         return self.current == ServiceStatus.ACTIVE
 
     @classmethod
-    def from_dict(cls, d: typing.Dict) -> 'ServiceInfo':
+    def from_dict(cls, d: '_ServiceInfoDict') -> 'ServiceInfo':
         """Create new ServiceInfo object from dict parsed from JSON."""
         try:
             startup = ServiceStartup(d['startup'])
@@ -751,38 +952,40 @@ def __repr__(self):
 class Check:
     """Represents a check in a Pebble configuration layer."""
 
-    def __init__(self, name: str, raw: typing.Dict = None):
+    def __init__(self, name: str, raw: Optional['_CheckDict'] = None):
         self.name = name
-        raw = raw or {}
-        self.override = raw.get('override', '')
+        dct = raw or {}  # type: _CheckDict
+        self.override = dct.get('override', '')  # type: str
         try:
-            self.level = CheckLevel(raw.get('level', ''))
+            level = CheckLevel(dct.get('level', ''))  # type: Union[CheckLevel, str]
         except ValueError:
-            self.level = raw.get('level')
-        self.period = raw.get('period', '')
-        self.timeout = raw.get('timeout', '')
-        self.threshold = raw.get('threshold')
+            level = dct.get('level', '')
+        self.level = level
+        self.period = dct.get('period', '')  # type: Optional[str]
+        self.timeout = dct.get('timeout', '')  # type: Optional[str]
+        self.threshold = dct.get('threshold')  # type: Optional[int]
 
-        http = raw.get('http')
+        http = dct.get('http')
         if http is not None:
             http = copy.deepcopy(http)
-        self.http = http
+        self.http = http  # type: Optional[_HttpDict]
 
-        tcp = raw.get('tcp')
+        tcp = dct.get('tcp')
         if tcp is not None:
             tcp = copy.deepcopy(tcp)
-        self.tcp = tcp
+        self.tcp = tcp  # type: Optional[_TcpDict]
 
-        exec_ = raw.get('exec')
+        exec_ = dct.get('exec')
         if exec_ is not None:
             exec_ = copy.deepcopy(exec_)
-        self.exec = exec_
+        self.exec = exec_  # type: Optional[_ExecDict]
 
-    def to_dict(self) -> typing.Dict[str, typing.Any]:
+    def to_dict(self) -> '_CheckDict':
         """Convert this check object to its dict representation."""
+        level = self.level.value if isinstance(self.level, CheckLevel) else self.level  # type: str
         fields = [
             ('override', self.override),
-            ('level', self.level.value),
+            ('level', level),
             ('period', self.period),
             ('timeout', self.timeout),
             ('threshold', self.threshold),
@@ -790,14 +993,15 @@ def to_dict(self) -> typing.Dict[str, typing.Any]:
             ('tcp', self.tcp),
             ('exec', self.exec),
         ]
-        return {name: value for name, value in fields if value}
+        dct = {name: value for name, value in fields if value}
+        return typing.cast('_CheckDict', dct)
 
     def __repr__(self) -> str:
         return 'Check({!r})'.format(self.to_dict())
 
-    def __eq__(self, other: typing.Union[typing.Dict, 'Check']) -> bool:
+    def __eq__(self, other: Union['_CheckDict', 'Check']) -> bool:
         """Compare this check configuration to another."""
-        if isinstance(other, dict):
+        if isinstance(other, dict):  # pyright: reportUnnecessaryComparison=false
             return self.to_dict() == other
         elif isinstance(other, Check):
             return self.to_dict() == other.to_dict()
@@ -839,14 +1043,14 @@ def __init__(
         self,
         path: str,
         name: str,
-        type: typing.Union['FileType', str],
-        size: typing.Optional[int],
+        type: Union['FileType', str],
+        size: Optional[int],
         permissions: int,
         last_modified: datetime.datetime,
-        user_id: typing.Optional[int],
-        user: typing.Optional[str],
-        group_id: typing.Optional[int],
-        group: typing.Optional[str],
+        user_id: Optional[int],
+        user: Optional[str],
+        group_id: Optional[int],
+        group: Optional[str],
     ):
         self.path = path
         self.name = name
@@ -860,7 +1064,7 @@ def __init__(
         self.group = group
 
     @classmethod
-    def from_dict(cls, d: typing.Dict) -> 'FileInfo':
+    def from_dict(cls, d: '_FileInfoDict') -> 'FileInfo':
         """Create new FileInfo object from dict parsed from JSON."""
         try:
             file_type = FileType(d['type'])
@@ -916,8 +1120,8 @@ class CheckInfo:
     def __init__(
         self,
         name: str,
-        level: typing.Optional[typing.Union[CheckLevel, str]],
-        status: typing.Union[CheckStatus, str],
+        level: Optional[Union[CheckLevel, str]],
+        status: Union[CheckStatus, str],
         failures: int = 0,
         threshold: int = 0,
     ):
@@ -928,7 +1132,7 @@ def __init__(
         self.threshold = threshold
 
     @classmethod
-    def from_dict(cls, d: typing.Dict) -> 'CheckInfo':
+    def from_dict(cls, d: '_InfoDict') -> 'CheckInfo':
         """Create new :class:`CheckInfo` object from dict parsed from JSON."""
         try:
             level = CheckLevel(d.get('level', ''))
@@ -985,20 +1189,20 @@ class ExecProcess:
 
     def __init__(
         self,
-        stdin: typing.Optional[typing.Union[typing.TextIO, typing.BinaryIO]],
-        stdout: typing.Optional[typing.Union[typing.TextIO, typing.BinaryIO]],
-        stderr: typing.Optional[typing.Union[typing.TextIO, typing.BinaryIO]],
+        stdin: Optional['_Readable'],
+        stdout: Optional['_Writeable'],
+        stderr: Optional['_Writeable'],
         client: 'Client',
-        timeout: typing.Optional[float],
+        timeout: Optional[float],
         control_ws: websocket.WebSocket,
         stdio_ws: websocket.WebSocket,
-        stderr_ws: websocket.WebSocket,
-        command: typing.List[str],
-        encoding: typing.Optional[str],
+        stderr_ws: Optional[websocket.WebSocket],
+        command: List[str],
+        encoding: Optional[str],
         change_id: ChangeID,
-        cancel_stdin: typing.Callable[[], None],
-        cancel_reader: typing.Optional[int],
-        threads: typing.List[threading.Thread],
+        cancel_stdin: Optional[Callable[[], None]],
+        cancel_reader: Optional[int],
+        threads: List[threading.Thread],
     ):
         self.stdin = stdin
         self.stdout = stdout
@@ -1035,7 +1239,7 @@ def wait(self):
         if exit_code != 0:
             raise ExecError(self._command, exit_code, None, None)
 
-    def _wait(self):
+    def _wait(self) -> int:
         self._waited = True
         timeout = self._timeout
         if timeout is not None:
@@ -1070,7 +1274,7 @@ def _wait(self):
             exit_code = change.tasks[0].data.get('exit-code', -1)
         return exit_code
 
-    def wait_output(self) -> typing.Tuple[typing.AnyStr, typing.AnyStr]:
+    def wait_output(self) -> Tuple['_StrOrBytes', Optional['_StrOrBytes']]:
         """Wait for the process to finish and return tuple of (stdout, stderr).
 
         If a timeout was specified to the :meth:`Client.exec` call, this waits
@@ -1095,16 +1299,16 @@ def wait_output(self) -> typing.Tuple[typing.AnyStr, typing.AnyStr]:
             t = _start_thread(shutil.copyfileobj, self.stderr, err)
             self._threads.append(t)
 
-        exit_code = self._wait()
+        exit_code = self._wait()  # type: int
 
-        out_value = out.getvalue()
-        err_value = err.getvalue() if err is not None else None
+        out_value = out.getvalue()  # type: '_StrOrBytes'
+        err_value = err.getvalue() if err is not None else None  # type: Optional['_StrOrBytes']
         if exit_code != 0:
             raise ExecError(self._command, exit_code, out_value, err_value)
 
         return (out_value, err_value)
 
-    def send_signal(self, sig: typing.Union[int, str]):
+    def send_signal(self, sig: Union[int, str]):
         """Send the given signal to the running process.
 
         Args:
@@ -1118,13 +1322,13 @@ def send_signal(self, sig: typing.Union[int, str]):
             'signal': {'name': sig},
         }
         msg = json.dumps(payload, sort_keys=True)
-        self._control_ws.send(msg)
+        self._control_ws.send(msg)  # type: ignore
 
 
-def _has_fileno(f):
+def _has_fileno(f: Any) -> bool:
     """Return True if the file-like object has a valid fileno() method."""
     try:
-        f.fileno()
+        f.fileno()  # type: ignore # noqa
         return True
     except Exception:
         # Some types define a fileno method that raises io.UnsupportedOperation,
@@ -1132,7 +1336,11 @@ def _has_fileno(f):
         return False
 
 
-def _reader_to_websocket(reader, ws, encoding, cancel_reader=None, bufsize=16 * 1024):
+def _reader_to_websocket(reader: '_WebsocketReader',
+                         ws: websocket.WebSocket,
+                         encoding: str,
+                         cancel_reader: Optional[int] = None,
+                         bufsize: int = 16 * 1024):
     """Read reader through to EOF and send each chunk read to the websocket."""
     while True:
         if cancel_reader is not None:
@@ -1146,15 +1354,16 @@ def _reader_to_websocket(reader, ws, encoding, cancel_reader=None, bufsize=16 *
             break
         if isinstance(chunk, str):
             chunk = chunk.encode(encoding)
-        ws.send_binary(chunk)
+        ws.send_binary(chunk)  # type: ignore
 
-    ws.send('{"command":"end"}')  # Send "end" command as TEXT frame to signal EOF
+    ws.send('{"command":"end"}')  # type: ignore # Send "end" command as TEXT frame to signal EOF
 
 
-def _websocket_to_writer(ws, writer, encoding):
+def _websocket_to_writer(ws: websocket.WebSocket, writer: '_WebsocketWriter',
+                         encoding: str):
     """Receive messages from websocket (until end signal) and write to writer."""
     while True:
-        chunk = ws.recv()
+        chunk = ws.recv()  # type: '_StrOrBytes'
 
         if isinstance(chunk, str):
             try:
@@ -1179,45 +1388,45 @@ def _websocket_to_writer(ws, writer, encoding):
 class _WebsocketWriter(io.BufferedIOBase):
     """A writable file-like object that sends what's written to it to a websocket."""
 
-    def __init__(self, ws):
+    def __init__(self, ws: websocket.WebSocket):
         self.ws = ws
 
     def writable(self):
         """Denote this file-like object as writable."""
         return True
 
-    def write(self, chunk):
+    def write(self, chunk: '_StrOrBytes') -> int:
         """Write chunk to the websocket."""
         if not isinstance(chunk, bytes):
             raise TypeError('value to write must be bytes, not {}'.format(type(chunk).__name__))
-        self.ws.send_binary(chunk)
+        self.ws.send_binary(chunk)  # type: ignore
         return len(chunk)
 
     def close(self):
         """Send end-of-file message to websocket."""
-        self.ws.send('{"command":"end"}')
+        self.ws.send('{"command":"end"}')  # type: ignore
 
 
 class _WebsocketReader(io.BufferedIOBase):
     """A readable file-like object whose reads come from a websocket."""
 
-    def __init__(self, ws):
+    def __init__(self, ws: websocket.WebSocket):
         self.ws = ws
         self.remaining = b''
         self.eof = False
 
-    def readable(self):
+    def readable(self) -> bool:
         """Denote this file-like object as readable."""
         return True
 
-    def read(self, n=-1):
+    def read(self, n: int = -1) -> '_StrOrBytes':
         """Read up to n bytes from the websocket (or one message if n<0)."""
         if self.eof:
             # Calling read() multiple times after EOF should still return EOF
             return b''
 
         while not self.remaining:
-            chunk = self.ws.recv()
+            chunk = self.ws.recv()  # type: '_StrOrBytes'
 
             if isinstance(chunk, str):
                 try:
@@ -1239,11 +1448,11 @@ def read(self, n=-1):
 
         if n < 0:
             n = len(self.remaining)
-        result = self.remaining[:n]
+        result = self.remaining[:n]  # type: '_StrOrBytes'
         self.remaining = self.remaining[n:]
         return result
 
-    def read1(self, n=-1):
+    def read1(self, n: int = -1) -> '_StrOrBytes':
         """An alias for read."""
         return self.read(n)
 
@@ -1253,15 +1462,19 @@ class Client:
 
     _chunk_size = 8192
 
-    def __init__(self, socket_path=None, opener=None, base_url='http://localhost', timeout=5.0):
+    def __init__(self, socket_path: str,
+                 opener: Optional[urllib.request.OpenerDirector] = None,
+                 base_url: str = 'http://localhost',
+                 timeout: float = 5.0):
         """Initialize a client instance.
 
         Defaults to using a Unix socket at socket_path (which must be specified
         unless a custom opener is provided).
         """
+        if not isinstance(socket_path, str):
+            raise TypeError('`socket_path` should be a string, '
+                            'not: {}'.format(type(socket_path)))
         if opener is None:
-            if socket_path is None:
-                raise ValueError('no socket path provided')
             opener = self._get_default_opener(socket_path)
         self.socket_path = socket_path
         self.opener = opener
@@ -1269,7 +1482,7 @@ def __init__(self, socket_path=None, opener=None, base_url='http://localhost', t
         self.timeout = timeout
 
     @classmethod
-    def _get_default_opener(cls, socket_path):
+    def _get_default_opener(cls, socket_path: str) -> urllib.request.OpenerDirector:
         """Build the default opener to use for requests (HTTP over Unix socket)."""
         opener = urllib.request.OpenerDirector()
         opener.add_handler(_UnixSocketHandler(socket_path))
@@ -1278,9 +1491,13 @@ def _get_default_opener(cls, socket_path):
         opener.add_handler(urllib.request.HTTPErrorProcessor())
         return opener
 
-    def _request(
-        self, method: str, path: str, query: typing.Dict = None, body: typing.Dict = None,
-    ) -> typing.Dict:
+    # we need to cast the return type depending on the request params
+    def _request(self,
+                 method: str,
+                 path: str,
+                 query: Optional[Dict[str, Any]] = None,
+                 body: Optional[Dict[str, Any]] = None
+                 ) -> Dict[str, Any]:
         """Make a JSON request to the Pebble server with the given HTTP method and path.
 
         If query dict is provided, it is encoded and appended as a query string
@@ -1296,10 +1513,12 @@ def _request(
 
         response = self._request_raw(method, path, query, headers, data)
         self._ensure_content_type(response.headers, 'application/json')
-        return _json_loads(response.read())
+        raw_resp = _json_loads(response.read())  # type: Dict[str, Any]
+        return raw_resp
 
     @staticmethod
-    def _ensure_content_type(headers, expected):
+    def _ensure_content_type(headers: 'Message',
+                             expected: 'Literal["multipart/form-data", "application/json"]'):
         """Parse Content-Type header from headers and ensure it's equal to expected.
 
         Return a dict of any options in the header, e.g., {'boundary': ...}.
@@ -1310,8 +1529,10 @@ def _ensure_content_type(headers, expected):
         return options
 
     def _request_raw(
-        self, method: str, path: str, query: typing.Dict = None, headers: typing.Dict = None,
-        data: bytes = None,
+        self, method: str, path: str,
+        query: Optional[Dict[str, Any]] = None,
+        headers: Optional[Dict[str, Any]] = None,
+        data: Optional[Union[bytes, Generator[bytes, Any, Any]]] = None,
     ) -> http.client.HTTPResponse:
         """Make a request to the Pebble server; return the raw HTTPResponse object."""
         url = self.base_url + path
@@ -1333,11 +1554,11 @@ def _request_raw(
             code = e.code
             status = e.reason
             try:
-                body = _json_loads(e.read())
-                message = body['result']['message']
+                body = _json_loads(e.read())  # type: Dict[str, Any]
+                message = body['result']['message']  # type: str
             except (IOError, ValueError, KeyError) as e2:
                 # Will only happen on read error or if Pebble sends invalid JSON.
-                body = {}
+                body = {}  # type: Dict[str, Any]
                 message = '{} - {}'.format(type(e2).__name__, e2)
             raise APIError(body, code, status, message)
         except urllib.error.URLError as e:
@@ -1350,7 +1571,7 @@ def get_system_info(self) -> SystemInfo:
         resp = self._request('GET', '/v1/system-info')
         return SystemInfo.from_dict(resp['result'])
 
-    def get_warnings(self, select: WarningState = WarningState.PENDING) -> typing.List[Warning]:
+    def get_warnings(self, select: WarningState = WarningState.PENDING) -> List[Warning]:
         """Get list of warnings in given state (pending or all)."""
         query = {'select': select.value}
         resp = self._request('GET', '/v1/warnings', query)
@@ -1363,10 +1584,10 @@ def ack_warnings(self, timestamp: datetime.datetime) -> int:
         return resp['result']
 
     def get_changes(
-        self, select: ChangeState = ChangeState.IN_PROGRESS, service: str = None,
-    ) -> typing.List[Change]:
+        self, select: ChangeState = ChangeState.IN_PROGRESS, service: Optional[str] = None,
+    ) -> List[Change]:
         """Get list of changes in given state, filter by service name if given."""
-        query = {'select': select.value}
+        query = {'select': select.value}  # type: Dict[str, Union[str, int]]
         if service is not None:
             query['for'] = service
         resp = self._request('GET', '/v1/changes', query)
@@ -1418,7 +1639,7 @@ def replan_services(self, timeout: float = 30.0, delay: float = 0.1) -> ChangeID
         return self._services_action('replan', [], timeout, delay)
 
     def start_services(
-        self, services: typing.List[str], timeout: float = 30.0, delay: float = 0.1,
+        self, services: Iterable[str], timeout: float = 30.0, delay: float = 0.1,
     ) -> ChangeID:
         """Start services by name and wait (poll) for them to be started.
 
@@ -1438,7 +1659,7 @@ def start_services(
         return self._services_action('start', services, timeout, delay)
 
     def stop_services(
-        self, services: typing.List[str], timeout: float = 30.0, delay: float = 0.1,
+        self, services: Iterable[str], timeout: float = 30.0, delay: float = 0.1,
     ) -> ChangeID:
         """Stop services by name and wait (poll) for them to be started.
 
@@ -1458,7 +1679,7 @@ def stop_services(
         return self._services_action('stop', services, timeout, delay)
 
     def restart_services(
-        self, services: typing.List[str], timeout: float = 30.0, delay: float = 0.1,
+        self, services: Iterable[str], timeout: float = 30.0, delay: float = 0.1,
     ) -> ChangeID:
         """Restart services by name and wait (poll) for them to be started.
 
@@ -1478,11 +1699,15 @@ def restart_services(
         return self._services_action('restart', services, timeout, delay)
 
     def _services_action(
-        self, action: str, services: typing.Iterable[str], timeout: float, delay: float,
+        self, action: str, services: Iterable[str], timeout: Optional[float],
+            delay: float,
     ) -> ChangeID:
-        if not isinstance(services, (list, tuple)):
-            raise TypeError('services must be a list of str, not {}'.format(
-                type(services).__name__))
+        if isinstance(services, (str, bytes)) or not hasattr(services, '__iter__'):
+            raise TypeError(
+                'services must be of type Iterable[str], not {}'.format(
+                    type(services).__name__))
+
+        services = list(services)
         for s in services:
             if not isinstance(s, str):
                 raise TypeError('service names must be str, not {}'.format(type(s).__name__))
@@ -1497,7 +1722,9 @@ def _services_action(
         return change_id
 
     def wait_change(
-        self, change_id: ChangeID, timeout: float = 30.0, delay: float = 0.1,
+        self, change_id: ChangeID,
+        timeout: Optional[float] = 30.0,
+        delay: float = 0.1,
     ) -> Change:
         """Wait for the given change to be ready.
 
@@ -1508,7 +1735,7 @@ def wait_change(
         Args:
             change_id: Change ID of change to wait for.
             timeout: Maximum time in seconds to wait for the change to be
-                ready. May be None, in which case wait_change never times out.
+                ready. It may be None, in which case wait_change never times out.
             delay: If polling, this is the delay in seconds between attempts.
 
         Returns:
@@ -1523,9 +1750,9 @@ def wait_change(
             # Pebble server doesn't support wait endpoint, fall back to polling
             return self._wait_change_using_polling(change_id, timeout, delay)
 
-    def _wait_change_using_wait(self, change_id, timeout):
+    def _wait_change_using_wait(self, change_id: ChangeID, timeout: Optional[float]):
         """Wait for a change to be ready using the wait-change API."""
-        deadline = time.time() + timeout if timeout is not None else None
+        deadline = time.time() + timeout if timeout is not None else 0
 
         # Hit the wait endpoint every Client.timeout-1 seconds to avoid long
         # requests (the -1 is to ensure it wakes up before the socket timeout)
@@ -1547,7 +1774,7 @@ def _wait_change_using_wait(self, change_id, timeout):
         raise TimeoutError('timed out waiting for change {} ({} seconds)'.format(
             change_id, timeout))
 
-    def _wait_change(self, change_id: ChangeID, timeout: float = None) -> Change:
+    def _wait_change(self, change_id: ChangeID, timeout: Optional[float] = None) -> Change:
         """Call the wait-change API endpoint directly."""
         query = {}
         if timeout is not None:
@@ -1565,9 +1792,10 @@ def _wait_change(self, change_id: ChangeID, timeout: float = None) -> Change:
 
         return Change.from_dict(resp['result'])
 
-    def _wait_change_using_polling(self, change_id, timeout, delay):
+    def _wait_change_using_polling(self, change_id: ChangeID, timeout: Optional[float],
+                                   delay: float):
         """Wait for a change to be ready by polling the get-change API."""
-        deadline = time.time() + timeout if timeout is not None else None
+        deadline = time.time() + timeout if timeout is not None else 0
 
         while timeout is None or time.time() < deadline:
             change = self.get_change(change_id)
@@ -1580,7 +1808,8 @@ def _wait_change_using_polling(self, change_id, timeout, delay):
             change_id, timeout))
 
     def add_layer(
-            self, label: str, layer: typing.Union[str, dict, Layer], *, combine: bool = False):
+            self, label: str, layer: Union[str, 'LayerDict', Layer], *,
+            combine: bool = False):
         """Dynamically add a new layer onto the Pebble configuration layers.
 
         If combine is False (the default), append the new layer as the top
@@ -1615,7 +1844,7 @@ def get_plan(self) -> Plan:
         resp = self._request('GET', '/v1/plan', {'format': 'yaml'})
         return Plan(resp['result'])
 
-    def get_services(self, names: typing.List[str] = None) -> typing.List[ServiceInfo]:
+    def get_services(self, names: Optional[Iterable[str]] = None) -> List[ServiceInfo]:
         """Get the service status for the configured services.
 
         If names is specified, only fetch the service status for the services
@@ -1630,8 +1859,7 @@ def get_services(self, names: typing.List[str] = None) -> typing.List[ServiceInf
     def pull(self,
              path: str,
              *,
-             encoding: typing.Optional[str] = 'utf-8') -> typing.Union[typing.BinaryIO,
-                                                                       typing.TextIO]:
+             encoding: Optional[str] = 'utf-8') -> Union[BinaryIO, TextIO]:
         """Read a file's content from the remote system.
 
         Args:
@@ -1687,12 +1915,13 @@ def pull(self,
         # removing opened files, and so we use the tempfile lib's
         # helper class to auto-delete on close/gc for us.
         if os.name != 'posix' or sys.platform == 'cygwin':
-            return tempfile._TemporaryFileWrapper(f, f.name, delete=True)
+            return tempfile._TemporaryFileWrapper(  # type: ignore
+                f, f.name, delete=True)  # type: ignore
         parser.remove_files()
         return f
 
     @staticmethod
-    def _raise_on_path_error(resp, path):
+    def _raise_on_path_error(resp: '_FilesResponse', path: str):
         result = resp['result'] or []  # in case it's null instead of []
         paths = {item['path']: item for item in result}
         if path not in paths:
@@ -1702,9 +1931,13 @@ def _raise_on_path_error(resp, path):
             raise PathError(error['kind'], error['message'])
 
     def push(
-            self, path: str, source: typing.Union[bytes, str, typing.BinaryIO, typing.TextIO], *,
-            encoding: str = 'utf-8', make_dirs: bool = False, permissions: int = None,
-            user_id: int = None, user: str = None, group_id: int = None, group: str = None):
+            self, path: str, source: '_IOSource', *,
+            encoding: str = 'utf-8', make_dirs: bool = False,
+            permissions: Optional[int] = None,
+            user_id: Optional[int] = None,
+            user: Optional[str] = None,
+            group_id: Optional[int] = None,
+            group: Optional[str] = None):
         """Write content to a given file path on the remote system.
 
         Args:
@@ -1742,11 +1975,16 @@ def push(
         response = self._request_raw('POST', '/v1/files', None, headers, data)
         self._ensure_content_type(response.headers, 'application/json')
         resp = _json_loads(response.read())
-        self._raise_on_path_error(resp, path)
+        # we need to cast the Dict[Any, Any] to _FilesResponse
+        self._raise_on_path_error(typing.cast('_FilesResponse', resp), path)
 
     @staticmethod
-    def _make_auth_dict(permissions, user_id, user, group_id, group) -> typing.Dict:
-        d = {}
+    def _make_auth_dict(permissions: Optional[int],
+                        user_id: Optional[int],
+                        user: Optional[str],
+                        group_id: Optional[int],
+                        group: Optional[str]) -> '_AuthDict':
+        d = {}  # type: _AuthDict
         if permissions is not None:
             d['permissions'] = format(permissions, '03o')
         if user_id is not None:
@@ -1759,20 +1997,21 @@ def _make_auth_dict(permissions, user_id, user, group_id, group) -> typing.Dict:
             d['group'] = group
         return d
 
-    def _encode_multipart(self, metadata, path, source, encoding):
+    def _encode_multipart(self, metadata: Dict[str, Any], path: str,
+                          source: '_IOSource', encoding: str):
         # Python's stdlib mime/multipart handling is screwy and doesn't handle
         # binary properly, so roll our own.
-
         if isinstance(source, str):
-            source = io.StringIO(source)
+            source_io = io.StringIO(source)  # type: _AnyStrFileLikeIO
         elif isinstance(source, bytes):
-            source = io.BytesIO(source)
-
+            source_io = io.BytesIO(source)  # type: _AnyStrFileLikeIO
+        else:
+            source_io = source  # type: _AnyStrFileLikeIO
         boundary = binascii.hexlify(os.urandom(16))
         path_escaped = path.replace('"', '\\"').encode('utf-8')  # NOQA: test_quote_backslashes
         content_type = 'multipart/form-data; boundary="' + boundary.decode('utf-8') + '"'
 
-        def generator():
+        def generator() -> Generator[bytes, None, None]:
             yield b''.join([
                 b'--', boundary, b'\r\n',
                 b'Content-Type: application/json\r\n',
@@ -1786,12 +2025,12 @@ def generator():
                 b'\r\n',
             ])
 
-            content = source.read(self._chunk_size)
+            content = source_io.read(self._chunk_size)  # type: '_StrOrBytes'
             while content:
                 if isinstance(content, str):
                     content = content.encode(encoding)
                 yield content
-                content = source.read(self._chunk_size)
+                content = source_io.read(self._chunk_size)
 
             yield b''.join([
                 b'\r\n',
@@ -1800,8 +2039,8 @@ def generator():
 
         return generator(), content_type
 
-    def list_files(self, path: str, *, pattern: str = None,
-                   itself: bool = False) -> typing.List[FileInfo]:
+    def list_files(self, path: str, *, pattern: Optional[str] = None,
+                   itself: bool = False) -> List[FileInfo]:
         """Return list of directory entries from given path on remote system.
 
         Despite the name, this method returns a list of files *and*
@@ -1828,8 +2067,12 @@ def list_files(self, path: str, *, pattern: str = None,
         return [FileInfo.from_dict(d) for d in result]
 
     def make_dir(
-            self, path: str, *, make_parents: bool = False, permissions: int = None,
-            user_id: int = None, user: str = None, group_id: int = None, group: str = None):
+            self, path: str, *, make_parents: bool = False,
+            permissions: Optional[int] = None,
+            user_id: Optional[int] = None,
+            user: Optional[str] = None,
+            group_id: Optional[int] = None,
+            group: Optional[str] = None):
         """Create a directory on the remote system with the given attributes.
 
         Args:
@@ -1853,7 +2096,7 @@ def make_dir(
             'dirs': [info],
         }
         resp = self._request('POST', '/v1/files', None, body)
-        self._raise_on_path_error(resp, path)
+        self._raise_on_path_error(typing.cast('_FilesResponse', resp), path)
 
     def remove_path(self, path: str, *, recursive: bool = False):
         """Remove a file or directory on the remote system.
@@ -1866,7 +2109,7 @@ def remove_path(self, path: str, *, recursive: bool = False):
                        to `rm -rf <file|dir>`
 
         """
-        info = {'path': path}
+        info = {'path': path}  # type: Dict[str, Any]
         if recursive:
             info['recursive'] = True
         body = {
@@ -1874,23 +2117,23 @@ def remove_path(self, path: str, *, recursive: bool = False):
             'paths': [info],
         }
         resp = self._request('POST', '/v1/files', None, body)
-        self._raise_on_path_error(resp, path)
+        self._raise_on_path_error(typing.cast('_FilesResponse', resp), path)
 
     def exec(
         self,
-        command: typing.List[str],
+        command: List[str],
         *,
-        environment: typing.Dict[str, str] = None,
-        working_dir: str = None,
-        timeout: float = None,
-        user_id: int = None,
-        user: str = None,
-        group_id: int = None,
-        group: str = None,
-        stdin: typing.Union[str, bytes, typing.TextIO, typing.BinaryIO] = None,
-        stdout: typing.Union[typing.TextIO, typing.BinaryIO] = None,
-        stderr: typing.Union[typing.TextIO, typing.BinaryIO] = None,
-        encoding: str = 'utf-8',
+        environment: Optional[Dict[str, str]] = None,
+        working_dir: Optional[str] = None,
+        timeout: Optional[float] = None,
+        user_id: Optional[int] = None,
+        user: Optional[str] = None,
+        group_id: Optional[int] = None,
+        group: Optional[str] = None,
+        stdin: Optional['_IOSource'] = None,
+        stdout: Optional['_TextOrBinaryIO'] = None,
+        stderr: Optional['_TextOrBinaryIO'] = None,
+        encoding: Optional[str] = 'utf-8',
         combine_stderr: bool = False
     ) -> ExecProcess:
         r"""Execute the given command on the remote system.
@@ -2038,7 +2281,7 @@ def exec(
         change_id = resp['change']
         task_id = resp['result']['task-id']
 
-        stderr_ws = None
+        stderr_ws = None  # type: Optional[websocket.WebSocket]
         try:
             control_ws = self._connect_websocket(task_id, 'control')
             stdio_ws = self._connect_websocket(task_id, 'stdio')
@@ -2052,9 +2295,9 @@ def exec(
                 raise ChangeError(change.err, change)
             raise ConnectionError('unexpected error connecting to websockets: {}'.format(e))
 
-        cancel_stdin = None
-        cancel_reader = None
-        threads = []
+        cancel_stdin = None  # type: Optional[Callable[[], None]]
+        cancel_reader = None  # type: Optional[int]
+        threads = []  # type: List[threading.Thread]
 
         if stdin is not None:
             if _has_fileno(stdin):
@@ -2066,9 +2309,10 @@ def exec(
                 # to cancel_writer it'll trigger the select and end the thread.
                 cancel_reader, cancel_writer = os.pipe()
 
-                def cancel_stdin():
+                def _cancel_stdin():
                     os.write(cancel_writer, b'x')  # doesn't matter what we write
                     os.close(cancel_writer)
+                cancel_stdin = _cancel_stdin
 
             t = _start_thread(_reader_to_websocket, stdin, stdio_ws, encoding, cancel_reader)
             threads.append(t)
@@ -2076,7 +2320,8 @@ def cancel_stdin():
         else:
             process_stdin = _WebsocketWriter(stdio_ws)
             if encoding is not None:
-                process_stdin = io.TextIOWrapper(process_stdin, encoding=encoding, newline='')
+                process_stdin = io.TextIOWrapper(
+                    process_stdin, encoding=encoding, newline='')  # type: ignore
 
         if stdout is not None:
             t = _start_thread(_websocket_to_writer, stdio_ws, stdout, encoding)
@@ -2085,7 +2330,8 @@ def cancel_stdin():
         else:
             process_stdout = _WebsocketReader(stdio_ws)
             if encoding is not None:
-                process_stdout = io.TextIOWrapper(process_stdout, encoding=encoding, newline='')
+                process_stdout = io.TextIOWrapper(
+                    process_stdout, encoding=encoding, newline='')  # type: ignore
 
         process_stderr = None
         if not combine_stderr:
@@ -2093,15 +2339,16 @@ def cancel_stdin():
                 t = _start_thread(_websocket_to_writer, stderr_ws, stderr, encoding)
                 threads.append(t)
             else:
-                process_stderr = _WebsocketReader(stderr_ws)
+                ws = typing.cast(websocket.WebSocket, stderr_ws)
+                process_stderr = _WebsocketReader(ws)
                 if encoding is not None:
                     process_stderr = io.TextIOWrapper(
-                        process_stderr, encoding=encoding, newline='')
+                        process_stderr, encoding=encoding, newline='')  # type: ignore
 
         process = ExecProcess(
             stdin=process_stdin,
-            stdout=process_stdout,
-            stderr=process_stderr,
+            stdout=process_stdout,  # type: ignore # doesn't like _Writeable
+            stderr=process_stderr,  # type: ignore # doesn't like _Writeable
             client=self,
             timeout=timeout,
             stdio_ws=stdio_ws,
@@ -2121,7 +2368,7 @@ def _connect_websocket(self, task_id: str, websocket_id: str) -> websocket.WebSo
         sock.connect(self.socket_path)
         url = self._websocket_url(task_id, websocket_id)
         ws = websocket.WebSocket(skip_utf8_validation=True)
-        ws.connect(url, socket=sock)
+        ws.connect(url, socket=sock)  # type: ignore
         return ws
 
     def _websocket_url(self, task_id: str, websocket_id: str) -> str:
@@ -2129,7 +2376,7 @@ def _websocket_url(self, task_id: str, websocket_id: str) -> str:
         url = '{}/v1/tasks/{}/websocket/{}'.format(base_url, task_id, websocket_id)
         return url
 
-    def send_signal(self, sig: typing.Union[int, str], services: typing.List[str]):
+    def send_signal(self, sig: Union[int, str], services: Iterable[str]):
         """Send the given signal to the list of services named.
 
         Args:
@@ -2141,11 +2388,11 @@ def send_signal(self, sig: typing.Union[int, str], services: typing.List[str]):
             APIError: If any of the services are not in the plan or are not
                 currently running.
         """
-        if not isinstance(services, (list, tuple)):
-            raise TypeError('services must be a list of str, not {}'.format(
-                type(services).__name__))
+        if isinstance(services, (str, bytes)) or not hasattr(services, '__iter__'):
+            raise TypeError('services must be of type Iterable[str], '
+                            'not {}'.format(type(services).__name__))
         for s in services:
-            if not isinstance(s, str):
+            if not isinstance(s, str):  # pyright: reportUnnecessaryIsInstance=false
                 raise TypeError('service names must be str, not {}'.format(type(s).__name__))
 
         if isinstance(sig, int):
@@ -2158,9 +2405,9 @@ def send_signal(self, sig: typing.Union[int, str], services: typing.List[str]):
 
     def get_checks(
         self,
-        level: CheckLevel = None,
-        names: typing.List[str] = None
-    ) -> typing.List[CheckInfo]:
+        level: Optional[CheckLevel] = None,
+        names: Optional[Iterable[str]] = None
+    ) -> List[CheckInfo]:
         """Get the check status for the configured checks.
 
         Args:
@@ -2176,7 +2423,7 @@ def get_checks(
         if level is not None:
             query['level'] = level.value
         if names:
-            query['names'] = names
+            query['names'] = list(names)
         resp = self._request('GET', '/v1/checks', query)
         return [CheckInfo.from_dict(info) for info in resp['result']]
 
@@ -2184,17 +2431,17 @@ def get_checks(
 class _FilesParser:
     """A limited purpose multi-part parser backed by files for memory efficiency."""
 
-    def __init__(self, boundary: typing.Union[bytes, str]):
-        self._response = None
-        self._files = {}
+    def __init__(self, boundary: Union[bytes, str]):
+        self._response = None  # type: Optional[_FilesResponse] # externally managed
+        self._part_type = None  # type: Optional[Literal["response", "files"]] # externally managed
+        self._headers = None  # type: Optional['Message'] # externally managed
+        self._files = {}  # type: Dict[str, _Tempfile]
 
         # Prepare the MIME multipart boundary line patterns.
         if isinstance(boundary, str):
             boundary = boundary.encode()
 
         # State vars, as we may enter the feed() function multiple times.
-        self._headers = None
-        self._part_type = None
         self._response_data = bytearray()
 
         self._max_lookahead = 8 * 1024 * 1024
@@ -2230,15 +2477,16 @@ def _process_header(self, data: bytes):
             raise ProtocolError(
                 'unexpected name in content-disposition header: {!r}'.format(name))
 
-        self._part_type = name
+        self._part_type = typing.cast('Literal["response", "files"]', name)
 
-    def _process_body(self, data: bytes, done=False):
+    def _process_body(self, data: bytes, done: bool = False):
         if self._part_type == 'response':
             self._response_data.extend(data)
             if done:
                 if len(self._response_data) > self._max_lookahead:
                     raise ProtocolError('response end marker not found')
-                self._response = json.loads(self._response_data.decode())
+                resp = json.loads(self._response_data.decode())
+                self._response = typing.cast('_FilesResponse', resp)
                 self._response_data = bytearray()
         elif self._part_type == 'files':
             if done:
@@ -2262,15 +2510,15 @@ def feed(self, data: bytes):
         """Provide more data to the running parser."""
         self._parser.feed(data)
 
-    def _prepare_tempfile(self, filename):
+    def _prepare_tempfile(self, filename: str):
         tf = tempfile.NamedTemporaryFile(delete=False)
-        self._files[filename] = tf
+        self._files[filename] = tf  # type: ignore # we have a custom protocol for it
         self.current_filename = filename
 
     def _get_open_tempfile(self):
         return self._files[self.current_filename]
 
-    def get_response(self):
+    def get_response(self) -> Optional['_FilesResponse']:
         """Return the deserialized JSON object from the multipart "response" field."""
         return self._response
 
@@ -2278,21 +2526,24 @@ def filenames(self):
         """Return a list of filenames from the "files" parts of the response."""
         return list(self._files.keys())
 
-    def get_file(self, path, encoding):
+    def get_file(self, path: str, encoding: Optional[str]) -> '_TextOrBinaryIO':
         """Return an open file object containing the data."""
         mode = 'r' if encoding else 'rb'
         # We're using text-based file I/O purely for file encoding purposes, not for
         # newline normalization.  newline='' serves the line endings as-is.
         newline = '' if encoding else None
-        return open(self._files[path].name, mode, encoding=encoding, newline=newline)
+        file_io = open(self._files[path].name, mode,
+                       encoding=encoding, newline=newline)
+        # open() returns IO[Any]
+        return typing.cast('_TextOrBinaryIO', file_io)
 
 
 class _MultipartParser:
     def __init__(
             self,
             marker: bytes,
-            handle_header,
-            handle_body,
+            handle_header: '_HeaderHandler',
+            handle_body: '_BodyHandler',
             max_lookahead: int = 0,
             max_boundary_length: int = 0):
         r"""Configures a parser for mime multipart messages.
@@ -2363,7 +2614,7 @@ def feed(self, data: bytes):
                 self._pos = end
             else:
                 # parse the part body
-                ii, nn, self._done = _next_part_boundary(self._buf, self._marker, start=self._pos)
+                ii, _, self._done = _next_part_boundary(self._buf, self._marker, start=self._pos)
                 safe_bound = max(0, len(self._buf) - self._max_boundary_length)
                 if ii != -1:
                     # part body is finished
@@ -2382,7 +2633,8 @@ def feed(self, data: bytes):
                     return  # waiting for more data
 
 
-def _next_part_boundary(buf, marker, start=0):
+def _next_part_boundary(buf: bytes, marker: bytes, start: int = 0
+                        ) -> Tuple[int, int, bool]:
     """Returns the index of the next boundary marker in buf beginning at start.
 
     Returns:
diff --git a/ops/storage.py b/ops/storage.py
index 52f2ec50c..978dda87d 100755
--- a/ops/storage.py
+++ b/ops/storage.py
@@ -13,20 +13,23 @@
 # limitations under the License.
 
 """Structures to offer storage to the charm (through Juju or locally)."""
-
+import logging
+import os
 import pickle
 import shutil
 import sqlite3
 import subprocess
 import typing
 from datetime import timedelta
+from pathlib import Path
 from typing import Any, Callable, Generator, List, Optional, Tuple, Type, Union
 
-import yaml
+import yaml  # pyright: reportMissingModuleSource=false
+
+logger = logging.getLogger()
 
-if typing.TYPE_CHECKING:
-    from pathlib import Path
 
+if typing.TYPE_CHECKING:
     # _Notice = Tuple[event_path, observer_path, method_name]
     _Notice = Tuple[str, str, str]
     _Notices = List[_Notice]
@@ -53,6 +56,11 @@ class SQLiteStorage:
     def __init__(self, filename: Union['Path', str]):
         # The isolation_level argument is set to None such that the implicit
         # transaction management behavior of the sqlite3 module is disabled.
+
+        if not os.path.exists(str(filename)):
+            # sqlite3.connect creates the file silently if it does not exist
+            logger.debug("Initializing SQLite local storage: {}.".format(filename))
+
         self._db = sqlite3.connect(str(filename),
                                    isolation_level=None,
                                    timeout=self.DB_LOCK_TIMEOUT.total_seconds())
diff --git a/pyproject.toml b/pyproject.toml
index 32c623b6b..8b2b92665 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -32,11 +32,11 @@ per-file-ignores = ["test/*:D100,D101,D102,D103,D104"]
 docstring-convention = "google"
 
 [tool.pyright]
-exclude = ["ops/_vendor", ".git", "__pycache__", ".tox", "build", "test",
-    "ops/lib", "ops/_private", "dist", "*.egg_info", "venv", "ops/charm.py",
-    "ops/framework.py", "ops/main.py", "ops/pebble.py",
-    "ops/testing.py", "ops/__init__.py"]
+include = ["ops/jujuversion.py", "ops/log.py", "ops/model.py", "ops/version.py",
+    "ops/__init__.py", "ops/framework.py", "ops/pebble.py", "ops/charm.py",
+    "ops/storage.py", "ops/main.py", "ops/testing.py"]
 pythonVersion = "3.5" # check no python > 3.5 features are used
 pythonPlatform = "All"
 typeCheckingMode = "strict"
-reportIncompatibleMethodOverride = false
\ No newline at end of file
+reportIncompatibleMethodOverride = false
+reportImportCycles = false
\ No newline at end of file
diff --git a/requirements-dev.txt b/requirements-dev.txt
index c96490f29..45f23037f 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,2 +1,3 @@
 PyYAML
-typing_extensions==4.2.0
\ No newline at end of file
+typing_extensions==4.2.0
+pyright==1.1.257
\ No newline at end of file
diff --git a/tox.ini b/tox.ini
index 41fd49c39..715592161 100644
--- a/tox.ini
+++ b/tox.ini
@@ -57,7 +57,7 @@ commands =
 [testenv:static]
 description = Run static type checker
 deps =
-    pyright
+    pyright==1.1.264
 commands =
     pyright {posargs}
 


Test patch:
diff --git a/.github/workflows/framework-tests.yaml b/.github/workflows/framework-tests.yaml
index dacbd4f38..f45d4df62 100644
--- a/.github/workflows/framework-tests.yaml
+++ b/.github/workflows/framework-tests.yaml
@@ -1,6 +1,6 @@
 name: Operator Framework Tests
 
-on: [push, pull_request]
+on: [push, pull_request, workflow_call]
 
 jobs:
   lint:
@@ -37,7 +37,7 @@ jobs:
     strategy:
       matrix:
         os: [ubuntu-latest, macos-latest, windows-latest]
-        python-version: [3.5, 3.6, 3.7, 3.8, 3.9]
+        python-version: ["3.5", "3.6", "3.8", "3.10"]
 
     steps:
       - uses: actions/checkout@v2
@@ -58,7 +58,7 @@ jobs:
     strategy:
       matrix:
         os: [ubuntu-latest]
-        python-version: [3.5, 3.6, 3.7, 3.8, 3.9]
+        python-version: ["3.5", "3.6", "3.8", "3.10"]
 
     steps:
       - uses: actions/checkout@v2
@@ -70,7 +70,7 @@ jobs:
       - name: Set up Go 1.17
         uses: actions/setup-go@v1
         with:
-          go-version: 1.17
+          go-version: "1.17"
 
       - name: Install tox
         run: pip install tox
@@ -90,4 +90,3 @@ jobs:
         env:
           RUN_REAL_PEBBLE_TESTS: 1 
           PEBBLE: /tmp/pebble 
-
diff --git a/.github/workflows/hello-charm-tests.yaml b/.github/workflows/hello-charm-tests.yaml
index a7e368598..4dda1393d 100644
--- a/.github/workflows/hello-charm-tests.yaml
+++ b/.github/workflows/hello-charm-tests.yaml
@@ -1,6 +1,6 @@
 name: Hello Charm Tests
 
-on: [push, pull_request]
+on: [push, pull_request, workflow_call]
 
 jobs:
   hello-charm-tests:
@@ -16,7 +16,7 @@ jobs:
       - name: Set up Python 3.8
         uses: actions/setup-python@v2
         with:
-          python-version: 3.8
+          python-version: "3.8"
 
       - name: Checkout the ${{ matrix.charm-repo }} repository
         uses: actions/checkout@v2
diff --git a/.github/workflows/observability-charm-tests.yaml b/.github/workflows/observability-charm-tests.yaml
index f9709e7e9..66d73e7eb 100644
--- a/.github/workflows/observability-charm-tests.yaml
+++ b/.github/workflows/observability-charm-tests.yaml
@@ -1,6 +1,6 @@
 name: Observability Charm Tests
 
-on: [push, pull_request]
+on: [push, pull_request, workflow_call]
 
 jobs:
   o11y-charm-tests:
diff --git a/ops/testing.py b/ops/testing.py
index 27f823d95..d92609bdd 100755
--- a/ops/testing.py
+++ b/ops/testing.py
@@ -40,37 +40,94 @@
 import random
 import signal
 import tempfile
-import typing
 import uuid
 import warnings
 from contextlib import contextmanager
 from io import BytesIO, StringIO
 from textwrap import dedent
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    AnyStr,
+    BinaryIO,
+    Dict,
+    Generic,
+    Iterable,
+    Iterator,
+    List,
+    Mapping,
+    Optional,
+    Set,
+    TextIO,
+    Tuple,
+    Type,
+    TypeVar,
+    Union,
+    cast,
+)
 
 from ops import charm, framework, model, pebble, storage
 from ops._private import yaml
+from ops.charm import CharmBase, CharmMeta, RelationRole
+from ops.model import RelationNotFoundError
+
+if TYPE_CHECKING:
+    from typing_extensions import Literal, TypedDict
+
+    from ops.model import UnitOrApplication
+
+    ReadableBuffer = Union[bytes, str, StringIO, BytesIO, BinaryIO]
+    _StringOrPath = Union[str, pathlib.PurePosixPath, pathlib.Path]
+    _FileOrDir = Union['_File', '_Directory']
+    _FileKwargs = TypedDict('_FileKwargs', {
+        'permissions': int,
+        'last_modified': datetime.datetime,
+        'user_id': Optional[int],
+        'user': Optional[str],
+        'group_id': Optional[int],
+        'group': Optional[str],
+    })
+
+    _RelationEntities = TypedDict('_RelationEntities', {
+        'app': str,
+        'units': List[str]
+    })
+
+    _ConfigValue = Union[str, int, float, bool]
+    _ConfigOption = TypedDict('_ConfigOption', {
+        'type': Literal['string', 'int', 'float', 'boolean'],
+        'description': str,
+        'default': _ConfigValue
+    })
+    _StatusName = Literal['unknown', 'blocked', 'active', 'maintenance', 'waiting']
+    _RawStatus = TypedDict('_RawStatus', {
+        'status': _StatusName,
+        'message': str,
+    })
+    RawConfig = TypedDict("RawConfig", {'options': Dict[str, _ConfigOption]})
 
 # Toggles Container.can_connect simulation globally for all harness instances.
 # For this to work, it must be set *before* Harness instances are created.
+
 SIMULATE_CAN_CONNECT = False
 
-# OptionalYAML is something like metadata.yaml or actions.yaml. You can
+# YAMLStringOrFile is something like metadata.yaml or actions.yaml. You can
 # pass in a file-like object or the string directly.
-OptionalYAML = typing.Optional[typing.Union[str, typing.TextIO]]
+YAMLStringOrFile = Union[str, TextIO]
 
 
 # An instance of an Application or Unit, or the name of either.
 # This is done here to avoid a scoping issue with the `model` property
 # of the Harness class below.
-AppUnitOrName = typing.Union[str, model.Application, model.Unit]
+AppUnitOrName = Union[str, model.Application, model.Unit]
 
 
 # CharmType represents user charms that are derived from CharmBase.
-CharmType = typing.TypeVar('CharmType', bound=charm.CharmBase)
+CharmType = TypeVar('CharmType', bound=charm.CharmBase)
 
 
 # noinspection PyProtectedMember
-class Harness(typing.Generic[CharmType]):
+class Harness(Generic[CharmType]):
     """This class represents a way to build up the model that will drive a test suite.
 
     The model that is created is from the viewpoint of the charm that you are testing.
@@ -103,37 +160,95 @@ class Harness(typing.Generic[CharmType]):
 
     def __init__(
             self,
-            charm_cls: typing.Type[CharmType],
+            charm_cls: Type[CharmType],
             *,
-            meta: OptionalYAML = None,
-            actions: OptionalYAML = None,
-            config: OptionalYAML = None):
+            meta: Optional[YAMLStringOrFile] = None,
+            actions: Optional[YAMLStringOrFile] = None,
+            config: Optional[YAMLStringOrFile] = None):
         self._charm_cls = charm_cls
-        self._charm = None
+        self._charm = None  # type: Optional[CharmType]
         self._charm_dir = 'no-disk-path'  # this may be updated by _create_meta
         self._meta = self._create_meta(meta, actions)
-        self._unit_name = self._meta.name + '/0'
-        self._framework = None
-        self._hooks_enabled = True
-        self._relation_id_counter = 0
-        self._backend = _TestingModelBackend(self._unit_name, self._meta)
+        self._unit_name = self._meta.name + '/0'  # type: str
+        self._hooks_enabled = True  # type: bool
+        self._relation_id_counter = 0  # type: int
+        config_ = self._get_config(config)
+        self._backend = _TestingModelBackend(self._unit_name, self._meta, config_)
         self._model = model.Model(self._meta, self._backend)
         self._storage = storage.SQLiteStorage(':memory:')
-        self._oci_resources = {}
         self._framework = framework.Framework(
             self._storage, self._charm_dir, self._meta, self._model)
-        self._defaults = self._load_config_defaults(config)
-        self._update_config(key_values=self._defaults)
+
+        # TODO: will be removed in the next breaking-changes release
+        #  together with self._oci_resources
+        self._deprecated_oci_resources_do_not_use = {}  # type: Dict[Any, Any]
 
         # TODO: If/when we decide to allow breaking changes for a release,
-        # change SIMULATE_CAN_CONNECT default value to True and remove the
-        # warning message below.  This warning was added 2022-03-22
+        #  change SIMULATE_CAN_CONNECT default value to True and remove the
+        #  warning message below.  This warning was added 2022-03-22
         if not SIMULATE_CAN_CONNECT:
             warnings.warn(
                 'Please set ops.testing.SIMULATE_CAN_CONNECT=True.'
                 'See https://juju.is/docs/sdk/testing#heading--simulate-can-connect for details.')
 
-    def set_can_connect(self, container: typing.Union[str, model.Container], val: bool):
+    @property
+    def _oci_resources(self):
+        warnings.warn('Deprecation warning: Harness.`_oci_resources` is '
+                      'deprecated and will be removed in a future release.')
+        return self._deprecated_oci_resources_do_not_use
+
+    def _event_context(self, event_name: str):
+        """Configures the Harness to behave as if an event hook were running.
+
+        This means that the Harness will perform strict access control of relation data.
+
+        Example usage:
+
+        # this is how we test that attempting to write a remote app's
+        # databag will raise RelationDataError.
+        >>> with harness._event_context('foo'):
+        >>>     with pytest.raises(ops.model.RelationDataError):
+        >>>         my_relation.data[remote_app]['foo'] = 'bar'
+
+        # this is how we test with 'realistic conditions' how an event handler behaves
+        # when we call it directly -- i.e. without going through harness.add_relation
+        >>> def test_foo():
+        >>>     class MyCharm:
+        >>>         ...
+        >>>         def event_handler(self, event):
+        >>>             # this is expected to raise an exception
+        >>>             event.relation.data[event.relation.app]['foo'] = 'bar'
+        >>>
+        >>>     harness = Harness(MyCharm)
+        >>>     event = MagicMock()
+        >>>     event.relation = harness.charm.model.relations[0]
+        >>>
+        >>>     with harness._event_context('my_relation_joined'):
+        >>>         with pytest.raises(ops.model.RelationDataError):
+        >>>             harness.charm.event_handler(event)
+
+
+        If event_name == '', conversely, the Harness will believe that no hook
+        is running, allowing you to temporarily have unrestricted access to read/write
+        a relation's databags even if you're inside an event handler.
+        >>> def test_foo():
+        >>>     class MyCharm:
+        >>>         ...
+        >>>         def event_handler(self, event):
+        >>>             # this is expected to raise an exception since we're not leader
+        >>>             event.relation.data[self.app]['foo'] = 'bar'
+        >>>
+        >>>     harness = Harness(MyCharm)
+        >>>     event = MagicMock()
+        >>>     event.relation = harness.charm.model.relations[0]
+        >>>
+        >>>     with harness._event_context('my_relation_joined'):
+        >>>         harness.charm.event_handler(event)
+
+        """
+        return self._framework._event_context(event_name)  # pyright: reportPrivateUsage=false
+
+    def set_can_connect(self, container: Union[str, model.Container], val: bool):
         """Change the simulated can_connect status of a container's underlying pebble client.
 
         Calling this method raises an exception if SIMULATE_CAN_CONNECT is False.
@@ -147,8 +262,12 @@ def charm(self) -> CharmType:
         """Return the instance of the charm class that was passed to __init__.
 
         Note that the Charm is not instantiated until you have called
-        :meth:`.begin()`.
+        :meth:`.begin()`. Until then, attempting to access this property will raise
+        an exception.
         """
+        if self._charm is None:
+            raise RuntimeError('The charm instance is not available yet. '
+                               'Call Harness.begin() first.')
         return self._charm
 
     @property
@@ -180,7 +299,7 @@ class TestEvents(self._charm_cls.on.__class__):
 
         TestEvents.__name__ = self._charm_cls.on.__class__.__name__
 
-        class TestCharm(self._charm_cls):
+        class TestCharm(self._charm_cls):  # type: ignore
             on = TestEvents()
 
         # Note: jam 2020-03-01 This is so that errors in testing say MyCharm has no attribute foo,
@@ -224,17 +343,15 @@ def begin_with_initial_hooks(self) -> None:
             # To be fired.
         """
         self.begin()
-
+        charm = cast(CharmBase, self._charm)
         # Checking if disks have been added
         # storage-attached events happen before install
         for storage_name in self._meta.storages:
-            for storage_index in self._backend.storage_list(storage_name):
-                storage_name = storage_name.replace('-', '_')
-                # Storage device(s) detected, emit storage-attached event(s)
-                self._charm.on[storage_name].storage_attached.emit(
-                    model.Storage(storage_name, storage_index, self._backend))
+            for storage_index in self._backend.storage_list(storage_name, include_detached=True):
+                s = model.Storage(storage_name, storage_index, self._backend)
+                self.attach_storage(s.full_id)
         # Storage done, emit install event
-        self._charm.on.install.emit()
+        charm.on.install.emit()
         # Juju itself iterates what relation to fire based on a map[int]relation, so it doesn't
         # guarantee a stable ordering between relation events. It *does* give a stable ordering
         # of joined units for a given relation.
@@ -242,7 +359,7 @@ def begin_with_initial_hooks(self) -> None:
         random.shuffle(items)
         this_app_name = self._meta.name
         for relname, rel_meta in items:
-            if rel_meta.role == charm.RelationRole.peer:
+            if rel_meta.role == RelationRole.peer:
                 # If the user has directly added a relation, leave it be, but otherwise ensure
                 # that peer relations are always established at before leader-elected.
                 rel_ids = self._backend._relation_ids_map.get(relname)
@@ -259,17 +376,17 @@ def begin_with_initial_hooks(self) -> None:
                     app_name = self._backend._relation_app_and_units[rel_id]["app"]
                     self._emit_relation_created(relname, rel_id, app_name)
         if self._backend._is_leader:
-            self._charm.on.leader_elected.emit()
+            charm.on.leader_elected.emit()
         else:
-            self._charm.on.leader_settings_changed.emit()
-        self._charm.on.config_changed.emit()
-        self._charm.on.start.emit()
+            charm.on.leader_settings_changed.emit()
+        charm.on.config_changed.emit()
+        charm.on.start.emit()
         # If the initial hooks do not set a unit status, the Juju controller will switch
         # the unit status from "Maintenance" to "Unknown". See gh#726
         post_setup_sts = self._backend.status_get()
         if post_setup_sts.get("status") == "maintenance" and not post_setup_sts.get("message"):
             self._backend.status_set("unknown", "", is_app=False)
-        all_ids = list(self._backend._relation_names.items())
+        all_ids = list(self._backend._relation_names.items())  # pyright:ReportPrivateUsage=false
         random.shuffle(all_ids)
         for rel_id, rel_name in all_ids:
             rel_app_and_units = self._backend._relation_app_and_units[rel_id]
@@ -279,15 +396,14 @@ def begin_with_initial_hooks(self) -> None:
             # relation-joined for the same unit.
             # Juju only fires relation-changed (app) if there is data for the related application
             relation = self._model.get_relation(rel_name, rel_id)
-            if self._backend._relation_data[rel_id].get(app_name):
+            if self._backend._relation_data_raw[rel_id].get(app_name):
                 app = self._model.get_app(app_name)
-                self._charm.on[rel_name].relation_changed.emit(
-                    relation, app, None)
+                charm.on[rel_name].relation_changed.emit(relation, app, None)
             for unit_name in sorted(rel_app_and_units["units"]):
                 remote_unit = self._model.get_unit(unit_name)
-                self._charm.on[rel_name].relation_joined.emit(
+                charm.on[rel_name].relation_joined.emit(
                     relation, remote_unit.app, remote_unit)
-                self._charm.on[rel_name].relation_changed.emit(
+                charm.on[rel_name].relation_changed.emit(
                     relation, remote_unit.app, remote_unit)
 
     def cleanup(self) -> None:
@@ -298,7 +414,8 @@ def cleanup(self) -> None:
         """
         self._backend._cleanup()
 
-    def _create_meta(self, charm_metadata, action_metadata):
+    def _create_meta(self, charm_metadata: Optional[YAMLStringOrFile],
+                     action_metadata: Optional[YAMLStringOrFile]) -> CharmMeta:
         """Create a CharmMeta object.
 
         Handle the cases where a user doesn't supply explicit metadata snippets.
@@ -325,12 +442,12 @@ def _create_meta(self, charm_metadata, action_metadata):
         elif isinstance(action_metadata, str):
             action_metadata = dedent(action_metadata)
 
-        return charm.CharmMeta.from_yaml(charm_metadata, action_metadata)
+        return CharmMeta.from_yaml(charm_metadata, action_metadata)
 
-    def _load_config_defaults(self, charm_config):
-        """Load default values from config.yaml.
+    def _get_config(self, charm_config: Optional['YAMLStringOrFile']):
+        """If the user passed a config to Harness, use it.
 
-        Handle the case where a user doesn't supply explicit config snippets.
+        Otherwise, attempt to load one from charm_dir/config.yaml.
         """
         filename = inspect.getfile(self._charm_cls)
         charm_dir = pathlib.Path(filename).parents[1]
@@ -345,12 +462,16 @@ def _load_config_defaults(self, charm_config):
                 charm_config = '{}'
         elif isinstance(charm_config, str):
             charm_config = dedent(charm_config)
-        charm_config = yaml.safe_load(charm_config)
-        charm_config = charm_config.get('options', {})
-        return {key: value.get('default', None) for key, value in charm_config.items()}
+
+        assert isinstance(charm_config, str)  # type guard
+        config = yaml.safe_load(charm_config)
+
+        if not isinstance(config, dict):  # pyright: reportUnnecessaryIsInstance=false
+            raise TypeError(config)
+        return cast('RawConfig', config)
 
     def add_oci_resource(self, resource_name: str,
-                         contents: typing.Mapping[str, str] = None) -> None:
+                         contents: Optional[Mapping[str, str]] = None) -> None:
         """Add oci resources to the backend.
 
         This will register an oci resource and create a temporary file for processing metadata
@@ -374,7 +495,7 @@ def add_oci_resource(self, resource_name: str,
         as_yaml = yaml.safe_dump(contents)
         self._backend._resources_map[resource_name] = ('contents.yaml', as_yaml)
 
-    def add_resource(self, resource_name: str, content: typing.AnyStr) -> None:
+    def add_resource(self, resource_name: str, content: AnyStr) -> None:
         """Add content for a resource to the backend.
 
         This will register the content, so that a call to `Model.resources.fetch(resource_name)`
@@ -447,16 +568,19 @@ def _next_relation_id(self):
         self._relation_id_counter += 1
         return rel_id
 
-    def add_storage(self, storage_name: str, count: int = 1) -> typing.List[str]:
-        """Declare a new storage device attached to this unit.
+    def add_storage(self, storage_name: str, count: int = 1,
+                    *, attach: bool = False) -> List[str]:
+        """Create a new storage device and attach it to this unit.
 
         To have repeatable tests, each device will be initialized with
-        location set to /<storage_name>N, where N is the counter and
-        will be a number from [0,total_num_disks-1]
+        location set to /[tmpdir]/<storage_name>N, where N is the counter and
+        will be a number from [0,total_num_disks-1].
 
         Args:
             storage_name: The storage backend name on the Charm
             count: Number of disks being added
+            attach: True to also attach the storage mount and emit storage-attached if
+                harness.begin() has been called.
 
         Return:
             A list of storage IDs, e.g. ["my-storage/1", "my-storage/2"].
@@ -464,18 +588,15 @@ def add_storage(self, storage_name: str, count: int = 1) -> typing.List[str]:
         if storage_name not in self._meta.storages:
             raise RuntimeError(
                 "the key '{}' is not specified as a storage key in metadata".format(storage_name))
-        storage_indices = self._backend.storage_add(storage_name, count)
 
-        # Reset associated cached value in the storage mappings.  If we don't do this,
-        # Model._storages won't return Storage objects for subsequently-added storage.
-        self._model._storages._invalidate(storage_name)
+        storage_indices = self._backend.storage_add(storage_name, count)
 
-        ids = []
+        ids = []  # type: List[str]
         for storage_index in storage_indices:
             s = model.Storage(storage_name, storage_index, self._backend)
             ids.append(s.full_id)
-            if self.charm is not None and self._hooks_enabled:
-                self.charm.on[storage_name].storage_attached.emit(s)
+            if attach:
+                self.attach_storage(s.full_id)
         return ids
 
     def detach_storage(self, storage_id: str) -> None:
@@ -489,14 +610,16 @@ def detach_storage(self, storage_id: str) -> None:
             storage_id: The full storage ID of the storage unit being detached, including the
                 storage key, e.g. my-storage/0.
         """
-        if self.charm is None:
+        if self._charm is None:
             raise RuntimeError('cannot detach storage before Harness is initialised')
         storage_name, storage_index = storage_id.split('/', 1)
         storage_index = int(storage_index)
-        if self._backend._storage_is_attached(storage_name, storage_index) and self._hooks_enabled:
+        storage_attached = self._backend._storage_is_attached(  # pyright:ReportPrivateUsage=false
+            storage_name, storage_index)
+        if storage_attached and self._hooks_enabled:
             self.charm.on[storage_name].storage_detaching.emit(
                 model.Storage(storage_name, storage_index, self._backend))
-        self._backend._storage_detach(storage_id)
+        self._backend._storage_detach(storage_id)  # pyright:ReportPrivateUsage=false
 
     def attach_storage(self, storage_id: str) -> None:
         """Attach a storage device.
@@ -509,11 +632,20 @@ def attach_storage(self, storage_id: str) -> None:
             storage_id: The full storage ID of the storage unit being attached, including the
                 storage key, e.g. my-storage/0.
         """
-        if self._backend._storage_attach(storage_id) and self._hooks_enabled:
-            storage_name, storage_index = storage_id.split('/', 1)
-            storage_index = int(storage_index)
-            self.charm.on[storage_name].storage_attached.emit(
-                model.Storage(storage_name, storage_index, self._backend))
+        if not self._backend._storage_attach(storage_id):
+            return  # storage was already attached
+        if not self._charm or not self._hooks_enabled:
+            return  # don't need to run hook callback
+
+        storage_name, storage_index = storage_id.split('/', 1)
+
+        # Reset associated cached value in the storage mappings.  If we don't do this,
+        # Model._storages won't return Storage objects for subsequently-added storage.
+        self._model._storages._invalidate(storage_name)
+
+        storage_index = int(storage_index)
+        self.charm.on[storage_name].storage_attached.emit(
+            model.Storage(storage_name, storage_index, self._backend))
 
     def remove_storage(self, storage_id: str) -> None:
         """Detach a storage device.
@@ -532,11 +664,12 @@ def remove_storage(self, storage_id: str) -> None:
         if storage_name not in self._meta.storages:
             raise RuntimeError(
                 "the key '{}' is not specified as a storage key in metadata".format(storage_name))
-        is_attached = self._backend._storage_is_attached(storage_name, storage_index)
-        if self.charm is not None and self._hooks_enabled and is_attached:
+        is_attached = self._backend._storage_is_attached(  # pyright:ReportPrivateUsage=false
+            storage_name, storage_index)
+        if self._charm is not None and self._hooks_enabled and is_attached:
             self.charm.on[storage_name].storage_detaching.emit(
                 model.Storage(storage_name, storage_index, self._backend))
-        self._backend._storage_remove(storage_id)
+        self._backend._storage_remove(storage_id)  # pyright:ReportPrivateUsage=false
 
     def add_relation(self, relation_name: str, remote_app: str) -> int:
         """Declare that there is a new relation between this app and `remote_app`.
@@ -553,24 +686,25 @@ def add_relation(self, relation_name: str, remote_app: str) -> int:
         Return:
             The relation_id created by this add_relation.
         """
-        rel_id = self._next_relation_id()
-        self._backend._relation_ids_map.setdefault(relation_name, []).append(rel_id)
-        self._backend._relation_names[rel_id] = relation_name
-        self._backend._relation_list_map[rel_id] = []
-        self._backend._relation_data[rel_id] = {
+        relation_id = self._next_relation_id()
+        self._backend._relation_ids_map.setdefault(  # pyright:ReportPrivateUsage=false
+            relation_name, []).append(relation_id)
+        self._backend._relation_names[relation_id] = relation_name
+        self._backend._relation_list_map[relation_id] = []  # pyright:ReportPrivateUsage=false
+        self._backend._relation_data_raw[relation_id] = {  # pyright:ReportPrivateUsage=false
             remote_app: {},
             self._backend.unit_name: {},
-            self._backend.app_name: {},
-        }
-        self._backend._relation_app_and_units[rel_id] = {
+            self._backend.app_name: {}}
+
+        self._backend._relation_app_and_units[relation_id] = {  # pyright:ReportPrivateUsage=false
             "app": remote_app,
             "units": [],
         }
         # Reload the relation_ids list
         if self._model is not None:
-            self._model.relations._invalidate(relation_name)
-        self._emit_relation_created(relation_name, rel_id, remote_app)
-        return rel_id
+            self._model.relations._invalidate(relation_name)  # pyright:ReportPrivateUsage=false
+        self._emit_relation_created(relation_name, relation_id, remote_app)
+        return relation_id
 
     def remove_relation(self, relation_id: int) -> None:
         """Remove a relation.
@@ -581,24 +715,27 @@ def remove_relation(self, relation_id: int) -> None:
         Raises:
             RelationNotFoundError: if relation id is not valid
         """
+        rel_names = self._backend._relation_names   # pyright:ReportPrivateUsage=false
         try:
-            relation_name = self._backend._relation_names[relation_id]
+            relation_name = rel_names[relation_id]
             remote_app = self._backend.relation_remote_app_name(relation_id)
         except KeyError as e:
             raise model.RelationNotFoundError from e
 
-        for unit_name in self._backend._relation_list_map[relation_id].copy():
+        rel_list_map = self._backend._relation_list_map  # pyright:ReportPrivateUsage=false
+        for unit_name in rel_list_map[relation_id].copy():
             self.remove_relation_unit(relation_id, unit_name)
 
         self._emit_relation_broken(relation_name, relation_id, remote_app)
         if self._model is not None:
-            self._model.relations._invalidate(relation_name)
+            self._model.relations._invalidate(relation_name)  # pyright:ReportPrivateUsage=false
 
-        self._backend._relation_app_and_units.pop(relation_id)
-        self._backend._relation_data.pop(relation_id)
-        self._backend._relation_list_map.pop(relation_id)
-        self._backend._relation_ids_map[relation_name].remove(relation_id)
-        self._backend._relation_names.pop(relation_id)
+        self._backend._relation_app_and_units.pop(relation_id)  # pyright:ReportPrivateUsage=false
+        self._backend._relation_data_raw.pop(relation_id)  # pyright:ReportPrivateUsage=false
+        rel_list_map.pop(relation_id)
+        ids_map = self._backend._relation_ids_map  # pyright:ReportPrivateUsage=false
+        ids_map[relation_name].remove(relation_id)
+        rel_names.pop(relation_id)
 
     def _emit_relation_created(self, relation_name: str, relation_id: int,
                                remote_app: str) -> None:
@@ -617,8 +754,7 @@ def _emit_relation_broken(self, relation_name: str, relation_id: int,
             return
         relation = self._model.get_relation(relation_name, relation_id)
         app = self._model.get_app(remote_app)
-        self._charm.on[relation_name].relation_broken.emit(
-            relation, app)
+        self._charm.on[relation_name].relation_broken.emit(relation, app)
 
     def add_relation_unit(self, relation_id: int, remote_unit_name: str) -> None:
         """Add a new unit to a relation.
@@ -642,15 +778,26 @@ def add_relation_unit(self, relation_id: int, remote_unit_name: str) -> None:
             None
         """
         self._backend._relation_list_map[relation_id].append(remote_unit_name)
-        self._backend._relation_data[relation_id][remote_unit_name] = {}
-        # TODO: jam 2020-08-03 This is where we could assert that the unit name matches the
-        #  application name (eg you don't have a relation to 'foo' but add units of 'bar/0'
-        self._backend._relation_app_and_units[relation_id]["units"].append(remote_unit_name)
+        # we can write remote unit data iff we are not in a hook env
         relation_name = self._backend._relation_names[relation_id]
+        relation = self._model.get_relation(relation_name, relation_id)
+
+        if not relation:
+            raise RuntimeError('Relation id {} is mapped to relation name {},'
+                               'but no relation matching that name was found.')
+
+        self._backend._relation_data_raw[relation_id][remote_unit_name] = {}
+        app = cast(model.Application, relation.app)  # should not be None since we're testing
+        if not remote_unit_name.startswith(app.name):
+            warnings.warn(
+                'Remote unit name invalid: the remote application of {} is called {!r}; '
+                'the remote unit name should be {}/<some-number>, not {!r}.'
+                ''.format(relation_name, app.name, app.name, remote_unit_name))
+        app_and_units = self._backend._relation_app_and_units  # pyright: ReportPrivateUsage=false
+        app_and_units[relation_id]["units"].append(remote_unit_name)
         # Make sure that the Model reloads the relation_list for this relation_id, as well as
         # reloading the relation data for this unit.
         remote_unit = self._model.get_unit(remote_unit_name)
-        relation = self._model.get_relation(relation_name, relation_id)
         unit_cache = relation.data.get(remote_unit, None)
         if unit_cache is not None:
             unit_cache._invalidate()
@@ -674,13 +821,13 @@ def remove_relation_unit(self, relation_id: int, remote_unit_name: str) -> None:
         normally be followed by a `relation_changed` event triggered
         by Juju. However when using the test harness a
         `relation_changed` event must be triggererd using
-        :meth:`.update_relation_data`. This deviation from normal Juju
+        :meth:`.update_relation_data`. This deviation from normal Juj
         behaviour, facilitates testing by making each step in the
         charm life cycle explicit.
 
         Args:
             relation_id: The integer relation identifier (as returned by add_relation).
-            remote_unit_name: A string representing the remote unit that is being added.
+            remote_unit_name: A string representing the remote unit that is being removed.
 
         Raises:
             KeyError: if relation_id or remote_unit_name is not valid
@@ -691,6 +838,14 @@ def remove_relation_unit(self, relation_id: int, remote_unit_name: str) -> None:
         # gather data to invalidate cache later
         remote_unit = self._model.get_unit(remote_unit_name)
         relation = self._model.get_relation(relation_name, relation_id)
+
+        if not relation:
+            # This should not really happen, since there being a relation name mapped
+            # to this ID in _relation_names should guarantee that you created the relation
+            # following the proper path, but still...
+            raise RuntimeError('Relation id {} is mapped to relation name {},'
+                               'but no relation matching that name was found.')
+
         unit_cache = relation.data.get(remote_unit, None)
 
         # remove the unit from the list of units in the relation
@@ -700,13 +855,13 @@ def remove_relation_unit(self, relation_id: int, remote_unit_name: str) -> None:
         # remove the relation data for the departed unit now that the event has happened
         self._backend._relation_list_map[relation_id].remove(remote_unit_name)
         self._backend._relation_app_and_units[relation_id]["units"].remove(remote_unit_name)
-        self._backend._relation_data[relation_id].pop(remote_unit_name)
+        self._backend._relation_data_raw[relation_id].pop(remote_unit_name)
         self.model._relations._invalidate(relation_name=relation.name)
 
         if unit_cache is not None:
             unit_cache._invalidate()
 
-    def _emit_relation_departed(self, relation_id, unit_name):
+    def _emit_relation_departed(self, relation_id: int, unit_name: str):
         """Trigger relation-departed event for a given relation id and unit."""
         if self._charm is None or not self._hooks_enabled:
             return
@@ -718,9 +873,10 @@ def _emit_relation_departed(self, relation_id, unit_name):
             unit = self.model.get_unit(unit_name)
         else:
             raise ValueError('Invalid Unit Name')
-        self._charm.on[rel_name].relation_departed.emit(relation, app, unit, unit_name)
+        self._charm.on[rel_name].relation_departed.emit(
+            relation, app, unit, unit_name)
 
-    def get_relation_data(self, relation_id: int, app_or_unit: AppUnitOrName) -> typing.Mapping:
+    def get_relation_data(self, relation_id: int, app_or_unit: AppUnitOrName) -> Mapping[str, str]:
         """Get the relation data bucket for a single app or unit in a given relation.
 
         This ignores all of the safety checks of who can and can't see data in relations (eg,
@@ -736,11 +892,19 @@ def get_relation_data(self, relation_id: int, app_or_unit: AppUnitOrName) -> typ
         Raises:
             KeyError: if relation_id doesn't exist
         """
-        if hasattr(app_or_unit, 'name'):
-            app_or_unit = app_or_unit.name
-        return self._backend._relation_data[relation_id].get(app_or_unit, None)
+        if isinstance(app_or_unit, model.Application):
+            name = app_or_unit.name
+        elif isinstance(app_or_unit, model.Unit):
+            name = app_or_unit.name
+        elif isinstance(app_or_unit, str):
+            name = app_or_unit
+        else:
+            raise TypeError('Expected Application | Unit | str, got {}'.format(type(app_or_unit)))
 
-    def get_pod_spec(self) -> (typing.Mapping, typing.Mapping):
+        # bypass access control by going directly to raw
+        return self._backend._relation_data_raw[relation_id].get(name, None)
+
+    def get_pod_spec(self) -> Tuple[Mapping[Any, Any], Mapping[Any, Any]]:
         """Return the content of the pod spec as last set by the charm.
 
         This returns both the pod spec and any k8s_resources that were supplied.
@@ -774,7 +938,7 @@ def container_pebble_ready(self, container_name: str):
         container's can_connect state to True before the hook
         function is called.
         """
-        if self.charm is None:
+        if self._charm is None:
             return
         container = self.model.unit.get_container(container_name)
         if SIMULATE_CAN_CONNECT:
@@ -785,7 +949,7 @@ def get_workload_version(self) -> str:
         """Read the workload version that was set by the unit."""
         return self._backend._workload_version
 
-    def set_model_info(self, name: str = None, uuid: str = None) -> None:
+    def set_model_info(self, name: Optional[str] = None, uuid: Optional[str] = None) -> None:
         """Set the name and uuid of the Model that this is representing.
 
         This cannot be called once begin() has been called. But it lets you set the value that
@@ -794,8 +958,10 @@ def set_model_info(self, name: str = None, uuid: str = None) -> None:
         This is a convenience method to invoke both Harness.set_model_name
         and Harness.set_model_uuid at once.
         """
-        self.set_model_name(name)
-        self.set_model_uuid(uuid)
+        if name is not None:
+            self.set_model_name(name)
+        if uuid is not None:
+            self.set_model_uuid(uuid)
 
     def set_model_name(self, name: str) -> None:
         """Set the name of the Model that this is representing.
@@ -821,7 +987,7 @@ def update_relation_data(
             self,
             relation_id: int,
             app_or_unit: str,
-            key_values: typing.Mapping,
+            key_values: Mapping[str, str],
     ) -> None:
         """Update the relation data for a given unit or application in a given relation.
 
@@ -839,26 +1005,35 @@ def update_relation_data(
             entity = self._model.get_unit(app_or_unit)
         else:
             entity = self._model.get_app(app_or_unit)
+
+        if not relation:
+            raise RuntimeError('Relation id {} is mapped to relation name {},'
+                               'but no relation matching that name was found.')
+
         rel_data = relation.data.get(entity, None)
         if rel_data is not None:
             # rel_data may have cached now-stale data, so _invalidate() it.
             # Note, this won't cause the data to be loaded if it wasn't already.
             rel_data._invalidate()
 
-        new_values = self._backend._relation_data[relation_id][app_or_unit].copy()
-        values_have_changed = False
-        for k, v in key_values.items():
-            if v == '':
-                if new_values.pop(k, None) != v:
-                    values_have_changed = True
-            else:
-                if k not in new_values or new_values[k] != v:
-                    new_values[k] = v
-                    values_have_changed = True
-
-        # Update the relation data in any case to avoid spurious references
-        # by an test to an updated value to be invalidated by a lack of assignment
-        self._backend._relation_data[relation_id][app_or_unit] = new_values
+        old_values = self._backend._relation_data_raw[relation_id][app_or_unit].copy()
+        assert isinstance(old_values, dict), old_values
+
+        # get a new relation instance to ensure a clean state
+        new_relation_instance = self.model.relations._get_unique(relation.name, relation_id)
+        assert new_relation_instance is not None  # type guard; this passed before...
+        databag = new_relation_instance.data[entity]
+        # ensure that WE as harness can temporarily write the databag
+        with self._event_context(''):
+            values_have_changed = False
+            for k, v in key_values.items():
+                if v == '':
+                    if databag.pop(k, None) != v:
+                        values_have_changed = True
+                else:
+                    if k not in databag or databag[k] != v:
+                        databag[k] = v  # this triggers relation-set
+                        values_have_changed = True
 
         if not values_have_changed:
             # Do not issue a relation changed event if the data bags have not changed
@@ -877,7 +1052,7 @@ def update_relation_data(
                 return
         self._emit_relation_changed(relation_id, app_or_unit)
 
-    def _emit_relation_changed(self, relation_id, app_or_unit):
+    def _emit_relation_changed(self, relation_id: int, app_or_unit: str):
         if self._charm is None or not self._hooks_enabled:
             return
         rel_name = self._backend._relation_names[relation_id]
@@ -896,8 +1071,8 @@ def _emit_relation_changed(self, relation_id, app_or_unit):
 
     def _update_config(
             self,
-            key_values: typing.Mapping[str, str] = None,
-            unset: typing.Iterable[str] = (),
+            key_values: Optional[Mapping[str, '_ConfigValue']] = None,
+            unset: Iterable[str] = (),
     ) -> None:
         """Update the config as seen by the charm.
 
@@ -917,24 +1092,24 @@ def _update_config(
         config = self._backend._config
         if key_values is not None:
             for key, value in key_values.items():
-                if key in self._defaults:
+                if key in config._defaults:
                     if value is not None:
-                        config[key] = value
+                        config._config_set(key, value)
                 else:
                     raise ValueError("unknown config option: '{}'".format(key))
 
         for key in unset:
             # When the key is unset, revert to the default if one exists
-            default = self._defaults.get(key, None)
+            default = config._defaults.get(key, None)
             if default is not None:
-                config[key] = default
+                config._config_set(key, default)
             else:
                 config.pop(key, None)
 
     def update_config(
             self,
-            key_values: typing.Mapping[str, str] = None,
-            unset: typing.Iterable[str] = (),
+            key_values: Optional[Mapping[str, '_ConfigValue']] = None,
+            unset: Iterable[str] = (),
     ) -> None:
         """Update the config as seen by the charm.
 
@@ -945,8 +1120,9 @@ def update_config(
 
         Args:
             key_values: A Mapping of key:value pairs to update in config.
-            unset: An iterable of keys to remove from Config. (Note that this does
-                not currently reset the config values to the default defined in config.yaml.)
+            unset: An iterable of keys to remove from Config.
+                This sets the value to the default if defined,
+                otherwise removes the key altogether.
         """
         self._update_config(key_values, unset)
         if self._charm is None or not self._hooks_enabled:
@@ -997,7 +1173,7 @@ def reset_planned_units(self):
         """
         self._backend._planned_units = None
 
-    def _get_backend_calls(self, reset: bool = True) -> list:
+    def _get_backend_calls(self, reset: bool = True) -> List[Tuple[Any, ...]]:
         """Return the calls that we have made to the TestingModelBackend.
 
         This is useful mostly for testing the framework itself, so that we can assert that we
@@ -1016,7 +1192,7 @@ def _get_backend_calls(self, reset: bool = True) -> list:
         return calls
 
 
-def _record_calls(cls):
+def _record_calls(cls: Any):
     """Replace methods on cls with methods that record that they have been called.
 
     Iterate all attributes of cls, and for public methods, replace them with a wrapped method
@@ -1026,8 +1202,8 @@ def _record_calls(cls):
         if meth_name.startswith('_'):
             continue
 
-        def decorator(orig_method):
-            def wrapped(self, *args, **kwargs):
+        def decorator(orig_method: Any):
+            def wrapped(self: '_TestingModelBackend', *args: Any, **kwargs: Any):
                 full_args = (orig_method.__name__,) + args
                 if kwargs:
                     full_args = full_args + (kwargs,)
@@ -1039,7 +1215,7 @@ def wrapped(self, *args, **kwargs):
     return cls
 
 
-def _copy_docstrings(source_cls):
+def _copy_docstrings(source_cls: Any):
     """Copy the docstrings from source_cls to target_cls.
 
     Use this as:
@@ -1049,8 +1225,8 @@ class TargetClass:
     And for any public method that exists on both classes, it will copy the
     __doc__ for that method.
     """
-    def decorator(target_cls):
-        for meth_name, orig_method in target_cls.__dict__.items():
+    def decorator(target_cls: Any):
+        for meth_name, _ in target_cls.__dict__.items():
             if meth_name.startswith('_'):
                 continue
             source_method = source_cls.__dict__.get(meth_name)
@@ -1060,14 +1236,86 @@ def decorator(target_cls):
     return decorator
 
 
-class _ResourceEntry:
-    """Tracks the contents of a Resource."""
+@_record_calls
+class _TestingConfig(Dict[str, '_ConfigValue']):
+    """Represents the Juju Config."""
+    _supported_types = {
+        'string': str,
+        'boolean': bool,
+        'int': int,
+        'float': float
+    }
+
+    def __init__(self, config: 'RawConfig'):
+        super().__init__()
+        self._spec = config
+        self._defaults = self._load_defaults(config)
+
+        for key, value in self._defaults.items():
+            if value is None:
+                continue
+            self._config_set(key, value)
+
+    @staticmethod
+    def _load_defaults(charm_config: 'RawConfig') -> Dict[str, '_ConfigValue']:
+        """Load default values from config.yaml.
+
+        Handle the case where a user doesn't supply explicit config snippets.
+        """
+        if not charm_config:
+            return {}
+        cfg = charm_config.get('options', {})  # type: Dict[str, '_ConfigOption']
+        return {key: value.get('default', None) for key, value in cfg.items()}
+
+    def _config_set(self, key: str, value: '_ConfigValue'):
+        # this is only called by the harness itself
+        # we don't do real serialization/deserialization, but we do check that the value
+        # has the expected type.
+        option = self._spec.get('options', {}).get(key)
+        if not option:
+            raise RuntimeError('Unknown config option {}; '
+                               'not declared in `config.yaml`.'
+                               'Check https://juju.is/docs/sdk/config for the '
+                               'spec.'.format(key))
+
+        declared_type = option.get('type')
+        if not declared_type:
+            raise RuntimeError('Incorrectly formatted `options.yaml`, option {} '
+                               'is expected to declare a `type`.'.format(key))
+
+        if declared_type not in self._supported_types:
+            raise RuntimeError(
+                'Incorrectly formatted `options.yaml`: `type` needs to be one '
+                'of [{}], not {}.'.format(', '.join(self._supported_types), declared_type))
+
+        if type(value) != self._supported_types[declared_type]:
+            raise RuntimeError('Config option {} is supposed to be of type '
+                               '{}, not `{}`.'.format(key, declared_type,
+                                                      type(value).__name__))
+
+        # call 'normal' setattr.
+        dict.__setitem__(self, key, value)  # type: ignore
+
+    def __setitem__(self, key: Any, value: Any):
+        # if a charm attempts to config[foo] = bar:
+        raise TypeError("'ConfigData' object does not support item assignment")
 
-    def __init__(self, resource_name):
-        self.name = resource_name
 
+class _TestingRelationDataContents(Dict[str, str]):
+    def __setitem__(self, key: str, value: str):
+        if not isinstance(key, str):
+            raise model.RelationDataError(
+                'relation data keys must be strings, not {}'.format(type(key)))
+        if not isinstance(value, str):
+            raise model.RelationDataError(
+                'relation data values must be strings, not {}'.format(type(value)))
+        super().__setitem__(key, value)
 
-@_copy_docstrings(model._ModelBackend)
+    def copy(self):
+        return _TestingRelationDataContents(super().copy())
+
+
+@_copy_docstrings(model._ModelBackend)  # pyright: reportPrivateUsage=false
 @_record_calls
 class _TestingModelBackend:
     """This conforms to the interface for ModelBackend but provides canned data.
@@ -1077,44 +1325,54 @@ class _TestingModelBackend:
     as the only public methods of this type are for implementing ModelBackend.
     """
 
-    def __init__(self, unit_name, meta):
+    def __init__(self, unit_name: str, meta: charm.CharmMeta, config: 'RawConfig'):
         self.unit_name = unit_name
         self.app_name = self.unit_name.split('/')[0]
         self.model_name = None
         self.model_uuid = str(uuid.uuid4())
 
         self._harness_tmp_dir = tempfile.TemporaryDirectory(prefix='ops-harness-')
-        self._calls = []
+        # this is used by the _record_calls decorator
+        self._calls = []  # type: List[Tuple[Any, ...]]
         self._meta = meta
-        self._relation_ids_map = {}  # relation name to [relation_ids,...]
-        self._relation_names = {}  # reverse map from relation_id to relation_name
-        self._relation_list_map = {}  # relation_id: [unit_name,...]
-        self._relation_data = {}  # {relation_id: {name: data}}
+        # relation name to [relation_ids,...]
+        self._relation_ids_map = {}   # type: Dict[str, List[int]]
+        # reverse map from relation_id to relation_name
+        self._relation_names = {}  # type: Dict[int, str]
+        # relation_id: [unit_name,...]
+        self._relation_list_map = {}  # type: Dict[int, List[str]]
+        # {relation_id: {name: Dict[str: str]}}
+        self._relation_data_raw = {}  # type: Dict[int, Dict[str, Dict[str, str]]]
         # {relation_id: {"app": app_name, "units": ["app/0",...]}
-        self._relation_app_and_units = {}
-        self._config = {}
-        self._is_leader = False
-        self._resources_map = {}  # {resource_name: resource_content}
-        self._pod_spec = None
-        self._app_status = {'status': 'unknown', 'message': ''}
-        self._unit_status = {'status': 'maintenance', 'message': ''}
-        self._workload_version = None
-        self._resource_dir = None
+        self._relation_app_and_units = {}  # type: Dict[int, _RelationEntities]
+        self._config = _TestingConfig(config)
+        self._is_leader = False  # type: bool
+        # {resource_name: resource_content}
+        # where resource_content is (path, content)
+        self._resources_map = {}  # type: Dict[str, Tuple[str, Union[str, bytes]]]
+        # fixme: understand how this is used and adjust the type
+        self._pod_spec = None  # type: Optional[Tuple[model.K8sSpec, Any]]
+        self._app_status = {'status': 'unknown', 'message': ''}  # type: _RawStatus
+        self._unit_status = {'status': 'maintenance', 'message': ''}  # type: _RawStatus
+        self._workload_version = None  # type: Optional[str]
+        self._resource_dir = None  # type: Optional[tempfile.TemporaryDirectory[Any]]
         # Format:
         # { "storage_name": {"<ID1>": { <other-properties> }, ... }
         # <ID1>: device id that is key for given storage_name
         # Initialize the _storage_list with values present on metadata.yaml
-        self._storage_list = {k: {} for k in self._meta.storages}
+        self._storage_list = {k: {} for k in self._meta.storages
+                              }  # type: Dict[str, Dict[int, Dict[str, Any]]]
 
-        self._storage_detached = {k: set() for k in self._meta.storages}
+        self._storage_attached = {k: set() for k in self._meta.storages
+                                  }  # type: Dict[str, Set[int]]
         self._storage_index_counter = 0
         # {container_name : _TestingPebbleClient}
-        self._pebble_clients = {}  # type: {str: _TestingPebbleClient}
-        self._pebble_clients_can_connect = {}  # type: {_TestingPebbleClient: bool}
-        self._planned_units = None
+        self._pebble_clients = {}  # type: Dict[str, _TestingPebbleClient]
+        self._pebble_clients_can_connect = {}  # type: Dict[_TestingPebbleClient, bool]
+        self._planned_units = None  # type: Optional[int]
         self._hook_is_running = ''
 
-    def _validate_relation_access(self, relation_name, relations):
+    def _validate_relation_access(self, relation_name: str, relations: List[model.Relation]):
         """Ensures that the named relation exists/has been added.
 
         This is called whenever relation data is accessed via model.get_relation(...).
@@ -1122,20 +1380,20 @@ def _validate_relation_access(self, relation_name, relations):
         if len(relations) > 0:
             return
 
-        relations = list(self._meta.peers.keys())
-        relations.extend(self._meta.requires.keys())
-        relations.extend(self._meta.provides.keys())
-        if self._hook_is_running == 'leader_elected' and relation_name in relations:
+        valid_relation_endpoints = list(self._meta.peers.keys())  # type: List[str]
+        valid_relation_endpoints.extend(self._meta.requires.keys())
+        valid_relation_endpoints.extend(self._meta.provides.keys())
+        if self._hook_is_running == 'leader_elected' and relation_name in valid_relation_endpoints:
             raise RuntimeError(
                 'cannot access relation data without first adding the relation: '
                 'use Harness.add_relation({!r}, <app>) before calling set_leader'
                 .format(relation_name))
 
-    def _can_connect(self, pebble_client) -> bool:
+    def _can_connect(self, pebble_client: '_TestingPebbleClient') -> bool:
         """Returns whether the mock client is active and can support API calls with no errors."""
         return self._pebble_clients_can_connect[pebble_client]
 
-    def _set_can_connect(self, pebble_client, val):
+    def _set_can_connect(self, pebble_client: '_TestingPebbleClient', val: bool):
         """Manually sets the can_connect state for the given mock client."""
         if not SIMULATE_CAN_CONNECT:
             raise RuntimeError('must set SIMULATE_CAN_CONNECT=True before using set_can_connect')
@@ -1155,37 +1413,71 @@ def _get_resource_dir(self) -> pathlib.Path:
             # $AGENT_DIR/resources/$RESOURCE_NAME/$RESOURCE_FILENAME
             # However, charms shouldn't depend on this.
             self._resource_dir = tempfile.TemporaryDirectory(prefix='tmp-ops-test-resource-')
-        return pathlib.Path(self._resource_dir.name)
+        res_dir_name = cast(str, self._resource_dir.name)
+        return pathlib.Path(res_dir_name)
 
-    def relation_ids(self, relation_name):
+    def relation_ids(self, relation_name: str) -> List[int]:
         try:
             return self._relation_ids_map[relation_name]
         except KeyError as e:
             if relation_name not in self._meta.relations:
                 raise model.ModelError('{} is not a known relation'.format(relation_name)) from e
-            return []
+            no_ids = []  # type: List[int]
+            return no_ids
 
-    def relation_list(self, relation_id):
+    def relation_list(self, relation_id: int):
         try:
             return self._relation_list_map[relation_id]
         except KeyError as e:
             raise model.RelationNotFoundError from e
 
-    def relation_remote_app_name(self, relation_id: int) -> typing.Optional[str]:
+    def relation_remote_app_name(self, relation_id: int) -> Optional[str]:
         if relation_id not in self._relation_app_and_units:
             # Non-existent or dead relation
             return None
+        if 'relation_broken' in self._hook_is_running:
+            # TODO: if juju ever starts setting JUJU_REMOTE_APP in relation-broken hooks runs,
+            # then we should kill this if clause.
+            # See https://bugs.launchpad.net/juju/+bug/1960934
+            return None
         return self._relation_app_and_units[relation_id]['app']
 
-    def relation_get(self, relation_id, member_name, is_app):
+    def relation_get(self, relation_id: int, member_name: str, is_app: bool):
+        if 'relation_broken' in self._hook_is_running and not self.relation_remote_app_name(
+                relation_id) and member_name != self.app_name and member_name != self.unit_name:
+            # TODO: if juju gets fixed to set JUJU_REMOTE_APP for this case, then we may opt to
+            # allow charms to read/get that (stale) relation data.
+            # See https://bugs.launchpad.net/juju/+bug/1960934
+            raise RuntimeError(
+                'remote-side relation data cannot be accessed during a relation-broken event')
         if is_app and '/' in member_name:
             member_name = member_name.split('/')[0]
-        if relation_id not in self._relation_data:
+        if relation_id not in self._relation_data_raw:
             raise model.RelationNotFoundError()
-        return self._relation_data[relation_id][member_name].copy()
+        return self._relation_data_raw[relation_id][member_name]
+
+    def update_relation_data(self, relation_id: int, _entity: 'UnitOrApplication',
+                             key: str, value: str):
+        # this is where the 'real' backend would call relation-set.
+        raw_data = self._relation_data_raw[relation_id][_entity.name]
+        if value == '':
+            raw_data.pop(key, None)
+        else:
+            raw_data[key] = value
+
+    def relation_set(self, relation_id: int, key: str, value: str, is_app: bool):
+        if not isinstance(is_app, bool):
+            raise TypeError('is_app parameter to relation_set must be a boolean')
+
+        if 'relation_broken' in self._hook_is_running and not self.relation_remote_app_name(
+                relation_id):
+            raise RuntimeError(
+                'remote-side relation data cannot be accessed during a relation-broken event')
+
+        if relation_id not in self._relation_data_raw:
+            raise RelationNotFoundError(relation_id)
 
-    def relation_set(self, relation_id, key, value, is_app):
-        relation = self._relation_data[relation_id]
+        relation = self._relation_data_raw[relation_id]
         if is_app:
             bucket_key = self.app_name
         else:
@@ -1198,16 +1490,16 @@ def relation_set(self, relation_id, key, value, is_app):
         else:
             bucket[key] = value
 
-    def config_get(self):
+    def config_get(self) -> _TestingConfig:
         return self._config
 
     def is_leader(self):
         return self._is_leader
 
-    def application_version_set(self, version):
+    def application_version_set(self, version: str):
         self._workload_version = version
 
-    def resource_get(self, resource_name):
+    def resource_get(self, resource_name: str):
         if resource_name not in self._resources_map:
             raise model.ModelError(
                 "ERROR could not download resource: HTTP request failed: "
@@ -1227,30 +1519,36 @@ def resource_get(self, resource_name):
                 resource_file.write(contents)
         return resource_filename
 
-    def pod_spec_set(self, spec, k8s_resources):
+    def pod_spec_set(self, spec: 'model.K8sSpec', k8s_resources: Any):  # fixme: any
         self._pod_spec = (spec, k8s_resources)
 
-    def status_get(self, *, is_app=False):
+    def status_get(self, *, is_app: bool = False):
         if is_app:
             return self._app_status
         else:
             return self._unit_status
 
-    def status_set(self, status, message='', *, is_app=False):
+    def status_set(self, status: '_StatusName', message: str = '', *, is_app: bool = False):
         if is_app:
             self._app_status = {'status': status, 'message': message}
         else:
             self._unit_status = {'status': status, 'message': message}
 
-    def storage_list(self, name):
+    def storage_list(self, name: str, include_detached: bool = False):
+        """Returns a list of all attached storage mounts for the given storage name.
+
+        Args:
+            name: name (i.e. from metadata.yaml).
+            include_detached: True to include unattached storage mounts as well.
+        """
         return list(index for index in self._storage_list[name]
-                    if self._storage_is_attached(name, index))
+                    if include_detached or self._storage_is_attached(name, index))
 
-    def storage_get(self, storage_name_id, attribute):
+    def storage_get(self, storage_name_id: str, attribute: str) -> Any:
         name, index = storage_name_id.split("/", 1)
         index = int(index)
         try:
-            if index in self._storage_detached[name]:
+            if index not in self._storage_attached[name]:
                 raise KeyError()  # Pretend the key isn't there
             else:
                 return self._storage_list[name][index][attribute]
@@ -1258,14 +1556,14 @@ def storage_get(self, storage_name_id, attribute):
             raise model.ModelError(
                 'ERROR invalid value "{}/{}" for option -s: storage not found'.format(name, index))
 
-    def storage_add(self, name: str, count: int = 1) -> typing.List[int]:
+    def storage_add(self, name: str, count: int = 1) -> List[int]:
         if '/' in name:
             raise model.ModelError('storage name cannot contain "/"')
 
         if name not in self._storage_list:
             self._storage_list[name] = {}
-        result = []
-        for i in range(count):
+        result = []  # type: List[int]
+        for _ in range(count):
             index = self._storage_index_counter
             self._storage_index_counter += 1
             self._storage_list[name][index] = {
@@ -1281,12 +1579,13 @@ def _storage_detach(self, storage_id: str):
         index = int(index)
 
         for client in self._pebble_clients.values():
-            client._fs.remove_mount(name)
+            client._fs.remove_mount(name)  # pyright: ReportPrivateUsage=false
 
         if self._storage_is_attached(name, index):
-            self._storage_detached[name].add(index)
+            self._storage_attached[name].remove(index)
 
     def _storage_attach(self, storage_id: str):
+        """Mark the named storage_id as attached and return True if it was previously detached."""
         # NOTE: This is an extra function for _TestingModelBackend to simulate
         # re-attachment of a storage unit.  This is not present in
         # ops.model._ModelBackend.
@@ -1297,16 +1596,17 @@ def _storage_attach(self, storage_id: str):
                 if mount.storage != name:
                     continue
                 for index, store in self._storage_list[mount.storage].items():
-                    client._fs.add_mount(mount.storage, mount.location, store['location'])
+                    fs = client._fs  # pyright: reportPrivateUsage=false
+                    fs.add_mount(mount.storage, mount.location, store['location'])
 
         index = int(index)
         if not self._storage_is_attached(name, index):
-            self._storage_detached[name].remove(index)
+            self._storage_attached[name].add(index)
             return True
         return False
 
-    def _storage_is_attached(self, storage_name, storage_index):
-        return storage_index not in self._storage_detached[storage_name]
+    def _storage_is_attached(self, storage_name: str, storage_index: int):
+        return storage_index in self._storage_attached[storage_name]
 
     def _storage_remove(self, storage_id: str):
         # NOTE: This is an extra function for _TestingModelBackend to simulate
@@ -1317,32 +1617,32 @@ def _storage_remove(self, storage_id: str):
         index = int(index)
         self._storage_list[name].pop(index, None)
 
-    def action_get(self):
-        raise NotImplementedError(self.action_get)
+    def action_get(self):  # type:ignore
+        raise NotImplementedError(self.action_get)  # type:ignore
 
-    def action_set(self, results):
-        raise NotImplementedError(self.action_set)
+    def action_set(self, results):  # type:ignore
+        raise NotImplementedError(self.action_set)  # type:ignore
 
-    def action_log(self, message):
-        raise NotImplementedError(self.action_log)
+    def action_log(self, message):  # type:ignore
+        raise NotImplementedError(self.action_log)  # type:ignore
 
-    def action_fail(self, message=''):
-        raise NotImplementedError(self.action_fail)
+    def action_fail(self, message=''):  # type:ignore
+        raise NotImplementedError(self.action_fail)  # type:ignore
 
-    def network_get(self, endpoint_name, relation_id=None):
-        raise NotImplementedError(self.network_get)
+    def network_get(self, endpoint_name, relation_id=None):  # type:ignore
+        raise NotImplementedError(self.network_get)  # type:ignore
 
-    def add_metrics(self, metrics, labels=None):
-        raise NotImplementedError(self.add_metrics)
+    def add_metrics(self, metrics, labels=None):  # type:ignore
+        raise NotImplementedError(self.add_metrics)  # type:ignore
 
     @classmethod
-    def log_split(cls, message, max_len):
-        raise NotImplementedError(cls.log_split)
+    def log_split(cls, message, max_len=model.MAX_LOG_LINE_LEN):  # type:ignore
+        raise NotImplementedError(cls.log_split)  # type:ignore
 
-    def juju_log(self, level, msg):
-        raise NotImplementedError(self.juju_log)
+    def juju_log(self, level, msg):  # type:ignore
+        raise NotImplementedError(self.juju_log)  # type:ignore
 
-    def get_pebble(self, socket_path: str) -> 'pebble.Client':
+    def get_pebble(self, socket_path: str) -> '_TestingPebbleClient':
         container = socket_path.split('/')[3]  # /charm/containers/<container_name>/pebble.socket
         client = self._pebble_clients.get(container, None)
         if client is None:
@@ -1358,7 +1658,7 @@ def get_pebble(self, socket_path: str) -> 'pebble.Client':
         self._pebble_clients_can_connect[client] = not SIMULATE_CAN_CONNECT
         return client
 
-    def planned_units(self):
+    def planned_units(self) -> int:
         """Simulate fetching the number of planned application units from the model.
 
         If self._planned_units is None, then we simulate what the Juju controller will do, which is
@@ -1369,17 +1669,15 @@ def planned_units(self):
         if self._planned_units is not None:
             return self._planned_units
 
-        units = []
-        peer_names = set(self._meta.peers.keys())
+        units = set()  # type: Set[str]
+        peer_names = set(self._meta.peers.keys())  # type: Set[str]
         for peer_id, peer_name in self._relation_names.items():
             if peer_name not in peer_names:
                 continue
             peer_units = self._relation_list_map[peer_id]
-            units += peer_units
+            units.update(peer_units)
 
-        count = len(set(units))  # de-dupe and get length.
-
-        return count + 1  # Account for this unit.
+        return len(units) + 1  # Account for this unit.
 
 
 @_copy_docstrings(pebble.Client)
@@ -1393,14 +1691,14 @@ class _TestingPebbleClient:
 
     def __init__(self, backend: _TestingModelBackend):
         self._backend = _TestingModelBackend
-        self._layers = {}
+        self._layers = {}  # type: Dict[str, pebble.Layer]
         # Has a service been started/stopped?
-        self._service_status = {}
+        self._service_status = {}  # type: Dict[str, pebble.ServiceStatus]
         self._fs = _TestingFilesystem()
         self._backend = backend
 
     def _check_connection(self):
-        if not self._backend._can_connect(self):
+        if not self._backend._can_connect(self):  # pyright: reportPrivateUsage=false
             raise pebble.ConnectionError('cannot connect to pebble')
 
     def get_system_info(self) -> pebble.SystemInfo:
@@ -1409,15 +1707,16 @@ def get_system_info(self) -> pebble.SystemInfo:
 
     def get_warnings(
             self, select: pebble.WarningState = pebble.WarningState.PENDING,
-    ) -> typing.List['pebble.Warning']:
+    ) -> List['pebble.Warning']:
         raise NotImplementedError(self.get_warnings)
 
     def ack_warnings(self, timestamp: datetime.datetime) -> int:
         raise NotImplementedError(self.ack_warnings)
 
     def get_changes(
-            self, select: pebble.ChangeState = pebble.ChangeState.IN_PROGRESS, service: str = None,
-    ) -> typing.List[pebble.Change]:
+            self, select: pebble.ChangeState = pebble.ChangeState.IN_PROGRESS,
+            service: Optional[str] = None,
+    ) -> List[pebble.Change]:
         raise NotImplementedError(self.get_changes)
 
     def get_change(self, change_id: pebble.ChangeID) -> pebble.Change:
@@ -1426,7 +1725,7 @@ def get_change(self, change_id: pebble.ChangeID) -> pebble.Change:
     def abort_change(self, change_id: pebble.ChangeID) -> pebble.Change:
         raise NotImplementedError(self.abort_change)
 
-    def autostart_services(self, timeout: float = 30.0, delay: float = 0.1) -> pebble.ChangeID:
+    def autostart_services(self, timeout: float = 30.0, delay: float = 0.1):
         self._check_connection()
         for name, service in self._render_services().items():
             # TODO: jam 2021-04-20 This feels awkward that Service.startup might be a string or
@@ -1439,12 +1738,12 @@ def autostart_services(self, timeout: float = 30.0, delay: float = 0.1) -> pebbl
             if startup == pebble.ServiceStartup.ENABLED:
                 self._service_status[name] = pebble.ServiceStatus.ACTIVE
 
-    def replan_services(self, timeout: float = 30.0, delay: float = 0.1) -> pebble.ChangeID:
+    def replan_services(self, timeout: float = 30.0, delay: float = 0.1):
         return self.autostart_services(timeout, delay)
 
     def start_services(
-            self, services: typing.List[str], timeout: float = 30.0, delay: float = 0.1,
-    ) -> pebble.ChangeID:
+            self, services: List[str], timeout: float = 30.0, delay: float = 0.1,
+    ):
         # A common mistake is to pass just the name of a service, rather than a list of services,
         # so trap that so it is caught quickly.
         if isinstance(services, str):
@@ -1469,7 +1768,7 @@ def start_services(
                 raise pebble.ChangeError('''\
 cannot perform the following tasks:
 - Start service "{}" (service "{}" was previously started)
-'''.format(name, name), change=1234)  # the change id is not respected
+'''.format(name, name), change=1234)  # type:ignore # the change id is not respected
         for name in services:
             # If you try to start a service which is started, you get a ChangeError:
             # $ PYTHONPATH=. python3 ./test/pebble_cli.py start serv
@@ -1478,8 +1777,8 @@ def start_services(
             self._service_status[name] = pebble.ServiceStatus.ACTIVE
 
     def stop_services(
-            self, services: typing.List[str], timeout: float = 30.0, delay: float = 0.1,
-    ) -> pebble.ChangeID:
+            self, services: List[str], timeout: float = 30.0, delay: float = 0.1,
+    ):
         # handle a common mistake of passing just a name rather than a list of names
         if isinstance(services, str):
             raise TypeError('stop_services should take a list of names, not just "{}"'.format(
@@ -1504,13 +1803,13 @@ def stop_services(
                 raise pebble.ChangeError('''\
 ChangeError: cannot perform the following tasks:
 - Stop service "{}" (service "{}" is not active)
-'''.format(name, name), change=1234)  # the change id is not respected
+'''.format(name, name), change=1234)  # type: ignore # the change id is not respected
         for name in services:
             self._service_status[name] = pebble.ServiceStatus.INACTIVE
 
     def restart_services(
-            self, services: typing.List[str], timeout: float = 30.0, delay: float = 0.1,
-    ) -> pebble.ChangeID:
+            self, services: List[str], timeout: float = 30.0, delay: float = 0.1,
+    ):
         # handle a common mistake of passing just a name rather than a list of names
         if isinstance(services, str):
             raise TypeError('restart_services should take a list of names, not just "{}"'.format(
@@ -1536,7 +1835,7 @@ def wait_change(
         raise NotImplementedError(self.wait_change)
 
     def add_layer(
-            self, label: str, layer: typing.Union[str, dict, pebble.Layer], *,
+            self, label: str, layer: Union[str, 'pebble.LayerDict', pebble.Layer], *,
             combine: bool = False):
         # I wish we could combine some of this helpful object corralling with the actual backend,
         # rather than having to re-implement it. Maybe we could subclass
@@ -1554,8 +1853,6 @@ def add_layer(
         self._check_connection()
 
         if label in self._layers:
-            # TODO: jam 2021-04-19 These should not be RuntimeErrors but should be proper error
-            #  types. https://github.com/canonical/operator/issues/514
             if not combine:
                 raise RuntimeError('400 Bad Request: layer "{}" already exists'.format(label))
             layer = self._layers[label]
@@ -1573,15 +1870,15 @@ def add_layer(
                 elif service.override == 'merge':
                     if combine and name in layer.services:
                         s = layer.services[name]
-                        s._merge(service)
+                        s._merge(service)  # type: ignore # noqa
                     else:
                         layer.services[name] = service
 
         else:
             self._layers[label] = layer_obj
 
-    def _render_services(self) -> typing.Mapping[str, pebble.Service]:
-        services = {}
+    def _render_services(self) -> Dict[str, pebble.Service]:
+        services = {}  # type: Dict[str, pebble.Service]
         for key in sorted(self._layers.keys()):
             layer = self._layers[key]
             for name, service in layer.services.items():
@@ -1599,14 +1896,14 @@ def get_plan(self) -> pebble.Plan:
             plan.services[name] = services[name]
         return plan
 
-    def get_services(self, names: typing.List[str] = None) -> typing.List[pebble.ServiceInfo]:
+    def get_services(self, names: Optional[List[str]] = None) -> List[pebble.ServiceInfo]:
         if isinstance(names, str):
             raise TypeError('start_services should take a list of names, not just "{}"'.format(
                 names))
 
         self._check_connection()
         services = self._render_services()
-        infos = []
+        infos = []  # type: List[pebble.ServiceInfo]
         if names is None:
             names = sorted(services.keys())
         for name in sorted(names):
@@ -1627,15 +1924,19 @@ def get_services(self, names: typing.List[str] = None) -> typing.List[pebble.Ser
             infos.append(info)
         return infos
 
-    def pull(self, path: str, *, encoding: str = 'utf-8') -> typing.Union[typing.BinaryIO,
-                                                                          typing.TextIO]:
+    def pull(self, path: str, *,
+             encoding: str = 'utf-8') -> Union[BinaryIO, TextIO]:
         self._check_connection()
         return self._fs.open(path, encoding=encoding)
 
     def push(
-            self, path: str, source: typing.Union[bytes, str, typing.BinaryIO, typing.TextIO], *,
-            encoding: str = 'utf-8', make_dirs: bool = False, permissions: int = None,
-            user_id: int = None, user: str = None, group_id: int = None, group: str = None):
+            self, path: str, source: 'ReadableBuffer', *,
+            encoding: str = 'utf-8', make_dirs: bool = False, permissions: Optional[int] = None,
+            user_id: Optional[int] = None,
+            user: Optional[str] = None,
+            group_id: Optional[int] = None,
+            group: Optional[str] = None
+    ) -> None:
         self._check_connection()
         if permissions is not None and not (0 <= permissions <= 0o777):
             raise pebble.PathError(
@@ -1654,8 +1955,8 @@ def push(
                 'paths must be absolute, got {!r}'.format(e.args[0])
             )
 
-    def list_files(self, path: str, *, pattern: str = None,
-                   itself: bool = False) -> typing.List[pebble.FileInfo]:
+    def list_files(self, path: str, *, pattern: Optional[str] = None,
+                   itself: bool = False) -> List[pebble.FileInfo]:
         self._check_connection()
         try:
             files = [self._fs.get_path(path)]
@@ -1678,11 +1979,19 @@ def list_files(self, path: str, *, pattern: str = None,
             _File: pebble.FileType.FILE,
             _Directory: pebble.FileType.DIRECTORY,
         }
+
+        def get_pebble_file_type(file: '_FileOrDir') -> pebble.FileType:
+            pebble_type = type_mappings.get(type(file))
+            if not pebble_type:
+                raise ValueError('unable to convert file {} '
+                                 '(type not one of {})'.format(file, type_mappings))
+            return pebble_type
+
         return [
             pebble.FileInfo(
                 path=str(file.path),
                 name=file.name,
-                type=type_mappings.get(type(file)),
+                type=get_pebble_file_type(file),
                 size=file.size if isinstance(file, _File) else None,
                 permissions=file.kwargs.get('permissions'),
                 last_modified=file.last_modified,
@@ -1695,8 +2004,14 @@ def list_files(self, path: str, *, pattern: str = None,
         ]
 
     def make_dir(
-            self, path: str, *, make_parents: bool = False, permissions: int = None,
-            user_id: int = None, user: str = None, group_id: int = None, group: str = None):
+            self, path: str, *,
+            make_parents: bool = False,
+            permissions: Optional[int] = None,
+            user_id: Optional[int] = None,
+            user: Optional[str] = None,
+            group_id: Optional[int] = None,
+            group: Optional[str] = None
+    ) -> None:
         self._check_connection()
         if permissions is not None and not (0 <= permissions <= 0o777):
             raise pebble.PathError(
@@ -1735,10 +2050,10 @@ def remove_path(self, path: str, *, recursive: bool = False):
                 'generic-file-error', 'cannot remove non-empty directory without recursive=True')
         self._fs.delete_path(path)
 
-    def exec(self, command, **kwargs):
-        raise NotImplementedError(self.exec)
+    def exec(self, command, **kwargs):  # type:ignore
+        raise NotImplementedError(self.exec)  # type:ignore
 
-    def send_signal(self, sig: typing.Union[int, str], *service_names: str):
+    def send_signal(self, sig: Union[int, str], *service_names: str):
         if not service_names:
             raise TypeError('send_signal expected at least 1 service name, got 0')
         self._check_connection()
@@ -1776,8 +2091,8 @@ def send_signal(self, sig: typing.Union[int, str], *service_names: str):
                 status='Internal Server Error',
                 message=message)
 
-    def get_checks(self, level=None, names=None):
-        raise NotImplementedError(self.get_checks)
+    def get_checks(self, level=None, names=None):  # type:ignore
+        raise NotImplementedError(self.get_checks)  # type:ignore
 
 
 class NonAbsolutePathError(Exception):
@@ -1803,7 +2118,7 @@ def __init__(self, location: pathlib.PurePosixPath, src: pathlib.Path):
 
         src.mkdir(exist_ok=True, parents=True)
 
-    def contains(self, path: typing.Union[str, pathlib.PurePosixPath]) -> bool:
+    def contains(self, path: '_StringOrPath') -> bool:
         """Returns true whether path resides within this simulated storage mount's location."""
         try:
             pathlib.PurePosixPath(path).relative_to(self._location)
@@ -1811,8 +2126,7 @@ def contains(self, path: typing.Union[str, pathlib.PurePosixPath]) -> bool:
         except Exception:
             return False
 
-    def check_contains(self, path: typing.Union[str,
-                       pathlib.PurePosixPath]) -> pathlib.PurePosixPath:
+    def check_contains(self, path: '_StringOrPath') -> pathlib.PurePosixPath:
         """Raises if path does not reside within this simulated storage mount's location."""
         if not self.contains(path):
             msg = 'the provided path "{!s}" does not reside within the mount location "{!s}"' \
@@ -1827,9 +2141,9 @@ def _srcpath(self, path: pathlib.PurePosixPath) -> pathlib.Path:
 
     def create_dir(
             self,
-            path: pathlib.PurePosixPath,
+            path: '_StringOrPath',
             make_parents: bool = False,
-            **kwargs) -> '_Directory':
+            **kwargs: Any) -> '_Directory':
         if not pathlib.PurePosixPath(path).is_absolute():
             raise NonAbsolutePathError(str(path))
         path = self.check_contains(path)
@@ -1850,44 +2164,46 @@ def create_dir(
 
     def create_file(
             self,
-            path: pathlib.PurePosixPath,
-            data: typing.Union[bytes, str, typing.BinaryIO, typing.TextIO],
+            path: '_StringOrPath',
+            data: 'ReadableBuffer',
             encoding: str = 'utf-8',
             make_dirs: bool = False,
-            **kwargs
+            **kwargs: Any
     ) -> '_File':
-        path = self.check_contains(path)
-        srcpath = self._srcpath(path)
+        posixpath = self.check_contains(path)  # type: pathlib.PurePosixPath
+        srcpath = self._srcpath(posixpath)
 
         dirname = srcpath.parent
         if not dirname.exists():
             if not make_dirs:
-                raise FileNotFoundError(str(path.parent))
+                raise FileNotFoundError(str(posixpath.parent))
             dirname.mkdir(parents=True, exist_ok=True)
 
         if isinstance(data, str):
             data = data.encode(encoding=encoding)
-        elif not isinstance(data, bytes):
+        elif isinstance(data, (StringIO, BytesIO)):
             data = data.getvalue()
             if isinstance(data, str):
                 data = data.encode()
 
+        byte_data = cast(bytes, data)
+
         with srcpath.open('wb') as f:
-            f.write(data)
+            f.write(byte_data)
 
-        return _File(path, data, encoding=encoding, **kwargs)
+        return _File(posixpath, byte_data, encoding=encoding, **kwargs)
 
-    def list_dir(self, path: pathlib.PurePosixPath) -> typing.List['_File']:
-        path = self.check_contains(path)
-        srcpath = self._srcpath(path)
+    def list_dir(self, path: '_StringOrPath') -> List['_FileOrDir']:
+        _path = self.check_contains(path)
+        srcpath = self._srcpath(_path)
 
-        results = []
+        results = []  # type: List[_FileOrDir]
         if not srcpath.exists():
-            raise FileNotFoundError(str(path))
+            raise FileNotFoundError(str(_path))
         if not srcpath.is_dir():
-            raise NotADirectoryError(str(path))
+            raise NotADirectoryError(str(_path))
         for fpath in srcpath.iterdir():
-            mountpath = path / fpath.name
+            mountpath = _path / fpath.name
             if fpath.is_dir():
                 results.append(_Directory(mountpath))
             elif fpath.is_file():
@@ -1899,9 +2215,9 @@ def list_dir(self, path: pathlib.PurePosixPath) -> typing.List['_File']:
 
     def open(
             self,
-            path: typing.Union[str, pathlib.PurePosixPath],
-            encoding: typing.Optional[str] = 'utf-8',
-    ) -> typing.Union[typing.BinaryIO, typing.TextIO]:
+            path: '_StringOrPath',
+            encoding: Optional[str] = 'utf-8',
+    ) -> Union[BinaryIO, TextIO]:
         path = self.check_contains(path)
 
         file = self.get_path(path)
@@ -1909,8 +2225,7 @@ def open(
             raise IsADirectoryError(str(file.path))
         return file.open(encoding=encoding)
 
-    def get_path(self, path: typing.Union[str, pathlib.PurePosixPath]
-                 ) -> typing.Union['_Directory', '_File']:
+    def get_path(self, path: '_StringOrPath') -> '_FileOrDir':
         path = self.check_contains(path)
         srcpath = self._srcpath(path)
         if srcpath.is_dir():
@@ -1920,7 +2235,7 @@ def get_path(self, path: typing.Union[str, pathlib.PurePosixPath]
         with srcpath.open('rb') as f:
             return _File(path, f.read())
 
-    def delete_path(self, path: typing.Union[str, pathlib.PurePosixPath]) -> None:
+    def delete_path(self, path: '_StringOrPath') -> None:
         path = self.check_contains(path)
         srcpath = self._srcpath(path)
         if srcpath.exists():
@@ -1932,23 +2247,24 @@ def delete_path(self, path: typing.Union[str, pathlib.PurePosixPath]) -> None:
 class _TestingFilesystem:
     r"""An in-memory mock of a pebble-controlled container's filesystem.
 
-    For now, the filesystem is assumed to be a POSIX-style filesytem; Windows-style directories
+    For now, the filesystem is assumed to be a POSIX-style filesystem; Windows-style directories
     (e.g. \, \foo\bar, C:\foo\bar) are not supported.
     """
 
     def __init__(self):
         self.root = _Directory(pathlib.PurePosixPath('/'))
-        self._mounts = {}
+        self._mounts = {}  # type: Dict[str, _TestingStorageMount]
 
-    def add_mount(self, name, mount_path, backing_src_path):
+    def add_mount(self, name: str, mount_path: Union[str, pathlib.Path],
+                  backing_src_path: Union[str, pathlib.Path]):
         self._mounts[name] = _TestingStorageMount(
             pathlib.PurePosixPath(mount_path), pathlib.Path(backing_src_path))
 
-    def remove_mount(self, name):
+    def remove_mount(self, name: str):
         if name in self._mounts:
             del self._mounts[name]
 
-    def create_dir(self, path: str, make_parents: bool = False, **kwargs) -> '_Directory':
+    def create_dir(self, path: str, make_parents: bool = False, **kwargs: Any) -> '_Directory':
         if not path.startswith('/'):
             raise NonAbsolutePathError(path)
         for mount in self._mounts.values():
@@ -1988,10 +2304,10 @@ def create_dir(self, path: str, make_parents: bool = False, **kwargs) -> '_Direc
     def create_file(
             self,
             path: str,
-            data: typing.Union[bytes, str, typing.BinaryIO, typing.TextIO],
-            encoding: typing.Optional[str] = 'utf-8',
+            data: 'ReadableBuffer',
+            encoding: str = 'utf-8',
             make_dirs: bool = False,
-            **kwargs
+            **kwargs: Any
     ) -> '_File':
         if not path.startswith('/'):
             raise NonAbsolutePathError(path)
@@ -2016,7 +2332,7 @@ def create_file(
                 'generic-file-error', 'parent is not a directory: {}'.format(str(dir_)))
         return dir_.create_file(path_obj.name, data, encoding=encoding, **kwargs)
 
-    def list_dir(self, path) -> typing.List['_File']:
+    def list_dir(self, path: '_StringOrPath') -> List['_FileOrDir']:
         for mount in self._mounts.values():
             if mount.contains(path):
                 return mount.list_dir(path)
@@ -2032,14 +2348,13 @@ def list_dir(self, path) -> typing.List['_File']:
             if not isinstance(current_dir, _Directory):
                 # For now, ignoring other possible cases besides File and Directory (e.g. Symlink).
                 raise NotImplementedError()
-
-        return [child for child in current_dir]
+        return list(current_dir)
 
     def open(
             self,
-            path: typing.Union[str, pathlib.PurePosixPath],
-            encoding: typing.Optional[str] = 'utf-8',
-    ) -> typing.Union[typing.BinaryIO, typing.TextIO]:
+            path: '_StringOrPath',
+            encoding: Optional[str] = 'utf-8',
+    ) -> Union[BinaryIO, TextIO]:
         for mount in self._mounts.values():
             if mount.contains(path):
                 return mount.open(path, encoding)
@@ -2049,8 +2364,7 @@ def open(
             raise IsADirectoryError(str(file.path))
         return file.open(encoding=encoding)
 
-    def get_path(self, path: typing.Union[str, pathlib.PurePosixPath]) \
-            -> typing.Union['_Directory', '_File']:
+    def get_path(self, path: '_StringOrPath') -> '_FileOrDir':
         for mount in self._mounts.values():
             if mount.contains(path):
                 return mount.get_path(path)
@@ -2059,27 +2373,33 @@ def get_path(self, path: typing.Union[str, pathlib.PurePosixPath]) \
         current_object = self.root
         for token in tokens:
             # ASSUMPTION / TESTME: object might be file
+            if isinstance(current_object, _File):
+                raise RuntimeError('cannot expand path {!r} from {!r}: '
+                                   'not a directory'.format(token, current_object))
             if token in current_object:
                 current_object = current_object[token]
             else:
                 raise FileNotFoundError(str(current_object.path / token))
         return current_object
 
-    def delete_path(self, path: typing.Union[str, pathlib.PurePosixPath]) -> None:
+    def delete_path(self, path: '_StringOrPath') -> None:
         for mount in self._mounts.values():
             if mount.contains(path):
                 return mount.delete_path(path)
         path = pathlib.PurePosixPath(path)
         parent_dir = self.get_path(path.parent)
+        if not isinstance(parent_dir, _Directory):
+            raise RuntimeError('cannot delete {}: parent {!r}'
+                               'is not a directory'.format(path.name, parent_dir))
         del parent_dir[path.name]
 
 
 class _Directory:
-    def __init__(self, path: pathlib.PurePosixPath, **kwargs):
+    def __init__(self, path: pathlib.PurePosixPath, **kwargs: Any):
         self.path = path
-        self._children = {}
+        self._children = {}  # type: Dict[str, Union[_Directory, _File]]
         self.last_modified = datetime.datetime.now()
-        self.kwargs = kwargs
+        self.kwargs = cast('_FileKwargs', kwargs)
 
     @property
     def name(self) -> str:
@@ -2090,10 +2410,10 @@ def name(self) -> str:
     def __contains__(self, child: str) -> bool:
         return child in self._children
 
-    def __iter__(self) -> typing.Iterator[typing.Union['_File', '_Directory']]:
+    def __iter__(self) -> Iterator['_FileOrDir']:
         return (value for value in self._children.values())
 
-    def __getitem__(self, key: str) -> typing.Union['_File', '_Directory']:
+    def __getitem__(self, key: str) -> '_FileOrDir':
         return self._children[key]
 
     def __delitem__(self, key: str) -> None:
@@ -2105,40 +2425,44 @@ def __delitem__(self, key: str) -> None:
     def __len__(self):
         return len(self._children)
 
-    def create_dir(self, name: str, **kwargs) -> '_Directory':
-        self._children[name] = _Directory(self.path / name, **kwargs)
-        return self._children[name]
+    def create_dir(self, name: str, **kwargs: Any) -> '_Directory':
+        dirc = _Directory(self.path / name, **kwargs)
+        self._children[name] = dirc
+        return dirc
 
     def create_file(
             self,
             name: str,
-            data: typing.Union[bytes, str, typing.BinaryIO, typing.TextIO],
-            encoding: typing.Optional[str] = 'utf-8',
-            **kwargs
+            data: 'ReadableBuffer',
+            encoding: Optional[str] = 'utf-8',
+            **kwargs: Any
     ) -> '_File':
-        self._children[name] = _File(self.path / name, data, encoding=encoding, **kwargs)
-        return self._children[name]
+        file = _File(self.path / name, data, encoding=encoding, **kwargs)
+        self._children[name] = file
+        return file
 
 
 class _File:
     def __init__(
             self,
             path: pathlib.PurePosixPath,
-            data: typing.Union[str, bytes, typing.BinaryIO, typing.TextIO],
-            encoding: typing.Optional[str] = 'utf-8',
-            **kwargs):
+            data: 'ReadableBuffer',
+            encoding: Optional[str] = 'utf-8',
+            **kwargs: Any):
 
-        if hasattr(data, 'read'):
-            data = data.read()
-        if isinstance(data, str):
-            data = data.encode(encoding)
-        data_size = len(data)
+        if hasattr(data, 'read'):  # if BytesIO/StringIO:
+            data = data.read()  # type: ignore
+        if isinstance(data, str):  # if str/StringIO
+            data = data.encode(encoding)  # type: ignore
+
+        byte_data = cast(bytes, data)  # it's bytes by now; pyright doesn't like redeclaring vars
+        data_size = len(byte_data)
 
         self.path = path
-        self.data = data
+        self.data = byte_data
         self.size = data_size
         self.last_modified = datetime.datetime.now()
-        self.kwargs = kwargs
+        self.kwargs = cast('_FileKwargs', kwargs)
 
     @property
     def name(self) -> str:
@@ -2146,9 +2470,10 @@ def name(self) -> str:
 
     def open(
             self,
-            encoding: typing.Optional[str] = 'utf-8',
-    ) -> typing.Union[typing.TextIO, typing.BinaryIO]:
+            encoding: Optional[str] = 'utf-8',
+    ) -> Union[TextIO, BinaryIO]:
         if encoding is None:
             return BytesIO(self.data)
         else:
-            return StringIO(self.data.decode(encoding))
+            raw = self.data.decode(encoding)
+            return StringIO(raw)
diff --git a/test/test_charm.py b/test/test_charm.py
index 51cae5c4f..1d1f828c7 100755
--- a/test/test_charm.py
+++ b/test/test_charm.py
@@ -11,7 +11,7 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-
+import functools
 import os
 import shutil
 import tempfile
@@ -24,6 +24,7 @@
     CharmMeta,
     ContainerMeta,
     ContainerStorageMeta,
+    StartEvent,
 )
 from ops.framework import EventBase, EventSource, Framework
 from ops.model import Model, Storage, _ModelBackend
@@ -95,6 +96,42 @@ def _on_start(self, event):
         with self.assertRaisesRegex(TypeError, "observer methods must now be explicitly provided"):
             framework.observe(charm.on.start, charm)
 
+    def test_observe_decorated_method(self):
+        # we test that charm methods decorated with @functools.wraps(wrapper)
+        # can be observed by Framework. Simpler decorators won't work because
+        # Framework searches for __self__ and other method things; functools.wraps
+        # is more careful and it still works, this test is here to ensure that
+        # it keeps working in future releases, as this is presently the only
+        # way we know of to cleanly decorate charm event observers.
+        events = []
+
+        def dec(fn):
+            # simple decorator that appends to the nonlocal
+            # `events` list all events it receives
+            @functools.wraps(fn)
+            def wrapper(charm, evt):
+                events.append(evt)
+                fn(charm, evt)
+            return wrapper
+
+        class MyCharm(CharmBase):
+            def __init__(self, *args):
+                super().__init__(*args)
+                framework.observe(self.on.start, self._on_start)
+                self.seen = None
+
+            @dec
+            def _on_start(self, event):
+                self.seen = event
+
+        framework = self.create_framework()
+        charm = MyCharm(framework)
+        charm.on.start.emit()
+        # check that the event has been seen by the decorator
+        self.assertEqual(1, len(events))
+        # check that the event has been seen by the observer
+        self.assertIsInstance(charm.seen, StartEvent)
+
     def test_empty_action(self):
         meta = CharmMeta.from_yaml('name: my-charm', '')
         self.assertEqual(meta.actions, {})
@@ -393,7 +430,15 @@ def _get_action_test_meta(cls):
   description: "Start the unit."
 ''')
 
-    def _test_action_events(self, cmd_type):
+    def _setup_test_action(self):
+        os.environ['JUJU_ACTION_NAME'] = 'foo-bar'
+        fake_script(self, 'action-get', """echo '{"foo-name": "name", "silent": true}'""")
+        fake_script(self, 'action-set', "")
+        fake_script(self, 'action-log', "")
+        fake_script(self, 'action-fail', "")
+        self.meta = self._get_action_test_meta()
+
+    def test_action_events(self):
 
         class MyCharm(CharmBase):
 
@@ -411,13 +456,7 @@ def _on_foo_bar_action(self, event):
             def _on_start_action(self, event):
                 pass
 
-        fake_script(self, cmd_type + '-get', """echo '{"foo-name": "name", "silent": true}'""")
-        fake_script(self, cmd_type + '-set', "")
-        fake_script(self, cmd_type + '-log', "")
-        fake_script(self, cmd_type + '-fail', "")
-        self.meta = self._get_action_test_meta()
-
-        os.environ['JUJU_{}_NAME'.format(cmd_type.upper())] = 'foo-bar'
+        self._setup_test_action()
         framework = self.create_framework()
         charm = MyCharm(framework)
 
@@ -428,10 +467,10 @@ def _on_start_action(self, event):
         charm.on.foo_bar_action.emit()
         self.assertEqual(charm.seen_action_params, {"foo-name": "name", "silent": True})
         self.assertEqual(fake_script_calls(self), [
-            [cmd_type + '-get', '--format=json'],
-            [cmd_type + '-log', "test-log"],
-            [cmd_type + '-set', "res=val with spaces"],
-            [cmd_type + '-fail', "test-fail"],
+            ['action-get', '--format=json'],
+            ['action-log', "test-log"],
+            ['action-set', "res=val with spaces"],
+            ['action-fail', "test-fail"],
         ])
 
         # Make sure that action events that do not match the current context are
@@ -439,8 +478,32 @@ def _on_start_action(self, event):
         with self.assertRaises(RuntimeError):
             charm.on.start_action.emit()
 
-    def test_action_events(self):
-        self._test_action_events('action')
+    def test_invalid_action_results(self):
+
+        class MyCharm(CharmBase):
+
+            def __init__(self, *args):
+                super().__init__(*args)
+                self.res = {}
+                framework.observe(self.on.foo_bar_action, self._on_foo_bar_action)
+
+            def _on_foo_bar_action(self, event):
+                event.set_results(self.res)
+
+        self._setup_test_action()
+        framework = self.create_framework()
+        charm = MyCharm(framework)
+
+        for bad_res in (
+                {'a': {'b': 'c'}, 'a.b': 'c'},
+                {'a': {'B': 'c'}},
+                {'a': {(1, 2): 'c'}},
+                {'a': {None: 'c'}},
+                {'aBc': 'd'}):
+            charm.res = bad_res
+
+            with self.assertRaises(ValueError):
+                charm.on.foo_bar_action.emit()
 
     def _test_action_event_defer_fails(self, cmd_type):
 
diff --git a/test/test_jujuversion.py b/test/test_jujuversion.py
index 214f602c8..542b81ea6 100755
--- a/test/test_jujuversion.py
+++ b/test/test_jujuversion.py
@@ -13,7 +13,6 @@
 # limitations under the License.
 
 import os
-import unittest
 import unittest.mock  # in this file, importing just 'patch' would be confusing
 
 from ops.jujuversion import JujuVersion
@@ -69,6 +68,11 @@ def test_has_controller_storage(self):
         self.assertTrue(JujuVersion('2.8.0').has_controller_storage())
         self.assertFalse(JujuVersion('2.7.9').has_controller_storage())
 
+    def test_has_secrets(self):
+        self.assertTrue(JujuVersion('3.0.2').has_secrets)
+        self.assertFalse(JujuVersion('3.0.1').has_secrets)
+        self.assertFalse(JujuVersion('2.9.30').has_secrets)
+
     def test_parsing_errors(self):
         invalid_versions = [
             "xyz",
diff --git a/test/test_main.py b/test/test_main.py
index e8a73a234..f109c15c5 100755
--- a/test/test_main.py
+++ b/test/test_main.py
@@ -28,7 +28,6 @@
 from unittest.mock import patch
 
 import logassert
-import yaml
 
 from ops.charm import (
     ActionEvent,
@@ -69,10 +68,6 @@
     'juju-log', '--log-level', 'DEBUG', '--',
     'Operator Framework {} up and running.'.format(version),
 ]
-SLOW_YAML_LOGLINE = [
-    'juju-log', '--log-level', 'DEBUG', '--',
-    'yaml does not have libyaml extensions, using slower pure Python yaml loader',
-]
 
 logger = logging.getLogger(__name__)
 
@@ -604,20 +599,11 @@ def test_collect_metrics(self):
 
         expected = [
             VERSION_LOGLINE,
-            ['juju-log', '--log-level', 'DEBUG', '--',
-             'Using local storage: {} already exists'.format(self.CHARM_STATE_FILE)],
             ['juju-log', '--log-level', 'DEBUG', '--', 'Emitting Juju event collect_metrics.'],
             ['add-metric', '--labels', 'bar=4.2', 'foo=42'],
         ]
-        if not yaml.__with_libyaml__:
-            expected.insert(1, SLOW_YAML_LOGLINE)
         calls = fake_script_calls(self)
 
-        if self.has_dispatch:
-            expected.insert(1, [
-                'juju-log', '--log-level', 'DEBUG', '--',
-                'Legacy {} does not exist.'.format(Path('hooks/collect-metrics'))])
-
         self.assertEqual(calls, expected)
 
     def test_logger(self):
@@ -656,17 +642,8 @@ def test_excepthook(self):
         calls = [' '.join(i) for i in fake_script_calls(self)]
 
         self.assertEqual(calls.pop(0), ' '.join(VERSION_LOGLINE))
-
-        if self.has_dispatch:
-            self.assertEqual(
-                calls.pop(0),
-                'juju-log --log-level DEBUG -- Legacy {} does not exist.'.format(
-                    Path("hooks/install")))
-
-        if not yaml.__with_libyaml__:
-            self.assertEqual(calls.pop(0), ' '.join(SLOW_YAML_LOGLINE))
-
         self.assertRegex(calls.pop(0), 'Using local storage: not a kubernetes charm')
+        self.assertRegex(calls.pop(0), 'Initializing SQLite local storage: ')
 
         self.maxDiff = None
         self.assertRegex(
@@ -888,9 +865,9 @@ def test_hook_and_dispatch(self):
             ['juju-log', '--log-level', 'DEBUG', '--',
              'Emitting Juju event install.'],
         ]
-        if not yaml.__with_libyaml__:
-            expected.insert(3, SLOW_YAML_LOGLINE)
-        self.assertEqual(fake_script_calls(self), expected)
+        calls = fake_script_calls(self)
+        self.assertRegex(' '.join(calls.pop(-2)), 'Initializing SQLite local storage: ')
+        self.assertEqual(calls, expected)
 
     @unittest.skipIf(is_windows, "this is UNIXish; TODO: write equivalent windows test")
     def test_non_executable_hook_and_dispatch(self):
@@ -908,9 +885,9 @@ def test_non_executable_hook_and_dispatch(self):
             ['juju-log', '--log-level', 'DEBUG', '--',
              'Emitting Juju event install.'],
         ]
-        if not yaml.__with_libyaml__:
-            expected.insert(2, SLOW_YAML_LOGLINE)
-        self.assertEqual(fake_script_calls(self), expected)
+        calls = fake_script_calls(self)
+        self.assertRegex(' '.join(calls.pop(-2)), 'Initializing SQLite local storage: ')
+        self.assertEqual(calls, expected)
 
     def test_hook_and_dispatch_with_failing_hook(self):
         self.stdout = self.stderr = tempfile.TemporaryFile()
@@ -995,9 +972,10 @@ def test_hook_and_dispatch_but_hook_is_dispatch_copy(self):
             ['juju-log', '--log-level', 'DEBUG', '--',
              'Emitting Juju event install.'],
         ]
-        if not yaml.__with_libyaml__:
-            expected.insert(5, SLOW_YAML_LOGLINE)
-        self.assertEqual(fake_script_calls(self), expected)
+        calls = fake_script_calls(self)
+        self.assertRegex(' '.join(calls.pop(-2)), 'Initializing SQLite local storage: ')
+
+        self.assertEqual(calls, expected)
 
 
 # NOTE
@@ -1154,4 +1132,3 @@ def test_not_if_already_local(self):
         meta = CharmMeta.from_yaml("series: [kubernetes]")
         with patch.dict(os.environ, {"JUJU_VERSION": "2.8"}), tempfile.NamedTemporaryFile() as fd:
             self.assertFalse(_should_use_controller_storage(Path(fd.name), meta))
-            self.assertLogged('Using local storage: {} already exists'.format(fd.name))
diff --git a/test/test_model.py b/test/test_model.py
index 0a5f8652b..173168fa6 100755
--- a/test/test_model.py
+++ b/test/test_model.py
@@ -18,6 +18,7 @@
 import json
 import os
 import pathlib
+import sys
 import tempfile
 import unittest
 from collections import OrderedDict
@@ -29,13 +30,13 @@
 import ops.charm
 import ops.model
 import ops.testing
+from ops import model
 from ops._private import yaml
 from ops.charm import RelationMeta, RelationRole
 from ops.pebble import APIError, FileInfo, FileType, ServiceInfo
 
 
 class TestModel(unittest.TestCase):
-
     def setUp(self):
         self.harness = ops.testing.Harness(ops.charm.CharmBase, meta='''
             name: myapp
@@ -58,10 +59,11 @@ def setUp(self):
             bar:
                 type: int
             qux:
-                type: bool
+                type: boolean
         ''')
         self.addCleanup(self.harness.cleanup)
         self.relation_id_db0 = self.harness.add_relation('db0', 'db')
+        self.harness._get_backend_calls(reset=True)
         self.model = self.harness.model
 
     def test_model_attributes(self):
@@ -176,13 +178,46 @@ def test_our_unit_is_our(self):
         self.assertTrue(self.model.unit._is_our_unit)
         self.assertTrue(self.model.unit.app._is_our_app)
 
+    def test_invalid_type_relation_data(self):
+        relation_id = self.harness.add_relation('db1', 'remoteapp1')
+        self.harness.add_relation_unit(relation_id, 'remoteapp1/0')
+
+        with self.assertRaises(model.RelationDataError):
+            with self.harness._event_context('foo_event'):
+                self.harness.update_relation_data(
+                    relation_id,
+                    'remoteapp1/0',
+                    {42: 'remoteapp1-0'})
+
+        with self.assertRaises(model.RelationDataError):
+            with self.harness._event_context('foo_event'):
+                self.harness.update_relation_data(
+                    relation_id,
+                    'remoteapp1/0',
+                    {'foo': 42})
+
+    def test_get_app_relation_data(self):
+        self.harness.begin()
+        relation_id = self.harness.add_relation('db1', 'remote')
+        self.harness.add_relation_unit(relation_id, 'remote/0')
+        local_app = self.harness.model.app.name
+        with self.harness._event_context('foo_event'):
+            self.harness.update_relation_data(
+                relation_id,
+                local_app,
+                {'foo': 'bar'})
+            assert self.harness.get_relation_data(
+                relation_id, self.harness.model.app) == self.harness.get_relation_data(
+                relation_id, local_app) == {'foo': 'bar'}
+
     def test_unit_relation_data(self):
         relation_id = self.harness.add_relation('db1', 'remoteapp1')
         self.harness.add_relation_unit(relation_id, 'remoteapp1/0')
-        self.harness.update_relation_data(
-            relation_id,
-            'remoteapp1/0',
-            {'host': 'remoteapp1-0'})
+        with self.harness._event_context('foo_event'):
+            self.harness.update_relation_data(
+                relation_id,
+                'remoteapp1/0',
+                {'host': 'remoteapp1-0'})
         self.model.relations._invalidate('db1')
         self.resetBackendCalls()
 
@@ -202,7 +237,9 @@ def test_unit_relation_data(self):
 
     def test_remote_app_relation_data(self):
         relation_id = self.harness.add_relation('db1', 'remoteapp1')
-        self.harness.update_relation_data(relation_id, 'remoteapp1', {'secret': 'cafedeadbeef'})
+        with self.harness._event_context('foo_event'):
+            self.harness.update_relation_data(relation_id, 'remoteapp1',
+                                              {'secret': 'cafedeadbeef'})
         self.harness.add_relation_unit(relation_id, 'remoteapp1/0')
         self.harness.add_relation_unit(relation_id, 'remoteapp1/1')
         self.resetBackendCalls()
@@ -226,9 +263,12 @@ def test_remote_app_relation_data(self):
 
     def test_relation_data_modify_remote(self):
         relation_id = self.harness.add_relation('db1', 'remoteapp1')
-        self.harness.update_relation_data(relation_id, 'remoteapp1', {'secret': 'cafedeadbeef'})
-        self.harness.add_relation_unit(relation_id, 'remoteapp1/0')
-        self.harness.update_relation_data(relation_id, 'remoteapp1/0', {'host': 'remoteapp1/0'})
+        with self.harness._event_context('foo_event'):
+            self.harness.update_relation_data(relation_id, 'remoteapp1',
+                                              {'secret': 'cafedeadbeef'})
+            self.harness.add_relation_unit(relation_id, 'remoteapp1/0')
+            self.harness.update_relation_data(relation_id, 'remoteapp1/0',
+                                              {'host': 'remoteapp1/0'})
         self.model.relations._invalidate('db1')
         self.resetBackendCalls()
 
@@ -239,8 +279,9 @@ def test_relation_data_modify_remote(self):
         self.assertIn('host', rel_db1.data[remoteapp1_0])
         self.assertEqual(repr(rel_db1.data[remoteapp1_0]), "{'host': 'remoteapp1/0'}")
 
-        with self.assertRaises(ops.model.RelationDataError):
-            rel_db1.data[remoteapp1_0]['foo'] = 'bar'
+        with self.harness._event_context('foo_event'):
+            with self.assertRaises(ops.model.RelationDataError):
+                rel_db1.data[remoteapp1_0]['foo'] = 'bar'
         self.assertNotIn('foo', rel_db1.data[remoteapp1_0])
 
         self.assertBackendCalls([
@@ -250,29 +291,35 @@ def test_relation_data_modify_remote(self):
         ])
 
         # this will fire more backend calls
+        with self.harness._event_context('foo_event'):
+            data_repr = repr(rel_db1.data)
         # the CountEqual and weird (and brittle) splitting is to accommodate python 3.5
         # TODO: switch to assertEqual when we drop 3.5
         self.assertCountEqual(
-            repr(rel_db1.data)[1:-1].split(', '),
+            data_repr[1:-1].split(', '),
             ["<ops.model.Unit myapp/0>: {}",
-             "<ops.model.Application myapp>: {}",
+             "<ops.model.Application myapp>: <n/a>",
              "<ops.model.Unit remoteapp1/0>: {'host': 'remoteapp1/0'}",
              "<ops.model.Application remoteapp1>: {'secret': 'cafedeadbeef'}"])
 
     def test_relation_data_modify_our(self):
         relation_id = self.harness.add_relation('db1', 'remoteapp1')
+
         self.harness.update_relation_data(relation_id, 'myapp/0', {'host': 'nothing'})
         self.resetBackendCalls()
-
-        rel_db1 = self.model.get_relation('db1')
-        # Force memory cache to be loaded.
-        self.assertIn('host', rel_db1.data[self.model.unit])
-        rel_db1.data[self.model.unit]['host'] = 'bar'
-        self.assertEqual(rel_db1.data[self.model.unit]['host'], 'bar')
+        with self.harness._event_context('foo_event'):
+            rel_db1 = self.model.get_relation('db1')
+            # update_relation_data will also trigger relation-get, so we
+            # invalidate the cache to ensure it will be reloaded
+            rel_db1.data[self.model.unit]._invalidate()
+            # Force memory cache to be loaded.
+            self.assertIn('host', rel_db1.data[self.model.unit])
+            rel_db1.data[self.model.unit]['host'] = 'bar'
+            self.assertEqual(rel_db1.data[self.model.unit]['host'], 'bar')
 
         self.assertBackendCalls([
             ('relation_get', relation_id, 'myapp/0', False),
-            ('relation_set', relation_id, 'host', 'bar', False),
+            ('update_relation_data', relation_id, self.model.unit, 'host', 'bar'),
         ])
 
     def test_app_relation_data_modify_local_as_leader(self):
@@ -291,13 +338,11 @@ def test_app_relation_data_modify_local_as_leader(self):
 
         self.assertEqual(rel_db1.data[local_app]['password'], 'foo')
 
-        self.assertBackendCalls([
-            ('relation_ids', 'db1'),
-            ('relation_list', relation_id),
-            ('relation_get', relation_id, 'myapp', True),
-            ('is_leader',),
-            ('relation_set', relation_id, 'password', 'foo', True),
-        ])
+        self.assertBackendCalls(
+            [('relation_ids', 'db1'),
+             ('relation_list', 1),
+             ('relation_get', 1, 'myapp', True),
+             ('update_relation_data', 1, self.model.app, 'password', 'foo')])
 
     def test_app_relation_data_modify_local_as_minion(self):
         relation_id = self.harness.add_relation('db1', 'remoteapp1')
@@ -311,19 +356,40 @@ def test_app_relation_data_modify_local_as_minion(self):
         rel_db1 = self.model.get_relation('db1')
         self.assertEqual(rel_db1.data[local_app], {'password': 'deadbeefcafe'})
 
-        with self.assertRaises(ops.model.RelationDataError):
-            rel_db1.data[local_app]['password'] = 'foobar'
-
-        self.assertBackendCalls([
-            ('relation_ids', 'db1'),
-            ('relation_list', relation_id),
-            ('relation_get', relation_id, 'myapp', True),
-            ('is_leader',),
-        ])
+        with self.harness._event_context('foo_event'):
+            # if we were inside an event context, we'd get:
+            with self.assertRaises(ops.model.RelationDataError):
+                rel_db1.data[local_app]['password'] = 'foobar'
+
+        self.assertBackendCalls([('relation_ids', 'db1'),
+                                 ('relation_list', 1),
+                                 ('relation_get', 1, 'myapp', True),
+                                 ('is_leader',)])
+
+    def test_relation_data_access_peer_leader(self):
+        r_id = self.harness.add_relation('db2', 'myapp')
+        self.harness.add_relation_unit(r_id, 'myapp/1')  # peer!
+        self.harness.update_relation_data(r_id, 'myapp', {'foo': 'bar'})
+        with self.harness._event_context('foo_event'):
+            # leaders can read
+            self.harness.set_leader(True)
+            relation = self.harness.model.get_relation('db2')
+            self.assertEqual(relation.data[relation.app]['foo'], 'bar')
+
+    def test_relation_data_access_peer_minion(self):
+        r_id = self.harness.add_relation('db2', 'myapp')
+        self.harness.add_relation_unit(r_id, 'myapp/1')  # peer!
+        self.harness.update_relation_data(r_id, 'myapp', {'foo': 'bar'})
+        with self.harness._event_context('foo_event'):
+            # nonleaders can read
+            self.harness.set_leader(False)
+            relation = self.harness.model.get_relation('db2')
+            self.assertEqual(relation.data[relation.app]['foo'], 'bar')
 
     def test_relation_data_del_key(self):
         relation_id = self.harness.add_relation('db1', 'remoteapp1')
-        self.harness.update_relation_data(relation_id, 'myapp/0', {'host': 'bar'})
+        with self.harness._event_context('foo_event'):
+            self.harness.update_relation_data(relation_id, 'myapp/0', {'host': 'bar'})
         self.harness.add_relation_unit(relation_id, 'remoteapp1/0')
         self.resetBackendCalls()
 
@@ -338,32 +404,37 @@ def test_relation_data_del_key(self):
             ('relation_ids', 'db1'),
             ('relation_list', relation_id),
             ('relation_get', relation_id, 'myapp/0', False),
-            ('relation_set', relation_id, 'host', '', False),
+            ('update_relation_data', relation_id, self.model.unit, 'host', ''),
         ])
 
     def test_relation_data_del_missing_key(self):
         relation_id = self.harness.add_relation('db1', 'remoteapp1')
-        self.harness.update_relation_data(relation_id, 'myapp/0', {'host': 'bar'})
+        with self.harness._event_context('foo_event'):
+            self.harness.update_relation_data(relation_id, 'myapp/0', {'host': 'bar'})
         self.harness.add_relation_unit(relation_id, 'remoteapp1/0')
         self.resetBackendCalls()
 
         rel_db1 = self.model.get_relation('db1')
         # Force memory cache to be loaded.
         self.assertIn('host', rel_db1.data[self.model.unit])
-        rel_db1.data[self.model.unit]['port'] = ''   # Same as a delete, should not fail.
+        with self.harness._event_context('foo_event'):
+            rel_db1.data[self.model.unit]['port'] = ''   # Same as a delete, should not fail.
         self.assertNotIn('port', rel_db1.data[self.model.unit])
-        self.assertEqual({'host': 'bar'}, self.harness.get_relation_data(relation_id, 'myapp/0'))
+        with self.harness._event_context('foo_event'):
+            self.assertEqual({'host': 'bar'},
+                             self.harness.get_relation_data(relation_id, 'myapp/0'))
 
         self.assertBackendCalls([
             ('relation_ids', 'db1'),
             ('relation_list', relation_id),
             ('relation_get', relation_id, 'myapp/0', False),
-            ('relation_set', relation_id, 'port', '', False),
+            ('update_relation_data', relation_id, self.model.unit, 'port', ''),
         ])
 
     def test_relation_set_fail(self):
         relation_id = self.harness.add_relation('db1', 'remoteapp1')
-        self.harness.update_relation_data(relation_id, 'myapp/0', {'host': 'myapp-0'})
+        with self.harness._event_context('foo_event'):
+            self.harness.update_relation_data(relation_id, 'myapp/0', {'host': 'myapp-0'})
         self.harness.add_relation_unit(relation_id, 'remoteapp1/0')
         self.resetBackendCalls()
 
@@ -373,27 +444,29 @@ def test_relation_set_fail(self):
         #       but there was nothing illegal about the data that was being set,
         #       for us to properly test the side effects of relation-set failing.
 
-        def broken_relation_set(relation_id, key, value, is_app):
-            backend._calls.append(('relation_set', relation_id, key, value, is_app))
+        def broken_update_relation_data(relation_id, entity, key, value):
+            backend._calls.append(('update_relation_data', relation_id, entity, key, value))
             raise ops.model.ModelError()
-        backend.relation_set = broken_relation_set
+        backend.update_relation_data = broken_update_relation_data
 
         rel_db1 = self.model.get_relation('db1')
         # Force memory cache to be loaded.
         self.assertIn('host', rel_db1.data[self.model.unit])
-        with self.assertRaises(ops.model.ModelError):
-            rel_db1.data[self.model.unit]['host'] = 'bar'
-        self.assertEqual(rel_db1.data[self.model.unit]['host'], 'myapp-0')
-        with self.assertRaises(ops.model.ModelError):
-            del rel_db1.data[self.model.unit]['host']
-        self.assertIn('host', rel_db1.data[self.model.unit])
+
+        with self.harness._event_context('foo_event'):
+            with self.assertRaises(ops.model.ModelError):
+                rel_db1.data[self.model.unit]['host'] = 'bar'
+            self.assertEqual(rel_db1.data[self.model.unit]['host'], 'myapp-0')
+            with self.assertRaises(ops.model.ModelError):
+                del rel_db1.data[self.model.unit]['host']
+            self.assertIn('host', rel_db1.data[self.model.unit])
 
         self.assertBackendCalls([
             ('relation_ids', 'db1'),
             ('relation_list', relation_id),
             ('relation_get', relation_id, 'myapp/0', False),
-            ('relation_set', relation_id, 'host', 'bar', False),
-            ('relation_set', relation_id, 'host', '', False),
+            ('update_relation_data', relation_id, self.model.unit, 'host', 'bar'),
+            ('update_relation_data', relation_id, self.model.unit, 'host', ''),
         ])
 
     def test_relation_data_type_check(self):
@@ -403,12 +476,20 @@ def test_relation_data_type_check(self):
         self.resetBackendCalls()
 
         rel_db1 = self.model.get_relation('db1')
-        with self.assertRaises(ops.model.RelationDataError):
-            rel_db1.data[self.model.unit]['foo'] = 1
-        with self.assertRaises(ops.model.RelationDataError):
-            rel_db1.data[self.model.unit]['foo'] = {'foo': 'bar'}
-        with self.assertRaises(ops.model.RelationDataError):
-            rel_db1.data[self.model.unit]['foo'] = None
+        for key, value in (
+                ('foo', 1),
+                ('foo', None),
+                ('foo', {'foo': 'bar'}),
+                (1, 'foo'),
+                (None, 'foo'),
+                (('foo', 'bar'), 'foo'),
+                (1, 1),
+                (None, None)
+        ):
+            with self.assertRaises(ops.model.RelationDataError):
+                with self.harness.framework._event_context('foo_event'):
+                    rel_db1.data[self.model.unit][key] = value
+
         # No data has actually been changed
         self.assertEqual(dict(rel_db1.data[self.model.unit]), {'host': 'myapp-0'})
 
@@ -418,6 +499,104 @@ def test_relation_data_type_check(self):
             ('relation_get', relation_id, 'myapp/0', False),
         ])
 
+    def test_relation_local_app_data_readability_leader(self):
+        relation_id = self.harness.add_relation('db1', 'remoteapp1')
+        self.harness.update_relation_data(relation_id, 'remoteapp1',
+                                          {'secret': 'cafedeadbeef'})
+        self.harness.update_relation_data(relation_id, 'myapp',
+                                          {'local': 'data'})
+
+        self.harness.add_relation_unit(relation_id, 'remoteapp1/0')
+        self.harness.update_relation_data(relation_id, 'remoteapp1/0',
+                                          {'host': 'remoteapp1/0'})
+        self.model.relations._invalidate('db1')
+        self.resetBackendCalls()
+
+        rel_db1 = self.model.get_relation('db1')
+        self.harness.begin()
+        self.harness.set_leader(True)
+        self.resetBackendCalls()
+
+        local_app = self.harness.charm.app
+        self.resetBackendCalls()
+
+        # addressing the object is OK
+        rel_db1.data[local_app]
+
+        self.assertBackendCalls([])
+
+        with self.harness._event_context('foo_event'):
+            self.resetBackendCalls()
+
+            self.assertEqual(rel_db1.data[local_app]['local'], 'data')
+
+            self.assertBackendCalls([('is_leader',),
+                                     ('relation_get', 1, 'myapp', True)])
+
+            self.resetBackendCalls()
+
+            self.assertEqual(repr(rel_db1.data[local_app]), repr({'local': 'data'}))
+
+            # we don't get the data, because we're lazy
+            self.assertBackendCalls([('is_leader',)])
+
+            # as well as relation data repr() in general:
+            self.assertIsInstance(repr(rel_db1.data), str)
+
+    def test_relation_local_app_data_readability_follower(self):
+        relation_id = self.harness.add_relation('db1', 'remoteapp1')
+        with self.harness._event_context('foo_event'):
+            self.harness.update_relation_data(relation_id, 'remoteapp1',
+                                              {'secret': 'cafedeadbeef'})
+            self.harness.update_relation_data(relation_id, 'myapp',
+                                              {'local': 'data'})
+
+            self.harness.add_relation_unit(relation_id, 'remoteapp1/0')
+            self.harness.update_relation_data(relation_id, 'remoteapp1/0',
+                                              {'host': 'remoteapp1/0'})
+        self.model.relations._invalidate('db1')
+        self.resetBackendCalls()
+
+        rel_db1 = self.model.get_relation('db1')
+        self.harness.begin()
+        self.harness.set_leader(False)
+
+        local_app = self.harness.charm.app
+        # addressing the object is OK
+        rel_db1.data[local_app]
+        # nonleader units cannot read their local app databag
+        # attempting to read it is not
+        with self.harness._event_context('foo_event'):
+            self.resetBackendCalls()
+
+            with self.assertRaises(ops.model.RelationDataError):
+                # 'local' is there, but still:
+                rel_db1.data[local_app]['local']
+
+            # we didn't even get to relation-get
+            self.assertBackendCalls([('is_leader', )])
+
+            # we can't see it but repr() works
+            self.assertEqual(repr(rel_db1.data[local_app]), '<n/a>')
+            self.assertBackendCalls([('is_leader', )])
+
+            # as well as relation data repr() in general:
+            self.assertIsInstance(repr(rel_db1.data), str)
+
+            expected_backend_calls = [
+                ('relation_get', 1, 'myapp/0', False),
+                ('is_leader',),
+                ('relation_get', 1, 'remoteapp1/0', False),
+                ('is_leader',),
+                ('relation_get', 1, 'remoteapp1', True)]
+            # in < 3.5 dicts are unsorted
+            major, minor, *_ = sys.version_info
+            if (major, minor) > (3, 5):
+                self.assertBackendCalls(expected_backend_calls)
+            else:
+                backend_calls = set(self.harness._get_backend_calls())
+                self.assertEqual(backend_calls, set(expected_backend_calls))
+
     def test_relation_no_units(self):
         self.harness.add_relation('db1', 'remoteapp1')
         rel = self.model.get_relation('db1')
@@ -430,6 +609,7 @@ def test_relation_no_units(self):
         ])
 
     def test_config(self):
+        self.harness._get_backend_calls(reset=True)
         self.harness.update_config({'foo': 'foo', 'bar': 1, 'qux': True})
         self.assertEqual(self.model.config, {
             'foo': 'foo',
@@ -564,6 +744,7 @@ def test_active_message_default(self):
         self.assertEqual(ops.model.ActiveStatus().message, '')
 
     def test_local_set_valid_unit_status(self):
+        self.harness._get_backend_calls(reset=True)
         test_cases = [(
             'active',
             ops.model.ActiveStatus('Green'),
@@ -690,7 +871,6 @@ def test_remote_app_status(self):
         self.assertBackendCalls([])
 
     def test_storage(self):
-        # TODO: (jam) 2020-05-07 Harness doesn't yet expose storage-get issue #263
         meta = ops.charm.CharmMeta()
         meta.storages = {'disks': None, 'data': None}
         model = ops.model.Model(meta, ops.model._ModelBackend('myapp/0'))
@@ -716,6 +896,10 @@ def test_storage(self):
         self.assertEqual(len(model.storages), 2)
         self.assertEqual(model.storages.keys(), meta.storages.keys())
         self.assertIn('disks', model.storages)
+
+        with pytest.raises(KeyError, match='Did you mean'):
+            model.storages['does-not-exist']
+
         test_cases = {
             0: {'name': 'disks', 'location': pathlib.Path('/var/srv/disks/0')},
             1: {'name': 'disks', 'location': pathlib.Path('/var/srv/disks/1')},
@@ -962,7 +1146,6 @@ def test_recursive_push_and_pull(case):
 
     errors = []
     try:
-        print(push_path, case.dst)
         c.push_path(push_path, case.dst)
     except ops.model.MultiPushPullError as err:
         if not case.errors:
@@ -972,9 +1155,6 @@ def test_recursive_push_and_pull(case):
     assert case.errors == errors, \
         'push_path gave wrong expected errors: want {}, got {}'.format(case.errors, errors)
     for fpath in case.want:
-
-        for f in ops.model.Container._list_recursive(c.list_files, pathlib.Path('/')):
-            print(f)
         assert c.exists(fpath), 'push_path failed: file {} missing at destination'.format(fpath)
 
     # create pull test case filesystem structure
@@ -1732,6 +1912,16 @@ def _check_binding_data(self, binding_name, binding):
             self.assertEqual(binding.network.interfaces[i].address, ipaddress.ip_address(address))
             self.assertEqual(binding.network.interfaces[i].subnet, ipaddress.ip_network(subnet))
 
+        for (i, (name, address, subnet)) in enumerate([
+                ('lo', '192.0.2.2', '192.0.2.0/24'),
+                ('lo', 'dead:beef::1', 'dead:beef::/64'),
+                ('tun', '192.0.3.3', '192.0.3.3/32'),
+                ('tun', '2001:db8::3', '2001:db8::3/128'),
+                ('tun', 'fe80::1:1', 'fe80::/64')]):
+            self.assertEqual(binding.network.interfaces[i].name, name)
+            self.assertEqual(binding.network.interfaces[i].address, ipaddress.ip_address(address))
+            self.assertEqual(binding.network.interfaces[i].subnet, ipaddress.ip_network(subnet))
+
     def test_invalid_keys(self):
         # Basic validation for passing invalid keys.
         for name in (object, 0):
@@ -1878,6 +2068,20 @@ def test_missing_egress_subnets(self):
         binding = self.model.get_binding(self.model.get_relation(binding_name))
         self.assertEqual(binding.network.egress_subnets, [])
 
+    def test_unresolved_ingress_addresses(self):
+        # sometimes juju fails to resolve an url to an IP, in which case
+        # ingress-addresses will be the 'raw' url instead of an IP.
+        network_data = json.dumps({
+            'ingress-addresses': [
+                'foo.bar.baz.com'
+            ],
+        })
+        fake_script(self, 'network-get',
+                    '''[ "$1" = db0 ] && echo '{}' || exit 1'''.format(network_data))
+        binding_name = 'db0'
+        binding = self.model.get_binding(self.model.get_relation(binding_name))
+        self.assertEqual(binding.network.ingress_addresses, ['foo.bar.baz.com'])
+
 
 class TestModelBackend(unittest.TestCase):
 
diff --git a/test/test_pebble.py b/test/test_pebble.py
index 8c57a00f3..924d11014 100644
--- a/test/test_pebble.py
+++ b/test/test_pebble.py
@@ -863,6 +863,18 @@ def test_dict(self):
         check.exec['command'] = 'foo'
         self.assertEqual(d['exec'], {'command': 'echo foo'})
 
+    def test_level_raw(self):
+        d = {
+            'override': 'replace',
+            'level': 'foobar!',
+            'period': '10s',
+            'timeout': '3s',
+            'threshold': 5,
+            'http': {'url': 'https://example.com/'},
+        }
+        check = pebble.Check('chk-http', d)
+        self.assertEqual(check.level, 'foobar!')  # remains a string
+
     def test_equality(self):
         d = {
             'override': 'replace',
@@ -1259,7 +1271,7 @@ def setUp(self):
 
     def test_client_init(self):
         pebble.Client(socket_path='foo')  # test that constructor runs
-        with self.assertRaises(ValueError):
+        with self.assertRaises(TypeError):
             pebble.Client()  # socket_path arg required
 
     def test_get_system_info(self):
@@ -2507,6 +2519,32 @@ def test_get_checks_filters(self):
             ('GET', '/v1/checks', {'level': 'ready', 'names': ['chk2']}, None),
         ])
 
+    def test_checklevel_conversion(self):
+        self.client.responses.append({
+            "result": [
+                {
+                    "name": "chk2",
+                    "level": "foobar!",
+                    "status": "up",
+                    "threshold": 3,
+                },
+            ],
+            "status": "OK",
+            "status-code": 200,
+            "type": "sync"
+        })
+        checks = self.client.get_checks(level=pebble.CheckLevel.READY, names=['chk2'])
+        self.assertEqual(len(checks), 1)
+        self.assertEqual(checks[0].name, 'chk2')
+        self.assertEqual(checks[0].level, 'foobar!')  # stays a raw string
+        self.assertEqual(checks[0].status, pebble.CheckStatus.UP)
+        self.assertEqual(checks[0].failures, 0)
+        self.assertEqual(checks[0].threshold, 3)
+
+        self.assertEqual(self.client.requests, [
+            ('GET', '/v1/checks', {'level': 'ready', 'names': ['chk2']}, None),
+        ])
+
 
 @unittest.skipIf(sys.platform == 'win32', "Unix sockets don't work on Windows")
 class TestSocketClient(unittest.TestCase):
diff --git a/test/test_testing.py b/test/test_testing.py
index 06e1e182d..60e7a8d05 100644
--- a/test/test_testing.py
+++ b/test/test_testing.py
@@ -22,14 +22,17 @@
 import sys
 import tempfile
 import textwrap
+import typing
 import unittest
 import uuid
 from io import BytesIO, StringIO
+from unittest.mock import MagicMock
 
+import pytest
 import yaml
 
 import ops.testing
-from ops import pebble
+from ops import model, pebble
 from ops.charm import (
     CharmBase,
     PebbleReadyEvent,
@@ -38,7 +41,7 @@
     StorageAttachedEvent,
     StorageDetachingEvent,
 )
-from ops.framework import Object
+from ops.framework import Framework, Object
 from ops.model import (
     ActiveStatus,
     Application,
@@ -320,6 +323,24 @@ def _on_cluster_relation_changed(self, event):
         self.assertTrue(len(harness.charm.observed_events), 1)
         self.assertIsInstance(harness.charm.observed_events[0], RelationEvent)
 
+    def test_relation_get_when_broken(self):
+        harness = Harness(RelationBrokenTester, meta='''
+            name: test-app
+            requires:
+                foo:
+                    interface: foofoo
+            ''')
+        self.addCleanup(harness.cleanup)
+        harness.begin()
+        harness.charm.observe_relation_events('foo')
+
+        # relation remote app is None to mirror production juju behavior where juju doesn't
+        # communicate the remote app to ops.
+        rel_id = harness.add_relation('foo', None)
+
+        with pytest.raises(KeyError, match='trying to access remote app data'):
+            harness.remove_relation(rel_id)
+
     def test_remove_relation(self):
         harness = Harness(RelationEventCharm, meta='''
             name: test-app
@@ -474,8 +495,7 @@ def test_remove_relation_unit(self):
         self.assertEqual(len(harness.charm.model.get_relation('db').units), 0)
         self.assertFalse(rel_unit in harness.charm.model.get_relation('db').data)
         # Check relation departed was raised with correct data
-        self.assertEqual(harness.charm.get_changes()[0],
-                         {'name': 'relation-departed',
+        self.assertEqual({'name': 'relation-departed',
                           'relation': 'db',
                           'data': {'app': 'postgresql',
                                    'unit': 'postgresql/0',
@@ -484,7 +504,8 @@ def test_remove_relation_unit(self):
                                    'relation_data': {'test-app/0': {},
                                                      'test-app': {},
                                                      'postgresql/0': {'foo': 'bar'},
-                                                     'postgresql': {}}}})
+                                                     'postgresql': {}}}},
+                         harness.charm.get_changes()[0])
 
     def test_removing_relation_removes_remote_app_data(self):
         # language=YAML
@@ -509,8 +530,9 @@ def test_removing_relation_removes_remote_app_data(self):
         harness.remove_relation(rel_id)
         # Check relation and app data are removed
         self.assertEqual(backend.relation_ids('db'), [])
-        self.assertRaises(RelationNotFoundError, backend.relation_get,
-                          rel_id, remote_app, is_app=True)
+        with harness._event_context('foo'):
+            self.assertRaises(RelationNotFoundError, backend.relation_get,
+                              rel_id, remote_app, is_app=True)
 
     def test_removing_relation_refreshes_charm_model(self):
         # language=YAML
@@ -980,7 +1002,7 @@ def test_no_event_on_no_diff_update_relation_unit_bag(self):
         self.assertEqual(viewer.changes, [{'initial': 'data'}])
 
     def test_empty_config_raises(self):
-        with self.assertRaises(AttributeError):
+        with self.assertRaises(TypeError):
             Harness(RecordingCharm, config='')
 
     def test_update_config(self):
@@ -1020,12 +1042,66 @@ def test_update_config_undefined_option(self):
         with self.assertRaises(ValueError):
             harness.update_config(key_values={'nonexistent': 'foo'})
 
+    def test_update_config_bad_type(self):
+        harness = Harness(RecordingCharm, config='''
+            options:
+                a:
+                    description: a config option
+                    type: boolean
+                    default: false
+            ''')
+        self.addCleanup(harness.cleanup)
+        harness.begin()
+        with self.assertRaises(RuntimeError):
+            # cannot cast to bool
+            harness.update_config(key_values={'a': 'foo'})
+
+        with self.assertRaises(RuntimeError):
+            # cannot cast to float
+            harness.update_config(key_values={'a': 42.42})
+
+        with self.assertRaises(RuntimeError):
+            # cannot cast to int
+            harness.update_config(key_values={'a': 42})
+
+        # can cast to bool!
+        harness.update_config(key_values={'a': False})
+
+    def test_bad_config_option_type(self):
+        with self.assertRaises(RuntimeError):
+            Harness(RecordingCharm, config='''
+                options:
+                    a:
+                        description: a config option
+                        type: gibberish
+                        default: False
+                ''')
+
+    def test_no_config_option_type(self):
+        with self.assertRaises(RuntimeError):
+            Harness(RecordingCharm, config='''
+                options:
+                    a:
+                        description: a config option
+                        default: False
+                ''')
+
+    def test_uncastable_config_option_type(self):
+        with self.assertRaises(RuntimeError):
+            Harness(RecordingCharm, config='''
+                options:
+                    a:
+                        description: a config option
+                        type: boolean
+                        default: peek-a-bool!
+                ''')
+
     def test_update_config_unset_boolean(self):
         harness = Harness(RecordingCharm, config='''
             options:
                 a:
                     description: a config option
-                    type: bool
+                    type: boolean
                     default: False
             ''')
         self.addCleanup(harness.cleanup)
@@ -1076,8 +1152,9 @@ def test_relation_set_app_not_leader(self):
         rel_id = harness.add_relation('db', 'postgresql')
         harness.add_relation_unit(rel_id, 'postgresql/0')
         rel = harness.charm.model.get_relation('db')
-        with self.assertRaises(ModelError):
-            rel.data[harness.charm.app]['foo'] = 'bar'
+        with harness._event_context('foo'):
+            with self.assertRaises(ModelError):
+                rel.data[harness.charm.app]['foo'] = 'bar'
         # The data has not actually been changed
         self.assertEqual(harness.get_relation_data(rel_id, 'test-charm'), {})
         harness.set_leader(True)
@@ -1236,8 +1313,8 @@ def test_config_from_directory(self):
         self.assertEqual(harness.model.config['opt_float'], 1.0)
         self.assertIsInstance(harness.model.config['opt_float'], float)
         self.assertFalse('opt_null' in harness.model.config)
-        self.assertIsNone(harness._defaults['opt_null'])
-        self.assertIsNone(harness._defaults['opt_no_default'])
+        self.assertIsNone(harness._backend._config._defaults['opt_null'])
+        self.assertIsNone(harness._backend._config._defaults['opt_no_default'])
 
     def test_set_model_name(self):
         harness = Harness(CharmBase, meta='''
@@ -1305,16 +1382,57 @@ def test_add_storage_before_harness_begin(self):
         self.addCleanup(harness.cleanup)
 
         stor_ids = harness.add_storage("test", count=3)
-        self.assertSetEqual(set(self._extract_storage_index(stor_id) for stor_id in stor_ids),
-                            set(harness._backend.storage_list("test")))
-        want = str(pathlib.PurePath('test', '0'))
-        self.assertEqual(want, harness._backend.storage_get("test/0", "location")[-6:])
+        for s in stor_ids:
+            # before begin, adding storage does not attach it.
+            self.assertNotIn(s, harness._backend.storage_list("test"))
+
+        with self.assertRaises(ops.model.ModelError):
+            harness._backend.storage_get("test/0", "location")[-6:]
+
+    def test_add_storage_then_harness_begin(self):
+        harness = Harness(StorageTester, meta='''
+            name: test-app
+            requires:
+                db:
+                    interface: pgsql
+            storage:
+                test:
+                    type: filesystem
+                    multiple:
+                        range: 1-3
+            ''')
+        self.addCleanup(harness.cleanup)
+
+        harness.add_storage("test", count=3)
+
+        with self.assertRaises(ops.model.ModelError):
+            harness._backend.storage_get("test/0", "location")[-6:]
 
         harness.begin_with_initial_hooks()
         self.assertEqual(len(harness.charm.observed_events), 3)
         for i in range(3):
             self.assertTrue(isinstance(harness.charm.observed_events[i], StorageAttachedEvent))
 
+        want = str(pathlib.PurePath('test', '0'))
+        self.assertEqual(want, harness._backend.storage_get("test/0", "location")[-6:])
+
+    def test_add_storage_not_attached_default(self):
+        harness = Harness(CharmBase, meta='''
+            name: test-app
+            requires:
+                db:
+                    interface: pgsql
+            storage:
+                test:
+                    type: filesystem
+            ''')
+        self.addCleanup(harness.cleanup)
+
+        harness.add_storage('test')
+        harness.begin()
+        assert len(harness.model.storages['test']) == 0, \
+            'storage should start in detached state and be excluded from storage listing'
+
     def test_add_storage_without_metadata_key_fails(self):
         harness = Harness(CharmBase, meta='''
             name: test-app
@@ -1345,18 +1463,13 @@ def test_add_storage_after_harness_begin(self):
         self.addCleanup(harness.cleanup)
 
         # Set up initial storage
-        stor_id = harness.add_storage("test")[0]
-        self.assertIn(self._extract_storage_index(stor_id),
-                      harness._backend.storage_list("test"))
-        want = str(pathlib.PurePath('test', '0'))
-        self.assertEqual(want, harness._backend.storage_get(stor_id, "location")[-6:])
-
+        harness.add_storage("test")[0]
         harness.begin_with_initial_hooks()
         self.assertEqual(len(harness.charm.observed_events), 1)
         self.assertTrue(isinstance(harness.charm.observed_events[0], StorageAttachedEvent))
 
         # Add additional storage
-        stor_ids = harness.add_storage("test", count=3)
+        stor_ids = harness.add_storage("test", count=3, attach=True)
         # NOTE: stor_id now reflects the 4th ID.  The 2nd and 3rd IDs are created and
         # used, but not returned by Harness.add_storage.
         # (Should we consider changing its return type?)
@@ -1367,7 +1480,7 @@ def test_add_storage_after_harness_begin(self):
         for i in ['1', '2', '3']:
             storage_name = 'test/' + i
             want = str(pathlib.PurePath('test', i))
-            self.assertEqual(want, harness._backend.storage_get(storage_name, "location")[-6:])
+            self.assertTrue(harness._backend.storage_get(storage_name, "location").endswith(want))
         self.assertEqual(len(harness.charm.observed_events), 4)
         for i in range(1, 4):
             self.assertTrue(isinstance(harness.charm.observed_events[i], StorageAttachedEvent))
@@ -1447,7 +1560,7 @@ def test_storage_with_hyphens_works(self):
         # Set up initial storage
         harness.begin()
         helper = StorageWithHyphensHelper(harness.charm, "helper")
-        harness.add_storage("test-with-hyphens")[0]
+        harness.add_storage("test-with-hyphens", attach=True)[0]
 
         self.assertEqual(len(helper.changes), 1)
 
@@ -1662,6 +1775,68 @@ def test_actions_passed_in(self):
         self.addCleanup(harness.cleanup)
         self.assertEqual(list(harness.framework.meta.actions), ['test-action'])
 
+    def test_event_context(self):
+        class MyCharm(CharmBase):
+            def event_handler(self, evt):
+                evt.relation.data[evt.relation.app]['foo'] = 'bar'
+
+        harness = Harness(MyCharm, meta='''
+            name: test-charm
+            requires:
+                db:
+                    interface: pgsql
+            ''')
+        harness.begin()
+        rel_id = harness.add_relation('db', 'postgresql')
+        rel = harness.charm.model.get_relation('db', rel_id)
+
+        event = MagicMock()
+        event.relation = rel
+
+        with harness._event_context('my_relation_joined'):
+            with self.assertRaises(ops.model.RelationDataError):
+                harness.charm.event_handler(event)
+
+    def test_event_context_inverse(self):
+        class MyCharm(CharmBase):
+            def __init__(self, framework: Framework,
+                         key: typing.Optional = None):
+                super().__init__(framework, key)
+                self.framework.observe(self.on.db_relation_joined,
+                                       self._join_db)
+
+            def _join_db(self, event):
+                # do things with APIs we cannot easily mock
+                raise NotImplementedError
+
+        harness = Harness(MyCharm, meta='''
+            name: test-charm
+            requires:
+                db:
+                    interface: pgsql
+            ''')
+        harness.begin()
+
+        def mock_join_db(event):
+            # the harness thinks we're inside a db_relation_joined hook
+            # but we want to mock the remote data here:
+            with harness._event_context(''):
+                # pretend for a moment we're not in a hook context,
+                # so the harness will let us:
+                print(event.relation.app)
+                event.relation.data[harness.charm.app]['foo'] = 'bar'
+
+        harness.charm._join_db = mock_join_db
+        rel_id = harness.add_relation('db', 'remote')
+        harness.add_relation_unit(rel_id, 'remote/0')
+        rel = harness.charm.model.get_relation('db', rel_id)
+        self.assertEqual({'foo': 'bar'},
+                         harness.get_relation_data(rel_id, 'test-charm'))
+
+        # now we're outside of the hook context:
+        assert not harness._backend._hook_is_running
+        assert rel.data[harness.charm.app]['foo'] == 'bar'
+
     def test_relation_set_deletes(self):
         harness = Harness(CharmBase, meta='''
             name: test-charm
@@ -1679,6 +1854,22 @@ def test_relation_set_deletes(self):
         del rel.data[harness.charm.model.unit]['foo']
         self.assertEqual({}, harness.get_relation_data(rel_id, 'test-charm/0'))
 
+    def test_relation_set_nonstring(self):
+        harness = Harness(CharmBase, meta='''
+            name: test-charm
+            requires:
+                db:
+                    interface: pgsql
+            ''')
+        self.addCleanup(harness.cleanup)
+        harness.begin()
+        harness.set_leader(False)
+        rel_id = harness.add_relation('db', 'postgresql')
+        for invalid_value in (1, 1.2, {}, [], set(), True, object(), type):
+            with self.assertRaises(model.RelationDataError):
+                harness.update_relation_data(rel_id, 'test-charm/0',
+                                             {'foo': invalid_value})
+
     def test_set_workload_version(self):
         harness = Harness(CharmBase, meta='''
             name: app
@@ -1701,29 +1892,45 @@ def test_get_backend_calls(self):
         # No calls to the backend yet
         self.assertEqual(harness._get_backend_calls(), [])
         rel_id = harness.add_relation('db', 'postgresql')
-        # update_relation_data ensures the cached data for the relation is wiped
-        harness.update_relation_data(rel_id, 'test-charm/0', {'foo': 'bar'})
+
         self.assertEqual(
-            harness._get_backend_calls(reset=True), [
+            [
                 ('relation_ids', 'db'),
                 ('relation_list', rel_id),
                 ('relation_remote_app_name', 0),
-            ])
+            ],
+            harness._get_backend_calls())
+
+        # update_relation_data ensures the cached data for the relation is wiped
+        harness.update_relation_data(rel_id, 'test-charm/0', {'foo': 'bar'})
+        test_charm_unit = harness.model.get_unit('test-charm/0')
+        self.assertEqual(
+            [
+                ('relation_get', 0, 'test-charm/0', False),
+                ('update_relation_data', 0, test_charm_unit, 'foo', 'bar')
+            ],
+            harness._get_backend_calls(reset=True))
         # add_relation_unit resets the relation_list, but doesn't trigger backend calls
         harness.add_relation_unit(rel_id, 'postgresql/0')
         self.assertEqual([], harness._get_backend_calls(reset=False))
         # however, update_relation_data does, because we are preparing relation-changed
         harness.update_relation_data(rel_id, 'postgresql/0', {'foo': 'bar'})
+        pgql_unit = harness.model.get_unit('postgresql/0')
+
         self.assertEqual(
             harness._get_backend_calls(reset=False), [
                 ('relation_ids', 'db'),
                 ('relation_list', rel_id),
+                ('relation_get', 0, 'postgresql/0', False),
+                ('update_relation_data', 0, pgql_unit, 'foo', 'bar')
             ])
         # If we check again, they are still there, but now we reset it
         self.assertEqual(
             harness._get_backend_calls(reset=True), [
                 ('relation_ids', 'db'),
                 ('relation_list', rel_id),
+                ('relation_get', 0, 'postgresql/0', False),
+                ('update_relation_data', 0, pgql_unit, 'foo', 'bar')
             ])
         # And the calls are gone
         self.assertEqual(harness._get_backend_calls(), [])
@@ -1976,7 +2183,8 @@ def test_begin_with_initial_hooks_no_relations(self):
         self.addCleanup(harness.cleanup)
         harness.update_config({'foo': 'bar'})
         harness.set_leader(True)
-        self.assertIsNone(harness.charm)
+        with self.assertRaises(RuntimeError):
+            _ = harness.charm
         harness.begin_with_initial_hooks()
         self.assertIsNotNone(harness.charm)
         self.assertEqual(
@@ -2000,7 +2208,8 @@ def test_begin_with_initial_hooks_no_relations_not_leader(self):
             ''')
         self.addCleanup(harness.cleanup)
         harness.update_config({'foo': 'bar'})
-        self.assertIsNone(harness.charm)
+        with self.assertRaises(RuntimeError):
+            _ = harness.charm
         harness.begin_with_initial_hooks()
         self.assertIsNotNone(harness.charm)
         self.assertEqual(
@@ -2031,7 +2240,8 @@ def __init__(self, framework):
             ''')
         self.addCleanup(harness.cleanup)
         harness.update_config({'foo': 'bar'})
-        self.assertIsNone(harness.charm)
+        with self.assertRaises(RuntimeError):
+            _ = harness.charm
         harness.begin_with_initial_hooks()
         self.assertIsNotNone(harness.charm)
         rel_id = harness.model.get_relation('peer').id
@@ -2173,7 +2383,6 @@ def __init__(self, framework):
         harness.update_relation_data(rel_id, 'postgresql', {'app': 'data'})
         harness.begin_with_initial_hooks()
         self.assertEqual(
-            harness.charm.changes,
             [
                 {'name': 'install'},
                 {'name': 'relation-created',
@@ -2207,7 +2416,8 @@ def __init__(self, framework):
                      'unit': 'postgresql/0',
                      'app': 'postgresql',
                  }},
-            ])
+            ],
+            harness.charm.changes)
 
     def test_begin_with_initial_hooks_with_multiple_units(self):
         class CharmWithDB(RelationEventCharm):
@@ -2572,6 +2782,7 @@ def __init__(self, framework):
         self.record_relation_data_on_events = False
 
     def observe_relation_events(self, relation_name):
+        self.relation_name = relation_name
         self.framework.observe(self.on[relation_name].relation_created, self._on_relation_created)
         self.framework.observe(self.on[relation_name].relation_joined, self._on_relation_joined)
         self.framework.observe(self.on[relation_name].relation_changed, self._on_relation_changed)
@@ -2617,6 +2828,16 @@ def _observe_relation_event(self, event_name, event):
         self.changes.append(recording)
 
 
+class RelationBrokenTester(RelationEventCharm):
+    """Access inaccessible relation data."""
+
+    def __init__(self, framework):
+        super().__init__(framework)
+
+    def _on_relation_broken(self, event):
+        print(event.relation.data[event.relation.app]['bar'])
+
+
 class ContainerEventCharm(RecordingCharm):
     """Record events related to container lifecycles."""
 
@@ -2767,6 +2988,9 @@ def test_resource_get_no_resource(self):
     def test_relation_remote_app_name(self):
         harness = Harness(CharmBase, meta='''
             name: test-charm
+            requires:
+               db:
+                 interface: foo
             ''')
         self.addCleanup(harness.cleanup)
         backend = harness._backend