Repository: canonical/ops-scenario (language: python)

Issue:
Minor issues I found when reviewing the new 7.0 API
Just opening an issue here for lack of a better place. The 7.0 API is looking really good IMO! Here are the (minor) issues I found:

- BindAddress and Network have public method hook_tool_output_fmt; can those be private?
  https://github.com/canonical/ops-scenario/blob/602f9b9ce0321ed383adc9f6de28047c3a4afac6/scenario/state.py#L373
- do we need custom exception types for things like MetadataNotFoundError? seems like just an Exception with a message would be fine, as you're not going to catch this
- State's docstring refers to "State.status", but there's only unit_status and app_status
- State has with_can_connect(), with_leadership(), with_unit_status() -- should we drop those?
- should the state.next_*() functions be public?
- should satate.normalize_name() be public?
- there's some funky ``'s (double backticks) in the doc output of the Context docstring
- is Context.output_state needed? Won't you always get that from .run()?
- scenario.consistency_checker is in the API docs output, as is scenario.capture_events -- those aren't intended to be public, right?



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/quality_checks.yaml b/.github/workflows/quality_checks.yaml
index 5e1159aa..2f0e309a 100644
--- a/.github/workflows/quality_checks.yaml
+++ b/.github/workflows/quality_checks.yaml
@@ -4,6 +4,7 @@ on:
   pull_request:
     branches:
       - main
+      - 7.0
 
 jobs:
   linting:
@@ -41,4 +42,4 @@ jobs:
       - name: Install dependencies
         run: python -m pip install tox
       - name: Run unit tests
-        run: tox -vve unit
\ No newline at end of file
+        run: tox -vve unit
diff --git a/.gitignore b/.gitignore
index c20b5a45..dd6ae82f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
 venv/
+.venv/
 build/
-docs/build/
+docs/_build/
 *.charm
 .tox/
 .coverage
@@ -11,3 +12,4 @@ __pycache__/
 dist/
 *.pytest_cache
 htmlcov/
+.vscode
diff --git a/README.md b/README.md
index 6007079a..7204fb08 100644
--- a/README.md
+++ b/README.md
@@ -82,26 +82,19 @@ available. The charm has no config, no relations, no leadership, and its status
 With that, we can write the simplest possible scenario test:
 
 ```python
-import ops
-import scenario
-
-
-class MyCharm(ops.CharmBase):
-    pass
-
-
 def test_scenario_base():
     ctx = scenario.Context(MyCharm, meta={"name": "foo"})
-    out = ctx.run(scenario.Event("start"), scenario.State())
-    assert out.unit_status == ops.UnknownStatus()
+    out = ctx.run(ctx.on.start(), scenario.State())
+    assert out.unit_status == scenario.UnknownStatus()
 ```
 
+Note that you should always compare the app and unit status using `==`, not `is`. You can compare
+them to either the `scenario` objects, or the `ops` ones.
+
 Now let's start making it more complicated. Our charm sets a special state if it has leadership on 'start':
 
 ```python
-import ops
 import pytest
-import scenario
 
 
 class MyCharm(ops.CharmBase):
@@ -119,8 +112,8 @@ class MyCharm(ops.CharmBase):
 @pytest.mark.parametrize('leader', (True, False))
 def test_status_leader(leader):
     ctx = scenario.Context(MyCharm, meta={"name": "foo"})
-    out = ctx.run('start', scenario.State(leader=leader))
-    assert out.unit_status == ops.ActiveStatus('I rule' if leader else 'I am ruled')
+    out = ctx.run(ctx.on.start(), scenario.State(leader=leader))
+    assert out.unit_status == scenario.ActiveStatus('I rule' if leader else 'I am ruled')
 ```
 
 By defining the right state we can programmatically define what answers will the charm get to all the questions it can
@@ -133,9 +126,6 @@ sets the expected unit/application status. We have seen a simple example above i
 charm transitions through a sequence of statuses?
 
 ```python
-import ops
-
-
 # charm code:
 def _on_event(self, _event):
     self.unit.status = ops.MaintenanceStatus('determining who the ruler is...')
@@ -174,24 +164,19 @@ context.
 You can verify that the charm has followed the expected path by checking the unit/app status history like so:
 
 ```python
-import ops
-import scenario
-from charm import MyCharm
-
-
 def test_statuses():
     ctx = scenario.Context(MyCharm, meta={"name": "foo"})
-    out = ctx.run('start', scenario.State(leader=False))
+    out = ctx.run(ctx.on.start(), scenario.State(leader=False))
     assert ctx.unit_status_history == [
-        ops.UnknownStatus(),
-        ops.MaintenanceStatus('determining who the ruler is...'),
-        ops.WaitingStatus('checking this is right...'),
+        scenario.UnknownStatus(),
+        scenario.MaintenanceStatus('determining who the ruler is...'),
+        scenario.WaitingStatus('checking this is right...'),
     ]
-    assert out.unit_status == ops.ActiveStatus("I am ruled"),
+    assert out.unit_status == scenario.ActiveStatus("I am ruled")
     
     # similarly you can check the app status history:
     assert ctx.app_status_history == [
-        ops.UnknownStatus(),
+        scenario.UnknownStatus(),
         ...
     ]
 ```
@@ -206,13 +191,19 @@ If you want to simulate a situation in which the charm already has seen some eve
 Unknown (the default status every charm is born with), you will have to pass the 'initial status' to State.
 
 ```python
-import ops
-import scenario
+class MyCharm(ops.CharmBase):
+    def __init__(self, framework):
+        super().__init__(framework)
+        framework.observe(self.on.start, self._on_start)
+
+    def _on_start(self, event):
+        self.model.unit.status = ops.ActiveStatus("foo")
 
 # ...
-ctx.run('start', scenario.State(unit_status=ops.ActiveStatus('foo')))
+ctx = scenario.Context(MyCharm, meta={"name": "foo"})
+ctx.run(ctx.on.start(), scenario.State(unit_status=scenario.ActiveStatus('foo')))
 assert ctx.unit_status_history == [
-    ops.ActiveStatus('foo'),  # now the first status is active: 'foo'!
+    scenario.ActiveStatus('foo'),  # now the first status is active: 'foo'!
     # ...
 ]
 ```
@@ -223,10 +214,9 @@ Using a similar api to `*_status_history`, you can assert that the charm has set
 hook execution:
 
 ```python
-import scenario
-
 # ...
-ctx: scenario.Context
+ctx = scenario.Context(HistoryCharm, meta={"name": "foo"})
+ctx.run(ctx.on.start(), scenario.State())
 assert ctx.workload_version_history == ['1', '1.2', '1.5']
 # ...
 ```
@@ -241,13 +231,9 @@ given Juju event triggering (say, 'start'), a specific chain of events is emitte
 resulting state, black-box as it is, gives little insight into how exactly it was obtained.
 
 ```python
-import ops
-import scenario
-
-
 def test_foo():
     ctx = scenario.Context(...)
-    ctx.run('start', ...)
+    ctx.run(ctx.on.start(), ...)
 
     assert len(ctx.emitted_events) == 1
     assert isinstance(ctx.emitted_events[0], ops.StartEvent)
@@ -259,15 +245,13 @@ You can configure what events will be captured by passing the following argument
 
 For example:
 ```python
-import scenario
-
 def test_emitted_full():
     ctx = scenario.Context(
         MyCharm,
         capture_deferred_events=True,
         capture_framework_events=True,
     )
-    ctx.run("start", scenario.State(deferred=[scenario.Event("update-status").deferred(MyCharm._foo)]))
+    ctx.run(ctx.on.start(), scenario.State(deferred=[ctx.on.update_status().deferred(MyCharm._foo)]))
 
     assert len(ctx.emitted_events) == 5
     assert [e.handle.kind for e in ctx.emitted_events] == [
@@ -279,61 +263,11 @@ def test_emitted_full():
     ]
 ```
 
-### Low-level access: using directly `capture_events`
-
-If you need more control over what events are captured (or you're not into pytest), you can use directly the context
-manager that powers the `emitted_events` fixture: `scenario.capture_events`.
-This context manager allows you to intercept any events emitted by the framework.
-
-Usage:
-
-```python
-import ops
-import scenario
-
-with capture_events() as emitted:
-    ctx = scenario.Context(...)
-    state_out = ctx.run(
-        "update-status",
-        scenario.State(deferred=[scenario.DeferredEvent("start", ...)])
-    )
-
-# deferred events get reemitted first
-assert isinstance(emitted[0], ops.StartEvent)
-# the main Juju event gets emitted next
-assert isinstance(emitted[1], ops.UpdateStatusEvent)
-# possibly followed by a tail of all custom events that the main Juju event triggered in turn
-# assert isinstance(emitted[2], MyFooEvent)
-# ...
-```
-
-You can filter events by type like so:
-
-```python
-import ops
-
-with capture_events(ops.StartEvent, ops.RelationEvent) as emitted:
-    # capture all `start` and `*-relation-*` events.
-    pass
-```
-
-Configuration:
-
-- Passing no event types, like: `capture_events()`, is equivalent to `capture_events(ops.EventBase)`.
-- By default, **framework events** (`PreCommit`, `Commit`) are not considered for inclusion in the output list even if
-  they match the instance check. You can toggle that by passing: `capture_events(include_framework=True)`.
-- By default, **deferred events** are included in the listing if they match the instance check. You can toggle that by
-  passing: `capture_events(include_deferred=False)`.
-
 ## Relations
 
 You can write scenario tests to verify the shape of relation data:
 
 ```python
-import ops
-import scenario
-
-
 # This charm copies over remote app data to local unit data
 class MyCharm(ops.CharmBase):
     ...
@@ -346,22 +280,21 @@ class MyCharm(ops.CharmBase):
 
 
 def test_relation_data():
-    state_in = scenario.State(relations=[
-        scenario.Relation(
-            endpoint="foo",
-            interface="bar",
-            remote_app_name="remote",
-            local_unit_data={"abc": "foo"},
-            remote_app_data={"cde": "baz!"},
-        ),
-    ])
+    rel = scenario.Relation(
+        endpoint="foo",
+        interface="bar",
+        remote_app_name="remote",
+        local_unit_data={"abc": "foo"},
+        remote_app_data={"cde": "baz!"},
+    )
+    state_in = scenario.State(relations={rel})
     ctx = scenario.Context(MyCharm, meta={"name": "foo"})
 
-    state_out = ctx.run('start', state_in)
+    state_out = ctx.run(ctx.on.start(), state_in)
 
-    assert state_out.relations[0].local_unit_data == {"abc": "baz!"}
-    # you can do this to check that there are no other differences:
-    assert state_out.relations == [
+    assert state_out.get_relation(rel.id).local_unit_data == {"abc": "baz!"}
+    # You can do this to check that there are no other differences:
+    assert state_out.relations == {
         scenario.Relation(
             endpoint="foo",
             interface="bar",
@@ -369,7 +302,7 @@ def test_relation_data():
             local_unit_data={"abc": "baz!"},
             remote_app_data={"cde": "baz!"},
         ),
-    ]
+    }
 
 # which is very idiomatic and superbly explicit. Noice.
 ```
@@ -395,8 +328,6 @@ have `remote_app_name` or `remote_app_data` arguments. Also, it talks in terms o
 - `Relation.remote_units_data` maps to `PeerRelation.peers_data`
 
 ```python
-import scenario
-
 relation = scenario.PeerRelation(
     endpoint="peers",
     peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}},
@@ -407,17 +338,22 @@ be mindful when using `PeerRelation` not to include **"this unit"**'s ID in `pee
 be flagged by the Consistency Checker:
 
 ```python
-import scenario
-
-state_in = scenario.State(relations=[
+state_in = scenario.State(relations={
     scenario.PeerRelation(
         endpoint="peers",
         peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}},
-    )])
-
-scenario.Context(..., unit_id=1).run("start", state_in)  # invalid: this unit's id cannot be the ID of a peer.
-
+    )})
 
+meta = {
+    "name": "invalid",
+    "peers": {
+        "peers": {
+            "interface": "foo",
+        }
+    }
+}
+ctx = scenario.Context(ops.CharmBase, meta=meta, unit_id=1)
+ctx.run(ctx.on.start(), state_in)  # invalid: this unit's id cannot be the ID of a peer.
 ```
 
 ### SubordinateRelation
@@ -432,8 +368,6 @@ Because of that, `SubordinateRelation`, compared to `Relation`, always talks in
 - `Relation.remote_units_data` becomes `SubordinateRelation.remote_unit_data` (a single databag instead of a mapping from unit IDs to databags)
 
 ```python
-import scenario
-
 relation = scenario.SubordinateRelation(
     endpoint="peers",
     remote_unit_data={"foo": "bar"},
@@ -445,77 +379,37 @@ relation.remote_unit_name  # "zookeeper/42"
 
 ### Triggering Relation Events
 
-If you want to trigger relation events, the easiest way to do so is get a hold of the Relation instance and grab the
-event from one of its aptly-named properties:
+If you want to trigger relation events, use `ctx.on.relation_changed` (and so
+on for the other relation events) and pass the relation object:
 
 ```python
-import scenario
+ctx = scenario.Context(MyCharm, meta=MyCharm.META)
 
 relation = scenario.Relation(endpoint="foo", interface="bar")
-changed_event = relation.changed_event
-joined_event = relation.joined_event
+changed_event = ctx.on.relation_changed(relation=relation)
+joined_event = ctx.on.relation_joined(relation=relation)
 # ...
 ```
 
-This is in fact syntactic sugar for:
-
-```python
-import scenario
-
-relation = scenario.Relation(endpoint="foo", interface="bar")
-changed_event = scenario.Event('foo-relation-changed', relation=relation)
-```
-
 The reason for this construction is that the event is associated with some relation-specific metadata, that Scenario
 needs to set up the process that will run `ops.main` with the right environment variables.
 
-### Working with relation IDs
-
-Every time you instantiate `Relation` (or peer, or subordinate), the new instance will be given a unique `relation_id`.
-To inspect the ID the next relation instance will have, you can call `scenario.state.next_relation_id`.
-
-```python
-import scenario.state
-
-next_id = scenario.state.next_relation_id(update=False)
-rel = scenario.Relation('foo')
-assert rel.relation_id == next_id
-``` 
-
-This can be handy when using `replace` to create new relations, to avoid relation ID conflicts:
-
-```python
-import scenario.state
-
-rel = scenario.Relation('foo')
-rel2 = rel.replace(local_app_data={"foo": "bar"}, relation_id=scenario.state.next_relation_id())
-assert rel2.relation_id == rel.relation_id + 1 
-``` 
-
-If you don't do this, and pass both relations into a `State`, you will trigger a consistency checker error.
-
 ### Additional event parameters
 
 All relation events have some additional metadata that does not belong in the Relation object, such as, for a
 relation-joined event, the name of the (remote) unit that is joining the relation. That is what determines what
 `ops.model.Unit` you get when you get `RelationJoinedEvent().unit` in an event handler.
 
-In order to supply this parameter, you will have to **call** the event object and pass as `remote_unit_id` the id of the
+In order to supply this parameter, as well as the relation object, pass as `remote_unit` the id of the
 remote unit that the event is about. The reason that this parameter is not supplied to `Relation` but to relation
 events, is that the relation already ties 'this app' to some 'remote app' (cfr. the `Relation.remote_app_name` attr),
 but not to a specific unit. What remote unit this event is about is not a `State` concern but an `Event` one.
 
-The `remote_unit_id` will default to the first ID found in the relation's `remote_units_data`, but if the test you are
-writing is close to that domain, you should probably override it and pass it manually.
-
 ```python
-import scenario
+ctx = scenario.Context(MyCharm, meta=MyCharm.META)
 
 relation = scenario.Relation(endpoint="foo", interface="bar")
-remote_unit_2_is_joining_event = relation.joined_event(remote_unit_id=2)
-
-# which is syntactic sugar for:
-remote_unit_2_is_joining_event = scenario.Event('foo-relation-changed', relation=relation, relation_remote_unit_id=2)
+remote_unit_2_is_joining_event = ctx.on.relation_joined(relation, remote_unit=2)
 ```
 
 ## Networks
@@ -523,17 +417,15 @@ remote_unit_2_is_joining_event = scenario.Event('foo-relation-changed', relation
 Simplifying a bit the Juju "spaces" model, each integration endpoint a charm defines in its metadata is associated with a network. Regardless of whether there is a living relation over that endpoint, that is.  
 
 If your charm has a relation `"foo"` (defined in its metadata), then the charm will be able at runtime to do `self.model.get_binding("foo").network`.
-The network you'll get by doing so is heavily defaulted (see `state.Network.default`) and good for most use-cases because the charm should typically not be concerned about what IP it gets. 
+The network you'll get by doing so is heavily defaulted (see `state.Network`) and good for most use-cases because the charm should typically not be concerned about what IP it gets. 
 
 On top of the relation-provided network bindings, a charm can also define some `extra-bindings` in its metadata and access them at runtime. Note that this is a deprecated feature that should not be relied upon. For completeness, we support it in Scenario.
 
 If you want to, you can override any of these relation or extra-binding associated networks with a custom one by passing it to `State.networks`.
 
 ```python
-import scenario
-
 state = scenario.State(networks={
-  'foo': scenario.Network.default(private_address='192.0.2.1')
+  scenario.Network("foo", [scenario.BindAddress([scenario.Address('192.0.2.1')])])
 })
 ```
 
@@ -545,17 +437,15 @@ When testing a Kubernetes charm, you can mock container interactions. When using
 be no containers. So if the charm were to `self.unit.containers`, it would get back an empty dict.
 
 To give the charm access to some containers, you need to pass them to the input state, like so:
-`State(containers=[...])`
+`State(containers={...})`
 
 An example of a state including some containers:
 
 ```python
-import scenario
-
-state = scenario.State(containers=[
+state = scenario.State(containers={
     scenario.Container(name="foo", can_connect=True),
     scenario.Container(name="bar", can_connect=False)
-])
+})
 ```
 
 In this case, `self.unit.get_container('foo').can_connect()` would return `True`, while for 'bar' it would give `False`.
@@ -567,16 +457,14 @@ You can configure a container to have some files in it:
 ```python
 import pathlib
 
-import scenario
-
 local_file = pathlib.Path('/path/to/local/real/file.txt')
 
 container = scenario.Container(
     name="foo",
     can_connect=True,
-    mounts={'local': Mount('/local/share/config.yaml', local_file)}
+    mounts={'local': scenario.Mount(location='/local/share/config.yaml', source=local_file)}
     )
-state = scenario.State(containers=[container])
+state = scenario.State(containers={container})
 ```
 
 In this case, if the charm were to:
@@ -595,9 +483,6 @@ data and passing it to the charm via the container.
 ```python
 import tempfile
 
-import ops
-import scenario
-
 
 class MyCharm(ops.CharmBase):
     def __init__(self, framework):
@@ -614,15 +499,15 @@ def test_pebble_push():
         container = scenario.Container(
             name='foo',
             can_connect=True,
-            mounts={'local': scenario.Mount('/local/share/config.yaml', local_file.name)}
+            mounts={'local': Mount(location='/local/share/config.yaml', source=local_file.name)}
         )
-        state_in = scenario.State(containers=[container])
-        ctx = scenario.Context(
+        state_in = State(containers={container})
+        ctx = Context(
             MyCharm,
             meta={"name": "foo", "containers": {"foo": {}}}
         )
         ctx.run(
-            container.pebble_ready_event(),
+            ctx.on.pebble_ready(container),
             state_in,
         )
         assert local_file.read().decode() == "TEST"
@@ -638,10 +523,6 @@ that envvar into the charm's runtime.
 If the charm writes files to a container (to a location you didn't Mount as a temporary folder you have access to), you will be able to inspect them using the `get_filesystem` api.
 
 ```python
-import ops
-import scenario
-
-
 class MyCharm(ops.CharmBase):
     def __init__(self, framework):
         super().__init__(framework)
@@ -654,13 +535,13 @@ class MyCharm(ops.CharmBase):
 
 def test_pebble_push():
     container = scenario.Container(name='foo', can_connect=True)
-    state_in = scenario.State(containers=[container])
+    state_in = scenario.State(containers={container})
     ctx = scenario.Context(
         MyCharm,
         meta={"name": "foo", "containers": {"foo": {}}}
     )
     
-    ctx.run("start", state_in)
+    ctx.run(ctx.on.start(), state_in)
 
     # This is the root of the simulated container filesystem. Any mounts will be symlinks in it.
     container_root_fs = container.get_filesystem(ctx)
@@ -675,9 +556,6 @@ worse issues to deal with. You need to specify, for each possible command the ch
 result of that would be: its return code, what will be written to stdout/stderr.
 
 ```python
-import ops
-import scenario
-
 LS_LL = """
 .rw-rw-r--  228 ubuntu ubuntu 18 jan 12:05 -- charmcraft.yaml
 .rw-rw-r--  497 ubuntu ubuntu 18 jan 12:05 -- config.yaml
@@ -690,6 +568,7 @@ class MyCharm(ops.CharmBase):
     def _on_start(self, _):
         foo = self.unit.get_container('foo')
         proc = foo.exec(['ls', '-ll'])
+        proc.stdin.write("...")
         stdout, _ = proc.wait_output()
         assert stdout == LS_LL
 
@@ -697,55 +576,84 @@ class MyCharm(ops.CharmBase):
 def test_pebble_exec():
     container = scenario.Container(
         name='foo',
-        exec_mock={
-            ('ls', '-ll'):  # this is the command we're mocking
-                scenario.ExecOutput(return_code=0,  # this data structure contains all we need to mock the call.
-                                    stdout=LS_LL)
+        execs={
+            scenario.Exec(
+                command_prefix=['ls'],
+                return_code=0,
+                stdout=LS_LL,
+            ),
         }
     )
-    state_in = scenario.State(containers=[container])
+    state_in = scenario.State(containers={container})
     ctx = scenario.Context(
         MyCharm,
         meta={"name": "foo", "containers": {"foo": {}}},
     )
     state_out = ctx.run(
-        container.pebble_ready_event,
+        ctx.on.pebble_ready(container),
         state_in,
     )
+    assert ctx.exec_history[container.name][0].command == ['ls', '-ll']
+    assert ctx.exec_history[container.name][0].stdin == "..."
 ```
 
+Scenario will attempt to find the right `Exec` object by matching the provided
+command prefix against the command used in the ops `container.exec()` call. For
+example if the command is `['ls', '-ll']` then the searching will be:
+
+ 1. an `Exec` with exactly the same as command prefix, `('ls', '-ll')`
+ 2. an `Exec` with the command prefix `('ls', )`
+ 3. an `Exec` with the command prefix `()`
+
+If none of these are found Scenario will raise an `ExecError`.
+
 ### Pebble Notices
 
 Pebble can generate notices, which Juju will detect, and wake up the charm to
 let it know that something has happened in the container. The most common
 use-case is Pebble custom notices, which is a mechanism for the workload
 application to trigger a charm event.
-
+-
 When the charm is notified, there might be a queue of existing notices, or just
 the one that has triggered the event:
 
 ```python
-import ops
-import scenario
-
 class MyCharm(ops.CharmBase):
     def __init__(self, framework):
         super().__init__(framework)
-        framework.observe(self.on["cont"].pebble_custom_notice, self._on_notice)
+        framework.observe(self.on["my-container"].pebble_custom_notice, self._on_notice)
 
     def _on_notice(self, event):
         event.notice.key  # == "example.com/c"
-        for notice in self.unit.get_container("cont").get_notices():
+        for notice in self.unit.get_container("my-container").get_notices():
             ...
 
 ctx = scenario.Context(MyCharm, meta={"name": "foo", "containers": {"my-container": {}}})
 notices = [
-    scenario.Notice(key="example.com/a", occurences=10),
+    scenario.Notice(key="example.com/a", occurrences=10),
     scenario.Notice(key="example.com/b", last_data={"bar": "baz"}),
     scenario.Notice(key="example.com/c"),
 ]
-cont = scenario.Container(notices=notices)
-ctx.run(container.get_notice("example.com/c").event, scenario.State(containers=[cont]))
+container = scenario.Container("my-container", notices=notices)
+state = scenario.State(containers={container})
+ctx.run(ctx.on.pebble_custom_notice(container=container, notice=notices[-1]), state)
+```
+
+### Pebble Checks
+
+A Pebble plan can contain checks, and when those checks exceed the configured
+failure threshold, or start succeeding again after, Juju will emit a
+pebble-check-failed or pebble-check-recovered event. In order to simulate these
+events, you need to add a `CheckInfo` to the container. Note that the status of the
+check doesn't have to match the event being generated: by the time that Juju
+sends a pebble-check-failed event the check might have started passing again.
+
+```python
+ctx = scenario.Context(MyCharm, meta={"name": "foo", "containers": {"my_container": {}}})
+check_info = scenario.CheckInfo("http-check", failures=7, status=ops.pebble.CheckStatus.DOWN)
+container = scenario.Container("my_container", check_infos={check_info})
+state = scenario.State(containers={container})
+ctx.run(ctx.on.pebble_check_failed(info=check_info, container=container), state=state)
 ```
 
 ## Storage
@@ -755,17 +663,15 @@ If your charm defines `storage` in its metadata, you can use `scenario.Storage`
 Using the same `get_filesystem` API as `Container`, you can access the temporary directory used by Scenario to mock the filesystem root before and after the scenario runs.
 
 ```python
-import scenario
-
 # Some charm with a 'foo' filesystem-type storage defined in its metadata:
-ctx = scenario.Context(MyCharm)
+ctx = scenario.Context(MyCharm, meta=MyCharm.META)
 storage = scenario.Storage("foo")
 
 # Setup storage with some content:
 (storage.get_filesystem(ctx) / "myfile.txt").write_text("helloworld")
 
-with ctx.manager("update-status", scenario.State(storage=[storage])) as mgr:
-    foo = mgr.charm.model.storages["foo"][0]
+with ctx(ctx.on.update_status(), scenario.State(storages={storage})) as manager:
+    foo = manager.charm.model.storages["foo"][0]
     loc = foo.location
     path = loc / "myfile.txt"
     assert path.exists()
@@ -786,7 +692,7 @@ Note that State only wants to know about **attached** storages. A storage which
 
 If a charm requests adding more storage instances while handling some event, you can inspect that from the `Context.requested_storage` API.
 
-```python
+```python notest
 # In MyCharm._on_foo:
 # The charm requests two new "foo" storage instances to be provisioned:
 self.model.storages.request("foo", 2)
@@ -794,11 +700,9 @@ self.model.storages.request("foo", 2)
 
 From test code, you can inspect that:
 
-```python
-import scenario
-
-ctx = scenario.Context(MyCharm)
-ctx.run('some-event-that-will-cause_on_foo-to-be-called', scenario.State())
+```python notest
+ctx = scenario.Context(MyCharm, meta=MyCharm.META)
+ctx.run(ctx.on.some_event_that_will_cause_on_foo_to_be_called(), scenario.State())
 
 # the charm has requested two 'foo' storages to be provisioned:
 assert ctx.requested_storages['foo'] == 2
@@ -808,16 +712,14 @@ Requesting storages has no other consequence in Scenario. In real life, this req
 So a natural follow-up Scenario test suite for this case would be:
 
 ```python
-import scenario
-
-ctx = scenario.Context(MyCharm)
+ctx = scenario.Context(MyCharm, meta=MyCharm.META)
 foo_0 = scenario.Storage('foo')
 # The charm is notified that one of the storages it has requested is ready:
-ctx.run(foo_0.attached_event, State(storage=[foo_0]))
+ctx.run(ctx.on.storage_attached(foo_0), scenario.State(storages={foo_0}))
 
 foo_1 = scenario.Storage('foo')
 # The charm is notified that the other storage is also ready:
-ctx.run(foo_1.attached_event, State(storage=[foo_0, foo_1]))
+ctx.run(ctx.on.storage_attached(foo_1), scenario.State(storages={foo_0, foo_1}))
 ```
 
 ## Ports
@@ -825,22 +727,17 @@ ctx.run(foo_1.attached_event, State(storage=[foo_0, foo_1]))
 Since `ops 2.6.0`, charms can invoke the `open-port`, `close-port`, and `opened-ports` hook tools to manage the ports opened on the host VM/container. Using the `State.opened_ports` API, you can: 
 
 - simulate a charm run with a port opened by some previous execution
-```python
-import scenario
-
-ctx = scenario.Context(MyCharm)
-ctx.run("start", scenario.State(opened_ports=[scenario.Port("tcp", 42)]))
+ctx = scenario.Context(MyCharm, meta=MyCharm.META)
+ctx.run(ctx.on.start(), scenario.State(opened_ports={scenario.TCPPort(42)}))
 ```
 - assert that a charm has called `open-port` or `close-port`:
 ```python
-import scenario
-
-ctx = scenario.Context(MyCharm)
-state1 = ctx.run("start", scenario.State())
-assert state1.opened_ports == [scenario.Port("tcp", 42)]
+ctx = scenario.Context(PortCharm, meta=MyCharm.META)
+state1 = ctx.run(ctx.on.start(), scenario.State())
+assert state1.opened_ports == [scenario.TCPPort(42)]
 
-state2 = ctx.run("stop", state1)
-assert state2.opened_ports == []
+state2 = ctx.run(ctx.on.stop(), state1)
+assert state2.opened_ports == {}
 ```
 
 ## Secrets
@@ -848,27 +745,30 @@ assert state2.opened_ports == []
 Scenario has secrets. Here's how you use them.
 
 ```python
-import scenario
-
 state = scenario.State(
-    secrets=[
+    secrets={
         scenario.Secret(
-            id='foo',
-            contents={0: {'key': 'public'}}
+            tracked_content={'key': 'public'},
+            latest_content={'key': 'public', 'cert': 'private'},
         )
-    ]
+    }
 )
 ```
 
-The only mandatory arguments to Secret are its secret ID (which should be unique) and its 'contents': that is, a mapping
-from revision numbers (integers) to a `str:str` dict representing the payload of the revision.
+The only mandatory arguments to Secret is the `tracked_content` dict: a `str:str`
+mapping representing the content of the revision. If there is a newer revision
+of the content than the one the unit that's handling the event is tracking, then
+`latest_content` should also be provided - if it's not, then Scenario assumes
+that `latest_content` is the `tracked_content`. If there are other revisions of
+the content, simply don't include them: the unit has no way of knowing about
+these.
 
 There are three cases:
 - the secret is owned by this app but not this unit, in which case this charm can only manage it if we are the leader
 - the secret is owned by this unit, in which case this charm can always manage it (leader or not)
-- (default) the secret is not owned by this app nor unit, which means we can't manage it but only view it
+- (default) the secret is not owned by this app nor unit, which means we can't manage it but only view it (this includes user secrets)
 
-Thus by default, the secret is not owned by **this charm**, but, implicitly, by some unknown 'other charm', and that other charm has granted us view rights.
+Thus by default, the secret is not owned by **this charm**, but, implicitly, by some unknown 'other charm' (or a user), and that other has granted us view rights.
 
 The presence of the secret in `State.secrets` entails that we have access to it, either as owners or as grantees. Therefore, if we're not owners, we must be grantees. Absence of a Secret from the known secrets list means we are not entitled to obtaining it in any way. The charm, indeed, shouldn't even know it exists.
 
@@ -879,49 +779,59 @@ If this charm does not own the secret, but also it was not granted view rights b
 To specify a secret owned by this unit (or app):
 
 ```python
-import scenario
-
+rel = scenario.Relation("web")
 state = scenario.State(
-    secrets=[
+    secrets={
         scenario.Secret(
-            id='foo',
-            contents={0: {'key': 'private'}},
+            {'key': 'private'},
             owner='unit',  # or 'app'
-            remote_grants={0: {"remote"}}
-            # the secret owner has granted access to the "remote" app over some relation with ID 0
+            # The secret owner has granted access to the "remote" app over some relation:
+            remote_grants={rel.id: {"remote"}}
         )
-    ]
+    }
 )
 ```
 
-To specify a secret owned by some other application and give this unit (or app) access to it:
+To specify a secret owned by some other application, or a user secret, and give this unit (or app) access to it:
 
 ```python
-import scenario
-
 state = scenario.State(
-    secrets=[
+    secrets={
         scenario.Secret(
-            id='foo',
-            contents={0: {'key': 'public'}},
+            {'key': 'public'},
             # owner=None, which is the default
-            revision=0,  # the revision that this unit (or app) is currently tracking
         )
-    ]
+    }
 )
 ```
 
-## StoredState
-
-Scenario can simulate StoredState. You can define it on the input side as:
+When handling the `secret-expired` and `secret-remove` events, the charm must remove the specified revision of the secret. For `secret-remove`, the revision will no longer be in the `State`, because it's no longer in use (which is why the `secret-remove` event was triggered). To ensure that the charm is removing the secret, check the context for the history of secret removal:
 
 ```python
-import ops
-import scenario
+class SecretCharm(ops.CharmBase):
+    def __init__(self, framework):
+        super().__init__(framework)
+        self.framework.observe(self.on.secret_remove, self._on_secret_remove)
 
-from ops.charm import CharmBase
+    def _on_secret_remove(self, event):
+        event.secret.remove_revision(event.revision)
 
 
+ctx = scenario.Context(SecretCharm, meta={"name": "foo"})
+secret = scenario.Secret({"password": "xxxxxxxx"}, owner="app")
+old_revision = 42
+state = ctx.run(
+    ctx.on.secret_remove(secret, revision=old_revision),
+    scenario.State(leader=True, secrets={secret})
+)
+assert ctx.removed_secret_revisions == [old_revision]
+```
+
+## StoredState
+
+Scenario can simulate StoredState. You can define it on the input side as:
+
+```python
 class MyCharmType(ops.CharmBase):
     my_stored_state = ops.StoredState()
 
@@ -930,15 +840,16 @@ class MyCharmType(ops.CharmBase):
         assert self.my_stored_state.foo == 'bar'  # this will pass!
 
 
-state = scenario.State(stored_state=[
+state = scenario.State(stored_states={
     scenario.StoredState(
         owner_path="MyCharmType",
         name="my_stored_state",
         content={
             'foo': 'bar',
             'baz': {42: 42},
-        })
-])
+        }),
+    },
+)
 ```
 
 And the charm's runtime will see `self.my_stored_state.foo` and `.baz` as expected. Also, you can run assertions on it on
@@ -953,13 +864,14 @@ However, when testing, this constraint is unnecessarily strict (and it would als
 So, the only consistency-level check we enforce in Scenario when it comes to resource is that if a resource is provided in State, it needs to have been declared in the metadata.
 
 ```python
-import scenario
+import pathlib
 
 ctx = scenario.Context(MyCharm, meta={'name': 'juliette', "resources": {"foo": {"type": "oci-image"}}})
-with ctx.manager("start", scenario.State(resources={'foo': '/path/to/resource.tar'})) as mgr:
+resource = scenario.Resource(name='foo', path='/path/to/resource.tar')
+with ctx(ctx.on.start(), scenario.State(resources={resource})) as manager:
     # If the charm, at runtime, were to call self.model.resources.fetch("foo"), it would get '/path/to/resource.tar' back.
-    path = mgr.charm.model.resources.fetch('foo')
-    assert path == '/path/to/resource.tar'
+    path = manager.charm.model.resources.fetch('foo')
+    assert path == pathlib.Path('/path/to/resource.tar')
 ```
 
 ## Model
@@ -969,15 +881,9 @@ but if you need to set the model name or UUID, you can provide a `scenario.Model
 to the state:
 
 ```python
-import ops
-import scenario
-
-class MyCharm(ops.CharmBase):
-    pass
-
 ctx = scenario.Context(MyCharm, meta={"name": "foo"})
 state_in = scenario.State(model=scenario.Model(name="my-model"))
-out = ctx.run("start", state_in)
+out = ctx.run(ctx.on.start(), state_in)
 assert out.model.name == "my-model"
 assert out.model.uuid == state_in.model.uuid
 ```
@@ -1026,55 +932,59 @@ class MyVMCharm(ops.CharmBase):
 An action is a special sort of event, even though `ops` handles them almost identically.
 In most cases, you'll want to inspect the 'results' of an action, or whether it has failed or
 logged something while executing. Many actions don't have a direct effect on the output state.
-For this reason, the output state is less prominent in the return type of `Context.run_action`.
 
 How to test actions with scenario:
 
 ## Actions without parameters
 
 ```python
-import scenario
-
-from charm import MyCharm
-
-
 def test_backup_action():
     ctx = scenario.Context(MyCharm)
 
     # If you didn't declare do_backup in the charm's metadata, 
     # the `ConsistencyChecker` will slap you on the wrist and refuse to proceed.
-    out: scenario.ActionOutput = ctx.run_action("do_backup_action", scenario.State())
-
-    # You can assert action results, logs, failure using the ActionOutput interface:
-    assert out.logs == ['baz', 'qux']
-    
-    if out.success:
-      # If the action did not fail, we can read the results:
-      assert out.results == {'foo': 'bar'}
+    state = ctx.run(ctx.on.action("do_backup"), scenario.State())
 
-    else:
-      # If the action fails, we can read a failure message:
-      assert out.failure == 'boo-hoo'
+    # You can assert on action results and logs using the context:
+    assert ctx.action_logs == ['baz', 'qux']
+    assert ctx.action_results == {'foo': 'bar'}
 ```
 
-## Parametrized Actions
+## Failing Actions
 
-If the action takes parameters, you'll need to instantiate an `Action`.
+If the charm code calls `event.fail()` to indicate that the action has failed,
+an `ActionFailed` exception will be raised. This avoids having to include
+success checks in every test where the action is successful.
 
 ```python
-import scenario
+def test_backup_action_failed():
+    ctx = scenario.Context(MyCharm)
+
+    with pytest.raises(ActionFailed) as exc_info:
+        ctx.run(ctx.on.action("do_backup"), scenario.State())
+    assert exc_info.value.message == "sorry, couldn't do the backup"
+    # The state is also available if that's required:
+    assert exc_info.value.state.get_container(...)
 
-from charm import MyCharm
+    # You can still assert action results and logs that occured as well as the failure:
+    assert ctx.action_logs == ['baz', 'qux']
+    assert ctx.action_results == {'foo': 'bar'}
+```
+
+## Parametrized Actions
 
+If the action takes parameters, you can pass those in the call.
 
+```python
 def test_backup_action():
-    # Define an action:
-    action = scenario.Action('do_backup', params={'a': 'b'})
     ctx = scenario.Context(MyCharm)
 
     # If the parameters (or their type) don't match what is declared in the metadata, 
     # the `ConsistencyChecker` will slap you on the other wrist.
-    out: scenario.ActionOutput = ctx.run_action(action, scenario.State())
+    state = ctx.run(
+        ctx.on.action("do_backup", params={'a': 'b'}),
+        scenario.State()
+    )
 
     # ...
 ```
@@ -1084,13 +994,10 @@ def test_backup_action():
 Scenario allows you to accurately simulate the Operator Framework's event queue. The event queue is responsible for
 keeping track of the deferred events. On the input side, you can verify that if the charm triggers with this and that
 event in its queue (they would be there because they had been deferred in the previous run), then the output state is
-valid.
+valid. You generate the deferred data structure using the event's `deferred()` method:
 
 ```python
-import scenario
-
-
-class MyCharm(...):
+class MyCharm(ops.CharmBase):
     ...
 
     def _on_update_status(self, event):
@@ -1100,41 +1007,24 @@ class MyCharm(...):
         event.defer()
 
 
-def test_start_on_deferred_update_status(MyCharm):
+def test_start_on_deferred_update_status():
     """Test charm execution if a 'start' is dispatched when in the previous run an update-status had been deferred."""
+    ctx = scenario.Context(MyCharm)
     state_in = scenario.State(
         deferred=[
-            scenario.deferred('update_status', handler=MyCharm._on_update_status)
+            ctx.on.update_status().deferred(handler=MyCharm._on_update_status)
         ]
     )
-    state_out = scenario.Context(MyCharm).run('start', state_in)
+    state_out = ctx.run(ctx.on.start(), state_in)
     assert len(state_out.deferred) == 1
     assert state_out.deferred[0].name == 'start'
 ```
 
-You can also generate the 'deferred' data structure (called a DeferredEvent) from the corresponding Event (and the
-handler):
-
-```python
-import scenario
-
-
-class MyCharm(...):
-    ...
-
-
-deferred_start = scenario.Event('start').deferred(MyCharm._on_start)
-deferred_install = scenario.Event('install').deferred(MyCharm._on_start)
-```
-
 On the output side, you can verify that an event that you expect to have been deferred during this trigger, has indeed
 been deferred.
 
 ```python
-import scenario
-
-
-class MyCharm(...):
+class MyCharm(ops.CharmBase):
     ...
 
     def _on_start(self, event):
@@ -1142,118 +1032,20 @@ class MyCharm(...):
 
 
 def test_defer(MyCharm):
-    out = scenario.Context(MyCharm).run('start', scenario.State())
+    out = scenario.Context(MyCharm).run(ctx.on.start(), scenario.State())
     assert len(out.deferred) == 1
     assert out.deferred[0].name == 'start'
 ```
 
-## Deferring relation events
-
-If you want to test relation event deferrals, some extra care needs to be taken. RelationEvents hold references to the
-Relation instance they are about. So do they in Scenario. You can use the deferred helper to generate the data
-structure:
-
-```python
-import scenario
-
-
-class MyCharm(...):
-    ...
-
-    def _on_foo_relation_changed(self, event):
-        event.defer()
-
-
-def test_start_on_deferred_update_status(MyCharm):
-    foo_relation = scenario.Relation('foo')
-    scenario.State(
-        relations=[foo_relation],
-        deferred=[
-            scenario.deferred('foo_relation_changed',
-                              handler=MyCharm._on_foo_relation_changed,
-                              relation=foo_relation)
-        ]
-    )
-```
-
-but you can also use a shortcut from the relation event itself:
-
-```python
-import scenario
-
-
-class MyCharm(...):
-    ...
-
-
-foo_relation = scenario.Relation('foo')
-foo_relation.changed_event.deferred(handler=MyCharm._on_foo_relation_changed)
-```
-
-## Fine-tuning
-
-The deferred helper Scenario provides will not support out of the box all custom event subclasses, or events emitted by
-charm libraries or objects other than the main charm class.
-
-For general-purpose usage, you will need to instantiate DeferredEvent directly.
-
-```python
-import scenario
-
-my_deferred_event = scenario.DeferredEvent(
-    handle_path='MyCharm/MyCharmLib/on/database_ready[1]',
-    owner='MyCharmLib',  # the object observing the event. Could also be MyCharm.
-    observer='_on_database_ready'
-)
-```
-
-# Emitting custom events
-
-While the main use case of Scenario is to emit Juju events, i.e. the built-in `start`, `install`, `*-relation-changed`,
-etc..., it can be sometimes handy to directly trigger custom events defined on arbitrary Objects in your hierarchy.
-
-Suppose your charm uses a charm library providing an `ingress_provided` event.
-The 'proper' way to emit it is to run the event that causes that custom event to be emitted by the library, whatever
-that may be, for example a `foo-relation-changed`.
-
-However, that may mean that you have to set up all sorts of State and mocks so that the right preconditions are met and
-the event is emitted at all.
-
-If for whatever reason you don't want to do that and you attempt to run that event directly you will get an error:
-
-```python
-import scenario
-
-scenario.Context(...).run("ingress_provided", scenario.State())  # raises scenario.ops_main_mock.NoObserverError
-```
-
-This happens because the framework, by default, searches for an event source named `ingress_provided` in `charm.on`, but
-since the event is defined on another Object, it will fail to find it.
-
-You can prefix the event name with the path leading to its owner to tell Scenario where to find the event source:
-
-```python
-import scenario
-
-scenario.Context(...).run("my_charm_lib.on.foo", scenario.State())
-```
-
-This will instruct Scenario to emit `my_charm.my_charm_lib.on.foo`.
-
-(always omit the 'root', i.e. the charm framework key, from the path)
-
 # Live charm introspection
 
 Scenario is a black-box, state-transition testing framework. It makes it trivial to assert that a status went from A to
 B, but not to assert that, in the context of this charm execution, with this state, a certain charm-internal method was called and returned a
 given piece of data, or would return this and that _if_ it had been called.
 
-Scenario offers a cheekily-named context manager for this use case specifically:
-
-```python
-import ops
-import scenario
+The Scenario `Context` object can be used as a context manager for this use case specifically:
 
+```python notest
 from charms.bar.lib_name.v1.charm_lib import CharmLib
 
 
@@ -1273,8 +1065,7 @@ class MyCharm(ops.CharmBase):
 
 def test_live_charm_introspection(mycharm):
     ctx = scenario.Context(mycharm, meta=mycharm.META)
-    # If you want to do this with actions, you can use `Context.action_manager` instead.
-    with ctx.manager("start", scenario.State()) as manager:
+    with ctx(ctx.on.start(), scenario.State()) as manager:
         # This is your charm instance, after ops has set it up:
         charm: MyCharm = manager.charm
         
@@ -1295,8 +1086,8 @@ def test_live_charm_introspection(mycharm):
     assert state_out.unit_status == ...
 ```
 
-Note that you can't call `manager.run()` multiple times: the manager is a context that ensures that `ops.main` 'pauses' right
-before emitting the event to hand you some introspection hooks, but for the rest this is a regular scenario test: you
+Note that you can't call `manager.run()` multiple times: the object is a context that ensures that `ops.main` 'pauses' right
+before emitting the event to hand you some introspection hooks, but for the rest this is a regular Scenario test: you
 can't emit multiple events in a single charm execution.
 
 # The virtual charm root
@@ -1307,16 +1098,12 @@ either inferred from the charm type being passed to `Context` or be passed to it
 the inferred one. This also allows you to test charms defined on the fly, as in:
 
 ```python
-import ops
-import scenario
-
-
 class MyCharmType(ops.CharmBase):
     pass
 
 
 ctx = scenario.Context(charm_type=MyCharmType, meta={'name': 'my-charm-name'})
-ctx.run('start', State())
+ctx.run(ctx.on.start(), scenario.State())
 ```
 
 A consequence of this fact is that you have no direct control over the temporary directory that we are creating to put the metadata
@@ -1325,20 +1112,18 @@ you are passing to `.run()` (because `ops` expects it to be a file...). That is,
 ```python
 import tempfile
 
-import ops
-import scenario
-
 
 class MyCharmType(ops.CharmBase):
     pass
 
 
 td = tempfile.TemporaryDirectory()
-state = scenario.Context(
+ctx = scenario.Context(
     charm_type=MyCharmType,
     meta={'name': 'my-charm-name'},
     charm_root=td.name
-).run('start', scenario.State())
+)
+state = ctx.run(ctx.on.start(), scenario.State())
 ```
 
 Do this, and you will be able to set up said directory as you like before the charm is run, as well as verify its
@@ -1356,14 +1141,14 @@ state that you obtain in return is a different instance, and all parts of it hav
 This ensures that you can do delta-based comparison of states without worrying about them being mutated by Scenario.
 
 If you want to modify any of these data structures, you will need to either reinstantiate it from scratch, or use
-the `replace` api.
+the dataclasses `replace` api.
 
 ```python
-import scenario
+import dataclasses
 
 relation = scenario.Relation('foo', remote_app_data={"1": "2"})
-# make a copy of relation, but with remote_app_data set to {"3", "4"} 
-relation2 = relation.replace(remote_app_data={"3", "4"})
+# make a copy of relation, but with remote_app_data set to {"3": "4"}
+relation2 = dataclasses.replace(relation, remote_app_data={"3": "4"})
 ```
 
 # Consistency checks
@@ -1375,7 +1160,7 @@ therefore, so far as we're concerned, that can't happen, and therefore we help y
 are consistent and raise an exception if that isn't so.
 
 That happens automatically behind the scenes whenever you trigger an event;
-`scenario.consistency_checker.check_consistency` is called and verifies that the scenario makes sense.
+`scenario._consistency_checker.check_consistency` is called and verifies that the scenario makes sense.
 
 ## Caveats:
 
diff --git a/UPGRADING.md b/UPGRADING.md
new file mode 100644
index 00000000..16df72fa
--- /dev/null
+++ b/UPGRADING.md
@@ -0,0 +1,408 @@
+# Upgrading
+
+## Scenario 6.x to Scenario 7.x
+
+Scenario 7.0 has substantial API incompatibility with earlier versions, but
+comes with an intention to reduce the frequency of breaking changes in the
+future, aligning with the `ops` library.
+
+The changes listed below are not the only features introduced in Scenario 7.0
+(for that, see the release notes), but cover the breaking changes where you will
+need to update your existing Scenario tests.
+
+### Specify events via context.on
+
+In previous versions of Scenario, an event would be passed to `Context.run`
+as a string name, via a convenient shorthand property of a state component
+(e.g. `Relation`, `Container`), or by explicitly constructing an `Event` object.
+These have been unified into a single `Context.on.{event name}()` approach,
+which is more consistent, resembles the structure you're familiar with from
+charm `observe` calls, and should provide more context to IDE and linting tools.
+
+```python
+# Older Scenario code.
+ctx.run('start', state)
+ctx.run(container.pebble_ready_event, state)
+ctx.run(Event('relation-joined', relation=relation), state)
+
+# Scenario 7.x
+ctx.run(ctx.on.start(), state)
+ctx.run(ctx.on.pebble_ready(container=container), state)
+ctx.run(ctx.on.relation_joined(relation=relation), state)
+```
+
+The same applies to action events:
+
+```python
+# Older Scenario code.
+action = Action("backup", params={...})
+ctx.run_action(action, state)
+
+# Scenario 7.x
+ctx.run(ctx.on.action("backup", params={...}), state)
+```
+
+### Provide State components as (frozen) sets
+
+The state components were previously lists, but containers, relations, networks,
+and other state components do not have any inherent ordering. This led to
+'magic' numbers creeping into test code. These are now all sets, and have 'get'
+methods to retrieve the object you want to assert on. In addition, they are
+actually `frozenset`s (Scenario will automatically freeze them if you pass a
+`set`), which increases the immutability of the state and prevents accidentally
+modifying the input state.
+
+```python
+# Older Scenario code.
+state_in = State(containers=[c1, c2], relations=[r1, r2])
+...
+assert state_out.containers[1]...
+assert state_out.relations[0]...
+state_out.relations.append(r3)  # Not recommended!
+
+# Scenario 7.x
+state_in = State(containers={c1, c2}, relations={r1, r2})
+...
+assert state_out.get_container(c2.name)...
+assert state_out.get_relation(id=r1.id)...
+new_state = dataclasses.replace(state_out, relations=state_out.relations + {r3})
+```
+
+### Run action events in the same way as other events
+
+Previously, to run an action event Scenario offered a `run_action` method that
+returned an object containing the result of the action. The `run_action()`
+method (top-level and on the context manager) has been unified with the `run()`
+method. All events, including action events, are run with `run()` and return a
+`State` object. The action logs and history are available via the `Context`
+object, and if the charm calls `event.fail()`, an exception will be raised.
+
+```python
+# Older Scenario Code
+action = Action("backup", params={...})
+out = ctx.run_action(action, state)
+assert out.logs == ["baz", "qux"]
+assert not out.success
+assert out.results == {"foo": "bar"}
+assert out.failure == "boo-hoo"
+
+# Scenario 7.x
+with pytest.raises(ActionFailure) as exc_info:
+    ctx.run(ctx.on.action("backup", params={...}), State())
+assert ctx.action_logs == ['baz', 'qux']
+assert ctx.action_results == {"foo": "bar"}
+assert exc_info.value.message == "boo-hoo"
+```
+
+### Use the Context object as a context manager
+
+The deprecated `pre_event` and `post_event` arguments to `run`
+(and `run_action`) have been removed: use the context handler instead. In
+addition, the `Context` object itself is now used for a context manager, rather
+than having `.manager()` and `action_manager()` methods.
+
+In addition, the `.output` attribute of the context manager has been removed.
+The state should be accessed explicitly by using the return value of the
+`run()` method.
+
+```python
+# Older Scenario code.
+ctx = Context(MyCharm)
+state = ctx.run("start", pre_event=lambda charm: charm.prepare(), state=State())
+
+ctx = Context(MyCharm)
+with ctx.manager("start", State()) as mgr:
+    mgr.charm.prepare()
+assert mgr.output....
+
+# Scenario 7.x
+ctx = Context(MyCharm)
+with ctx(ctx.on.start(), State()) as manager:
+    manager.charm.prepare()
+    out = manager.run()
+    assert out...
+```
+
+### Pass State components are by keyword
+
+Previously, it was possible (but inadvisable) to use positional arguments for
+the `State` and its components. Most state components, and the `State` object
+itself, now request at least some arguments to be passed by keyword. In most
+cases, it's likely that you were already doing this, but the API is now
+enforced.
+
+```python
+# Older Scenario code.
+container1 = Container('foo', True)
+state = State({'key': 'value'}, [relation1, relation2], [network], [container1, container2])
+
+# Scenario 7.x
+container1 = Container('foo', can_connect=True)
+state = State(
+    config={'key': 'value'},
+    relations={relation1, relation2},
+    networks={network},
+    containers={container1, container2},
+)
+```
+
+### Pass only the tracked and latest content to Secrets
+
+In the past, any number of revision contents were provided when creating a
+`Secret. Now, rather than having a dictionary of many revisions as part of `Secret`
+objects, only the tracked and latest revision content needs to be included.
+These are the only revisions that the charm has access to, so any other
+revisions are not required. In addition, there's no longer a requirement to
+pass in an ID.
+
+```python
+# Older Scenario code.
+state = State(
+    secrets=[
+        scenario.Secret(
+            id='foo',
+            contents={0: {'certificate': 'xxxx'}}
+        ),
+        scenario.Secret(
+            id='foo',
+            contents={
+                0: {'password': '1234'},
+                1: {'password': 'abcd'},
+                2: {'password': 'admin'},
+            }
+        ),
+    ]
+)
+
+# Scenario 7.x
+state = State(
+    secrets={
+        scenario.Secret({'certificate': 'xxxx'}),
+        scenario.Secret(
+            tracked_content={'password': '1234'},
+            latest_content={'password': 'admin'},
+        ),
+    }
+)
+```
+
+### Trigger custom events by triggering the underlying Juju event
+
+Scenario no longer supports explicitly running custom events. Instead, you
+should run the Juju event(s) that will trigger the custom event. For example,
+if you have a charm lib that will emit a `database-created` event on
+`relation-created`:
+
+```python
+# Older Scenario code.
+ctx.run("my_charm_lib.on.database_created", state)
+
+# Scenario 7.x
+ctx.run(ctx.on.relation_created(relation=relation), state)
+```
+
+Scenario will still capture custom events in `Context.emitted_events`.
+
+### Copy objects with dataclasses.replace and copy.deepcopy
+
+The `copy()` and `replace()` methods of `State` and the various state components
+have been removed. You should use the `dataclasses.replace` and `copy.deepcopy`
+methods instead.
+
+```python
+# Older Scenario code.
+new_container = container.replace(can_connect=True)
+duplicate_relation = relation.copy()
+
+# Scenario 7.x
+new_container = dataclasses.replace(container, can_connect=True)
+duplicate_relation = copy.deepcopy(relation)
+```
+
+### Define resources with the Resource class
+
+The resources in State objects were previously plain dictionaries, and are now
+`scenario.Resource` objects, aligning with all of the other State components.
+
+```python
+# Older Scenario code
+state = State(resources={"/path/to/foo", pathlib.Path("/mock/foo")})
+
+# Scenario 7.x
+resource = Resource(location="/path/to/foo", source=pathlib.Path("/mock/foo"))
+state = State(resources={resource})
+```
+
+### Give Network objects a binding name attribute
+
+Previously, `Network` objects were added to the state as a dictionary of
+`{binding_name: network}`. Now, `Network` objects are added to the state as a
+set, like the other components. This means that the `Network` object now
+requires a binding name to be passed in when it is created.
+
+```python
+# Older Scenario code
+state = State(networks={"foo": Network.default()})
+
+# Scenario 7.x
+state = State(networks={Network.default("foo")})
+```
+
+### Use the .deferred() method to populate State.deferred
+
+Previously, there were multiple methods to populate the `State.deferred` list:
+events with a `.deferred()` method, the `scenario.deferred()` method, and
+creating a `DeferredEvent` object manually. Now, for Juju events, you should
+always use the `.deferred()` method of the event -- this also ensures that the
+deferred event has all of the required links (to relations, containers, secrets,
+and so on).
+
+```python
+# Older Scenario code
+deferred_start = scenario.deferred('start', handler=MyCharm._on_start)
+deferred_relation_created = Relation('foo').changed_event.deferred(handler=MyCharm._on_foo_relation_changed)
+deferred_config_changed = DeferredEvent(
+    handle_path='MyCharm/on/config_changed[1]',
+    owner='MyCharm',
+    observer='_on_config_changed'
+)
+
+# Scenario 7.x
+deferred_start = ctx.on.start().deferred(handler=MyCharm._on_start)
+deferred_relation_changed = ctx.on.relation_changed(Relation('foo')).deferred(handler=MyCharm._on_foo_relation_changed)
+deferred_config_changed = ctx.on.config_changed().deferred(handler=MyCharm._on_config_changed)
+```
+
+### Update names: State.storages, State.stored_states, Container.execs, Container.service_statuses
+
+The `State.storage` and `State.stored_state` attributes are now plurals. This
+reflects that you may have more than one in the state, and also aligns with the
+other State components.
+
+```python
+# Older Scenario code
+state = State(stored_state=[ss1, ss2], storage=[s1, s2])
+
+# Scenario 7.x
+state = State(stored_states={s1, s2}, storages={s1, s2})
+```
+
+Similarly, `Container.exec_mocks` is now named `Container.execs`,
+`Container.service_status` is now named `Container.service_statuses`, and
+`ExecOutput` is now named `Exec`.
+
+```python
+# Older Scenario code
+container = Container(
+    name="foo",
+    exec_mock={("ls", "-ll"): ExecOutput(return_code=0, stdout=....)},
+    service_status={"srv1": ops.pebble.ServiceStatus.ACTIVE}
+)
+
+# Scenario 7.x
+container = Container(
+    name="foo",
+    execs={Exec(["ls", "-ll"], return_code=0, stdout=....)},
+    service_statuses={"srv1": ops.pebble.ServiceStatus.ACTIVE},
+)
+```
+
+### Don't use `Event`, or `StoredState.data_type_name`
+
+Several attributes and classes that were never intended for end users have been
+made private:
+
+* The `data_type_name` attribute of `StoredState` is now private.
+* The `Event` class is now private.
+
+### Use Catan rather than `scenario.sequences`
+
+The `scenario.sequences` module has been removed. We encourage you to look at
+the new [Catan](https://github.com/PietroPasotti/catan) package.
+
+### Use the jsonpatch library directly
+
+The `State.jsonpatch_delta()` and `state.sort_patch()` methods have been
+removed. We are considering adding delta-comparisons of state again in the
+future, but have not yet decided how this will look. In the meantime, you can
+use the jsonpatch package directly if necessary. See the tests/helpers.py file
+for an example.
+
+### Remove calls to `cleanup`/`clear`
+
+The `Context.cleanup()` and `Context.clear()` methods have been removed. You
+do not need to manually call any cleanup methods after running an event. If you
+want a fresh `Context` (e.g. with no history), you should create a new object.
+
+### Include secrets in the state only if the charm has permission to view them
+
+`Secret.granted` has been removed. Only include in the state the secrets that
+the charm has permission to (at least) view.
+
+### Use 'app' for application-owned secrets
+
+`Secret.owner` should be `'app'` (or `'unit'` or `None`) rather than
+`'application'`.
+
+### Compare statuses with status objects
+
+It is no longer possible to compare statuses with tuples. Create the appropriate
+status object and compare to that. Note that you should always compare statuses
+with `==` not `is`.
+
+### Pass the name of the container to `State.get_container`
+
+The `State.get_container` method previously allowed passing in a `Container`
+object or a container name, but now only accepts a name. This is more consistent
+with the other new `get_*` methods, some of which would be quite complex if they
+accepted an object or key.
+
+### Use `State.storages` to get all the storages in the state
+
+The `State.get_storages` method has been removed. This was primarily intended
+for internal use. You can use `State.get_storage` or iterate through
+`State.storages` instead.
+
+### Use .replace() to change can_connect, leader, and unit_status
+
+The `State` class previously had convenience methods `with_can_connect`,
+`with_leadership`, and `with_unit_status`. You should now use the regular
+`.replace()` mechanism instead.
+
+```python
+# Older Scenario code
+new_state = state.with_can_connect(container_name, can_connect=True)
+new_state = state.with_leadership(leader=True)
+new_state = state.with_unit_status(status=ActiveStatus())
+
+# Scenario 7.x
+new_container = dataclasses.replace(container, can_connect=True)
+new_state = dataclasses.replace(containers={container})
+new_state = dataclasses.replace(state, leader=True)
+new_state = dataclasses.replace(state, status=ActiveStatus())
+```
+
+### Let Scenario handle the relation, action, and notice IDs, and storage index
+
+Scenario previously had `next_relation_id`, `next_action_id`,
+`next_storage_index`, and `next_notice_id` methods. You should now let Scenario
+manage the IDs and indexes of these objects.
+
+### Get the output state from the run() call
+
+The `Context` class previously had an `output_state` attribute that held the
+most recent output state. You should now get the output state from the `run()`
+return value.
+
+### Don't use internal details
+
+The `*_SUFFIX`, and `_EVENTS` names, the `hook_tool_output_fmt()` methods, the
+`normalize_name` method, the `DEFAULT_JUJU_VERSION` and `DEFAULT_JUJU_DATABAG`
+names have all been removed, and shouldn't need replacing.
+
+The `capture_events` and `consistency_checker` modules are also no longer
+available for public use - the consistency checker will still run automatically,
+and the `Context` class has attributes for capturing events.
+
+The `AnyRelation` and `PathLike` names have been removed: use `RelationBase` and
+`str | Path` instead.
diff --git a/docs/custom_conf.py b/docs/custom_conf.py
index 37ad32ef..a4993d4b 100644
--- a/docs/custom_conf.py
+++ b/docs/custom_conf.py
@@ -31,6 +31,7 @@ def _compute_navigation_tree(context):
     r'''^ ([\w.]+::)?            # explicit module name
           ([\w.]+\.)?            # module and/or class name(s)
           ([^.()]+)  \s*         # thing name
+          (?: \[\s*(.*)\s*])?    # optional: type parameters list, Sphinx 7&8
           (?: \((.*)\)           # optional: arguments
            (?:\s* -> \s* (.*))?  #           return annotation
           )? $                   # and nothing more
@@ -306,8 +307,32 @@ def _compute_navigation_tree(context):
 # ('envvar', 'LD_LIBRARY_PATH').
 nitpick_ignore = [
     # Please keep this list sorted alphabetically.
-    ('py:class', 'AnyJson'),
-    ('py:class', '_CharmSpec'),
-    ('py:class', 'scenario.state._DCBase'),
+    ('py:class', '_Event'),
+    ('py:class', '_EntityStatus'),
+    ('py:class', 'ModelError'),  # This is in a copied docstring so we can't fix it.
     ('py:class', 'scenario.state._EntityStatus'),
+    ('py:class', 'scenario.state._Event'),
+    ('py:class', 'scenario.state._max_posargs.<locals>._MaxPositionalArgs'),
 ]
+
+# Monkeypatch Sphinx to look for __init__ rather than __new__ for the subclasses
+# of _MaxPositionalArgs.
+import inspect
+import sphinx.ext.autodoc
+
+_real_get_signature = sphinx.ext.autodoc.ClassDocumenter._get_signature
+
+def _custom_get_signature(self):
+    if any(p.__name__ == '_MaxPositionalArgs' for p in self.object.__mro__):
+        signature = inspect.signature(self.object)
+        parameters = []
+        for position, param in enumerate(signature.parameters.values()):
+            if position >= self.object._max_positional_args:
+                parameters.append(param.replace(kind=inspect.Parameter.KEYWORD_ONLY))
+            else:
+                parameters.append(param)
+        signature = signature.replace(parameters=parameters)
+        return None, None, signature
+    return _real_get_signature(self)
+
+sphinx.ext.autodoc.ClassDocumenter._get_signature = _custom_get_signature
diff --git a/docs/index.rst b/docs/index.rst
index 1a261b3d..a64e16f7 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -1,33 +1,13 @@
-
 Scenario API reference
 ======================
 
-.. toctree::
-   :maxdepth: 2
-   :caption: Contents:
-
-scenario.State
-==============
-
-.. automodule:: scenario.state
-
-
-scenario.Context
-================
-
-.. automodule:: scenario.context
-
-
-scenario.consistency_checker
-============================
-
-.. automodule:: scenario.consistency_checker
-
+scenario
+========
 
-scenario.capture_events
-=======================
+.. automodule:: scenario
+   :special-members: __call__
 
-.. automodule:: scenario.capture_events
+.. automodule:: scenario.errors
 
 
 Indices
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 7b02bdf0..f517d485 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,53 +1,65 @@
 #
-# This file is autogenerated by pip-compile with Python 3.11
+# This file is autogenerated by pip-compile with Python 3.12
 # by the following command:
 #
 #    pip-compile --extra=docs --output-file=docs/requirements.txt pyproject.toml
 #
-alabaster==0.7.13
+alabaster==1.0.0
     # via sphinx
-babel==2.14.0
+anyio==4.4.0
+    # via
+    #   starlette
+    #   watchfiles
+babel==2.16.0
     # via sphinx
 beautifulsoup4==4.12.3
     # via
     #   canonical-sphinx-extensions
     #   furo
     #   pyspelling
-bracex==2.4
+bracex==2.5
     # via wcmatch
-canonical-sphinx-extensions==0.0.19
+canonical-sphinx-extensions==0.0.23
     # via ops-scenario (pyproject.toml)
-certifi==2024.2.2
+certifi==2024.7.4
     # via requests
 charset-normalizer==3.3.2
     # via requests
+click==8.1.7
+    # via uvicorn
 colorama==0.4.6
     # via sphinx-autobuild
-docutils==0.19
+docutils==0.21.2
     # via
     #   canonical-sphinx-extensions
     #   myst-parser
     #   sphinx
     #   sphinx-tabs
-furo==2024.1.29
+furo==2024.8.6
     # via ops-scenario (pyproject.toml)
+gitdb==4.0.11
+    # via gitpython
+gitpython==3.1.43
+    # via canonical-sphinx-extensions
+h11==0.14.0
+    # via uvicorn
 html5lib==1.1
     # via pyspelling
-idna==3.6
-    # via requests
+idna==3.8
+    # via
+    #   anyio
+    #   requests
 imagesize==1.4.1
     # via sphinx
-jinja2==3.1.3
+jinja2==3.1.4
     # via
     #   myst-parser
     #   sphinx
 linkify-it-py==2.0.3
     # via ops-scenario (pyproject.toml)
-livereload==2.6.3
-    # via sphinx-autobuild
-lxml==5.2.1
+lxml==5.3.0
     # via pyspelling
-markdown==3.6
+markdown==3.7
     # via pyspelling
 markdown-it-py==3.0.0
     # via
@@ -55,44 +67,46 @@ markdown-it-py==3.0.0
     #   myst-parser
 markupsafe==2.1.5
     # via jinja2
-mdit-py-plugins==0.4.0
+mdit-py-plugins==0.4.1
     # via myst-parser
 mdurl==0.1.2
     # via markdown-it-py
-myst-parser==2.0.0
+myst-parser==4.0.0
     # via ops-scenario (pyproject.toml)
-ops==2.12.0
+ops==2.15.0
     # via ops-scenario (pyproject.toml)
-packaging==24.0
+packaging==24.1
     # via sphinx
-pygments==2.17.2
+pygments==2.18.0
     # via
     #   furo
     #   sphinx
     #   sphinx-tabs
 pyspelling==2.10
     # via ops-scenario (pyproject.toml)
-pyyaml==6.0.1
+pyyaml==6.0.2
     # via
     #   myst-parser
     #   ops
     #   ops-scenario (pyproject.toml)
     #   pyspelling
-requests==2.31.0
+requests==2.32.3
     # via
     #   canonical-sphinx-extensions
     #   sphinx
 six==1.16.0
-    # via
-    #   html5lib
-    #   livereload
+    # via html5lib
+smmap==5.0.1
+    # via gitdb
+sniffio==1.3.1
+    # via anyio
 snowballstemmer==2.2.0
     # via sphinx
-soupsieve==2.5
+soupsieve==2.6
     # via
     #   beautifulsoup4
     #   pyspelling
-sphinx==6.2.1
+sphinx==8.0.2
     # via
     #   canonical-sphinx-extensions
     #   furo
@@ -106,43 +120,49 @@ sphinx==6.2.1
     #   sphinx-tabs
     #   sphinxcontrib-jquery
     #   sphinxext-opengraph
-sphinx-autobuild==2024.2.4
+sphinx-autobuild==2024.4.16
     # via ops-scenario (pyproject.toml)
 sphinx-basic-ng==1.0.0b2
     # via furo
 sphinx-copybutton==0.5.2
     # via ops-scenario (pyproject.toml)
-sphinx-design==0.5.0
+sphinx-design==0.6.1
     # via ops-scenario (pyproject.toml)
-sphinx-notfound-page==1.0.0
+sphinx-notfound-page==1.0.4
     # via ops-scenario (pyproject.toml)
 sphinx-tabs==3.4.5
     # via ops-scenario (pyproject.toml)
-sphinxcontrib-applehelp==1.0.4
+sphinxcontrib-applehelp==2.0.0
     # via sphinx
-sphinxcontrib-devhelp==1.0.2
+sphinxcontrib-devhelp==2.0.0
     # via sphinx
-sphinxcontrib-htmlhelp==2.0.1
+sphinxcontrib-htmlhelp==2.1.0
     # via sphinx
 sphinxcontrib-jquery==4.1
     # via ops-scenario (pyproject.toml)
 sphinxcontrib-jsmath==1.0.1
     # via sphinx
-sphinxcontrib-qthelp==1.0.3
+sphinxcontrib-qthelp==2.0.0
     # via sphinx
-sphinxcontrib-serializinghtml==1.1.5
+sphinxcontrib-serializinghtml==2.0.0
     # via sphinx
 sphinxext-opengraph==0.9.1
     # via ops-scenario (pyproject.toml)
-tornado==6.4
-    # via livereload
+starlette==0.38.2
+    # via sphinx-autobuild
 uc-micro-py==1.0.3
     # via linkify-it-py
-urllib3==2.2.1
+urllib3==2.2.2
     # via requests
-wcmatch==8.5.1
+uvicorn==0.30.6
+    # via sphinx-autobuild
+watchfiles==0.24.0
+    # via sphinx-autobuild
+wcmatch==9.0
     # via pyspelling
 webencodings==0.5.1
     # via html5lib
-websocket-client==1.7.0
+websocket-client==1.8.0
     # via ops
+websockets==13.0.1
+    # via sphinx-autobuild
diff --git a/pyproject.toml b/pyproject.toml
index 388a3a50..eb6695b4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta"
 [project]
 name = "ops-scenario"
 
-version = "6.1.7"
+version = "7.0.0"
 
 authors = [
     { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" }
@@ -18,7 +18,7 @@ license.text = "Apache-2.0"
 keywords = ["juju", "test"]
 
 dependencies = [
-    "ops>=2.12,<=2.16",
+    "ops~=2.15",
     "PyYAML>=6.0.1",
 ]
 readme = "README.md"
@@ -45,7 +45,7 @@ docs = [
     "linkify-it-py",
     "myst-parser",
     "pyspelling",
-    "sphinx==6.2.1",
+    "sphinx ~= 8.0.0",
     "sphinx-autobuild",
     "sphinx-copybutton",
     "sphinx-design",
@@ -96,11 +96,6 @@ skip-magic-trailing-comma = false
 # Like Black, automatically detect the appropriate line ending.
 line-ending = "auto"
 
-[tool.pyright]
-ignore = [
-    "scenario/sequences.py",
-    "scenario/capture_events.py"
-]
 [tool.isort]
 profile = "black"
 
diff --git a/scenario/__init__.py b/scenario/__init__.py
index dfe567f0..2ba5a24c 100644
--- a/scenario/__init__.py
+++ b/scenario/__init__.py
@@ -1,59 +1,148 @@
 #!/usr/bin/env python3
 # Copyright 2023 Canonical Ltd.
 # See LICENSE file for licensing details.
-from scenario.context import ActionOutput, Context
+
+"""Charm state-transition testing SDK for Ops charms.
+
+Write tests that declaratively define the Juju state all at once, define the
+Juju context against which to test the charm, and fire a single event on the
+charm to execute its logic. The tests can then assert that the Juju state has
+changed as expected.
+
+These tests are 'state-transition' tests, a way to test isolated units of charm
+functionality (how the state changes in reaction to events). They are not
+necessarily tests of individual methods or functions (but might be, depending on
+the charm's event observers); they are testing the 'contract' of the charm: given
+a certain state, when a certain event happens, the charm should transition to a
+certain (likely different) state. They do not test against a real Juju
+controller and model, and focus on a single Juju unit, unlike integration tests.
+For simplicity, we refer to them as 'unit' tests in the charm context.
+
+Writing these tests should nudge you into thinking of a charm as a black-box
+input->output function. The input is the union of an `Event` (why am I, charm,
+being executed), a `State` (am I leader? what is my integration data? what is my
+config?...) and the charm's execution `Context` (what integrations can I have?
+what containers can I have?...). The output is another `State`: the state after
+the charm has had a chance to interact with the mocked Juju model and affect the
+state.
+
+.. image:: https://raw.githubusercontent.com/canonical/ops-scenario/main/resources/state-transition-model.png
+   :alt: Transition diagram, with the input state and event on the left, the context including the charm in the centre, and the state out on the right
+
+Writing unit tests for a charm, then, means verifying that:
+
+- the charm does not raise uncaught exceptions while handling the event
+- the output state (as compared with the input state) is as expected.
+
+A test consists of three broad steps:
+
+- **Arrange**:
+    - declare the context
+    - declare the input state
+- **Act**:
+    - select an event to fire
+    - run the context (i.e. obtain the output state, given the input state and the event)
+- **Assert**:
+    - verify that the output state (as compared with the input state) is how you expect it to be
+    - verify that the charm has seen a certain sequence of statuses, events, and `juju-log` calls
+    - optionally, you can use a context manager to get a hold of the charm instance and run
+      assertions on APIs and state internal to it.
+
+The most basic scenario is one in which all is defaulted and barely any data is
+available. The charm has no config, no integrations, no leadership, and its
+status is `unknown`. With that, we can write the simplest possible test:
+
+.. code-block:: python
+
+    def test_base():
+        ctx = Context(MyCharm)
+        out = ctx.run(ctx.on.start(), State())
+        assert out.unit_status == UnknownStatus()
+"""
+
+from scenario.context import Context, Manager
 from scenario.state import (
-    Action,
+    ActionFailed,
+    ActiveStatus,
     Address,
+    AnyJson,
     BindAddress,
+    BlockedStatus,
+    CharmType,
+    CheckInfo,
     CloudCredential,
     CloudSpec,
     Container,
     DeferredEvent,
-    Event,
-    ExecOutput,
+    ErrorStatus,
+    Exec,
+    ICMPPort,
+    JujuLogLine,
+    MaintenanceStatus,
     Model,
     Mount,
     Network,
     Notice,
     PeerRelation,
     Port,
+    RawDataBagContents,
+    RawSecretRevisionContents,
     Relation,
     RelationBase,
+    Resource,
     Secret,
     State,
     StateValidationError,
     Storage,
     StoredState,
     SubordinateRelation,
-    deferred,
+    TCPPort,
+    UDPPort,
+    UnitID,
+    UnknownStatus,
+    WaitingStatus,
 )
 
 __all__ = [
-    "Action",
-    "ActionOutput",
+    "ActionFailed",
+    "ActiveStatus",
+    "Address",
+    "AnyJson",
+    "BindAddress",
+    "BlockedStatus",
+    "CharmType",
+    "CheckInfo",
     "CloudCredential",
     "CloudSpec",
+    "Container",
     "Context",
-    "deferred",
-    "StateValidationError",
-    "Secret",
-    "RelationBase",
-    "Relation",
-    "SubordinateRelation",
-    "PeerRelation",
+    "DeferredEvent",
+    "ErrorStatus",
+    "Exec",
+    "ICMPPort",
+    "JujuLogLine",
+    "MaintenanceStatus",
+    "Manager",
     "Model",
-    "ExecOutput",
     "Mount",
-    "Container",
-    "Notice",
-    "Address",
-    "BindAddress",
     "Network",
+    "Notice",
+    "PeerRelation",
     "Port",
+    "RawDataBagContents",
+    "RawSecretRevisionContents",
+    "Relation",
+    "RelationBase",
+    "Resource",
+    "Secret",
+    "State",
+    "StateValidationError",
     "Storage",
     "StoredState",
-    "State",
-    "DeferredEvent",
-    "Event",
+    "SubordinateRelation",
+    "TCPPort",
+    "UDPPort",
+    "UnitID",
+    "UnknownStatus",
+    "WaitingStatus",
 ]
diff --git a/scenario/consistency_checker.py b/scenario/_consistency_checker.py
similarity index 68%
rename from scenario/consistency_checker.py
rename to scenario/_consistency_checker.py
index 895e6f8f..e8807aeb 100644
--- a/scenario/consistency_checker.py
+++ b/scenario/_consistency_checker.py
@@ -1,31 +1,53 @@
 #!/usr/bin/env python3
 # Copyright 2023 Canonical Ltd.
 # See LICENSE file for licensing details.
+
+"""
+The :meth:`check_consistency` function is the primary entry point for the
+consistency checks. Calling it ensures that the :class:`State` and the event,
+in combination with the ``Context``, is viable in Juju. For example, Juju can't
+emit a ``foo-relation-changed`` event on your charm unless your charm has
+declared a ``foo`` relation endpoint in its metadata.
+
+Normally, there is no need to explicitly call this function; that happens
+automatically behind the scenes whenever you trigger an event.
+
+If you have a clear false negative, are explicitly testing 'edge',
+inconsistent situations, or for whatever reason the checker is in your way, you
+can set the ``SCENARIO_SKIP_CONSISTENCY_CHECKS`` environment variable and skip
+it altogether.
+"""
+
+import marshal
 import os
 import re
-from collections import Counter
+from collections import Counter, defaultdict
 from collections.abc import Sequence
 from numbers import Number
 from typing import TYPE_CHECKING, Iterable, List, NamedTuple, Tuple, Union
 
-from scenario.runtime import InconsistentScenarioError
+from scenario.errors import InconsistentScenarioError
 from scenario.runtime import logger as scenario_logger
 from scenario.state import (
-    Action,
     PeerRelation,
     SubordinateRelation,
+    _Action,
     _CharmSpec,
-    normalize_name,
+    _normalise_name,
 )
 
 if TYPE_CHECKING:  # pragma: no cover
-    from scenario.state import Event, State
+    from scenario.state import State, _Event
 
 logger = scenario_logger.getChild("consistency_checker")
 
 
 class Results(NamedTuple):
-    """Consistency checkers return type."""
+    """Consistency checker return type.
+
+    Each consistency check function returns a ``Results`` instance with the
+    warnings and errors found during the check.
+    """
 
     errors: Iterable[str]
     warnings: Iterable[str]
@@ -33,24 +55,26 @@ class Results(NamedTuple):
 
 def check_consistency(
     state: "State",
-    event: "Event",
+    event: "_Event",
     charm_spec: "_CharmSpec",
     juju_version: str,
 ):
-    """Validate the combination of a state, an event, a charm spec, and a juju version.
-
-    When invoked, it performs a series of checks that validate that the state is consistent with
-    itself, with the event being emitted, the charm metadata, etc...
-
-    This function performs some basic validation of the combination of inputs that goes into a
-    scenario test and determines if the scenario is a realistic/plausible/consistent one.
-
-    A scenario is inconsistent if it can practically never occur because it contradicts
-    the juju model.
-    For example: juju guarantees that upon calling config-get, a charm will only ever get the keys
-    it declared in its config.yaml.
-    So a State declaring some config keys that are not in the charm's config.yaml is nonsense,
-    and the combination of the two is inconsistent.
+    """Validate the combination of a state, an event, a charm spec, and a Juju version.
+
+    When invoked, it performs a series of checks that validate that the state is
+    consistent with itself, with the event being emitted, the charm metadata,
+    and so on.
+
+    This function performs some basic validation of the combination of inputs
+    that goes into a test and determines if the scenario is a
+    realistic/plausible/consistent one.
+
+    A scenario is inconsistent if it can practically never occur because it
+    contradicts the Juju model. For example: Juju guarantees that upon calling
+    ``config-get``, a charm will only ever get the keys it declared in its
+    config metadata, so a :class:`scenario.State` declaring some config keys
+    that are not in the charm's ``charmcraft.yaml`` is nonsense, and the
+    combination of the two is inconsistent.
     """
     juju_version_: Tuple[int, ...] = tuple(map(int, juju_version.split(".")))
 
@@ -71,6 +95,7 @@ def check_consistency(
         check_relation_consistency,
         check_network_consistency,
         check_cloudspec_consistency,
+        check_storedstate_consistency,
     ):
         results = check(
             state=state,
@@ -101,12 +126,12 @@ def check_resource_consistency(
     charm_spec: "_CharmSpec",
     **_kwargs,  # noqa: U101
 ) -> Results:
-    """Check the internal consistency of the resources from metadata and in State."""
+    """Check the internal consistency of the resources from metadata and in :class:`scenario.State`."""
     errors = []
     warnings = []
 
     resources_from_meta = set(charm_spec.meta.get("resources", {}))
-    resources_from_state = set(state.resources)
+    resources_from_state = {resource.name for resource in state.resources}
     if not resources_from_meta.issuperset(resources_from_state):
         errors.append(
             f"any and all resources passed to State.resources need to have been defined in "
@@ -118,11 +143,12 @@ def check_resource_consistency(
 
 def check_event_consistency(
     *,
-    event: "Event",
+    event: "_Event",
     charm_spec: "_CharmSpec",
+    state: "State",
     **_kwargs,  # noqa: U101
 ) -> Results:
-    """Check the internal consistency of the Event data structure.
+    """Check the internal consistency of the ``_Event`` data structure.
 
     For example, it checks that a relation event has a relation instance, and that
     the relation endpoint name matches the event prefix.
@@ -140,23 +166,24 @@ def check_event_consistency(
         )
 
     if event._is_relation_event:
-        _check_relation_event(charm_spec, event, errors, warnings)
+        _check_relation_event(charm_spec, event, state, errors, warnings)
 
     if event._is_workload_event:
-        _check_workload_event(charm_spec, event, errors, warnings)
+        _check_workload_event(charm_spec, event, state, errors, warnings)
 
     if event._is_action_event:
-        _check_action_event(charm_spec, event, errors, warnings)
+        _check_action_event(charm_spec, event, state, errors, warnings)
 
     if event._is_storage_event:
-        _check_storage_event(charm_spec, event, errors, warnings)
+        _check_storage_event(charm_spec, event, state, errors, warnings)
 
     return Results(errors, warnings)
 
 
 def _check_relation_event(
     charm_spec: _CharmSpec,  # noqa: U100
-    event: "Event",
+    event: "_Event",
+    state: "State",
     errors: List[str],
     warnings: List[str],  # noqa: U100
 ):
@@ -166,34 +193,56 @@ def _check_relation_event(
             "Please pass one.",
         )
     else:
-        if not event.name.startswith(normalize_name(event.relation.endpoint)):
+        if not event.name.startswith(_normalise_name(event.relation.endpoint)):
             errors.append(
                 f"relation event should start with relation endpoint name. {event.name} does "
                 f"not start with {event.relation.endpoint}.",
             )
+        if event.relation not in state.relations:
+            errors.append(
+                f"cannot emit {event.name} because relation {event.relation.id} is not in the state.",
+            )
 
 
 def _check_workload_event(
     charm_spec: _CharmSpec,  # noqa: U100
-    event: "Event",
+    event: "_Event",
+    state: "State",
     errors: List[str],
-    warnings: List[str],  # noqa: U100
+    warnings: List[str],
 ):
     if not event.container:
         errors.append(
             "cannot construct a workload event without the container instance. "
             "Please pass one.",
         )
-    elif not event.name.startswith(normalize_name(event.container.name)):
-        errors.append(
-            f"workload event should start with container name. {event.name} does "
-            f"not start with {event.container.name}.",
-        )
+    else:
+        if not event.name.startswith(_normalise_name(event.container.name)):
+            errors.append(
+                f"workload event should start with container name. {event.name} does "
+                f"not start with {event.container.name}.",
+            )
+            if event.container not in state.containers:
+                errors.append(
+                    f"cannot emit {event.name} because container {event.container.name} "
+                    f"is not in the state.",
+                )
+            if not event.container.can_connect:
+                warnings.append(
+                    "you **can** fire fire pebble-ready while the container cannot connect, "
+                    "but that's most likely not what you want.",
+                )
+        names = Counter(exec.command_prefix for exec in event.container.execs)
+        if dupes := [n for n in names if names[n] > 1]:
+            errors.append(
+                f"container {event.container.name} has duplicate command prefixes: {dupes}",
+            )
 
 
 def _check_action_event(
     charm_spec: _CharmSpec,
-    event: "Event",
+    event: "_Event",
+    state: "State",  # noqa: U100
     errors: List[str],
     warnings: List[str],
 ):
@@ -205,7 +254,7 @@ def _check_action_event(
         )
         return
 
-    elif not event.name.startswith(normalize_name(action.name)):
+    elif not event.name.startswith(_normalise_name(action.name)):
         errors.append(
             f"action event should start with action name. {event.name} does "
             f"not start with {action.name}.",
@@ -222,7 +271,8 @@ def _check_action_event(
 
 def _check_storage_event(
     charm_spec: _CharmSpec,
-    event: "Event",
+    event: "_Event",
+    state: "State",
     errors: List[str],
     warnings: List[str],  # noqa: U100
 ):
@@ -234,7 +284,7 @@ def _check_storage_event(
             "cannot construct a storage event without the Storage instance. "
             "Please pass one.",
         )
-    elif not event.name.startswith(normalize_name(storage.name)):
+    elif not event.name.startswith(_normalise_name(storage.name)):
         errors.append(
             f"storage event should start with storage name. {event.name} does "
             f"not start with {storage.name}.",
@@ -244,11 +294,16 @@ def _check_storage_event(
             f"storage event {event.name} refers to storage {storage.name} "
             f"which is not declared in the charm metadata (metadata.yaml) under 'storage'.",
         )
+    elif storage not in state.storages:
+        errors.append(
+            f"cannot emit {event.name} because storage {storage.name} "
+            f"is not in the state.",
+        )
 
 
 def _check_action_param_types(
     charm_spec: _CharmSpec,
-    action: Action,
+    action: _Action,
     errors: List[str],
     warnings: List[str],
 ):
@@ -303,12 +358,12 @@ def check_storages_consistency(
     charm_spec: "_CharmSpec",
     **_kwargs,  # noqa: U101
 ) -> Results:
-    """Check the consistency of the state.storages with the charm_spec.metadata (metadata.yaml)."""
-    state_storage = state.storage
+    """Check the consistency of the :class:`scenario.State` storages with the charm_spec metadata."""
+    state_storage = state.storages
     meta_storage = (charm_spec.meta or {}).get("storage", {})
     errors = []
 
-    if missing := {s.name for s in state.storage}.difference(
+    if missing := {s.name for s in state_storage}.difference(
         set(meta_storage.keys()),
     ):
         errors.append(
@@ -321,7 +376,7 @@ def check_storages_consistency(
         if tag in seen:
             errors.append(
                 f"duplicate storage in State: storage {s.name} with index {s.index} "
-                f"occurs multiple times in State.storage.",
+                f"occurs multiple times in State.storages.",
             )
         seen.append(tag)
 
@@ -341,7 +396,7 @@ def check_config_consistency(
     juju_version: Tuple[int, ...],
     **_kwargs,  # noqa: U101
 ) -> Results:
-    """Check the consistency of the state.config with the charm_spec.config (config.yaml)."""
+    """Check the consistency of the :class:`scenario.State` config with the charm_spec config."""
     state_config = state.config
     meta_config = (charm_spec.config or {}).get("options", {})
     errors = []
@@ -349,7 +404,8 @@ def check_config_consistency(
     for key, value in state_config.items():
         if key not in meta_config:
             errors.append(
-                f"config option {key!r} in state.config but not specified in config.yaml.",
+                f"config option {key!r} in state.config but not specified in config.yaml or "
+                f"charmcraft.yaml.",
             )
             continue
 
@@ -394,19 +450,21 @@ def check_config_consistency(
 
 def check_secrets_consistency(
     *,
-    event: "Event",
+    event: "_Event",
     state: "State",
     juju_version: Tuple[int, ...],
     **_kwargs,  # noqa: U101
 ) -> Results:
-    """Check the consistency of Secret-related stuff."""
+    """Check the consistency of any :class:`scenario.Secret` in the :class:`scenario.State`."""
     errors = []
     if not event._is_secret_event:
         return Results(errors, [])
 
-    if not state.secrets:
+    assert event.secret is not None
+    if event.secret not in state.secrets:
+        secret_key = event.secret.id if event.secret.id else event.secret.label
         errors.append(
-            "the event being processed is a secret event; but the state has no secrets.",
+            f"cannot emit {event.name} because secret {secret_key} is not in the state.",
         )
     elif juju_version < (3,):
         errors.append(
@@ -420,10 +478,11 @@ def check_secrets_consistency(
 def check_network_consistency(
     *,
     state: "State",
-    event: "Event",  # noqa: U100
+    event: "_Event",  # noqa: U100
     charm_spec: "_CharmSpec",
     **_kwargs,  # noqa: U101
 ) -> Results:
+    """Check the consistency of any :class:`scenario.Network` in the :class:`scenario.State`."""
     errors = []
 
     meta_bindings = set(charm_spec.meta.get("extra-bindings", ()))
@@ -437,12 +496,12 @@ def check_network_consistency(
         if metadata.get("scope") != "container"  # mark of a sub
     }
 
-    state_bindings = set(state.networks)
+    state_bindings = {network.binding_name for network in state.networks}
     if diff := state_bindings.difference(
         meta_bindings.union(non_sub_relations).union(implicit_bindings),
     ):
         errors.append(
-            f"Some network bindings defined in State are not in metadata.yaml: {diff}.",
+            f"Some network bindings defined in State are not in the metadata: {diff}.",
         )
 
     endpoints = {endpoint for endpoint, metadata in all_relations}
@@ -457,10 +516,11 @@ def check_network_consistency(
 def check_relation_consistency(
     *,
     state: "State",
-    event: "Event",  # noqa: U100
+    event: "_Event",  # noqa: U100
     charm_spec: "_CharmSpec",
     **_kwargs,  # noqa: U101
 ) -> Results:
+    """Check the consistency of any relations in the :class:`scenario.State`."""
     errors = []
 
     peer_relations_meta = charm_spec.meta.get("peers", {}).items()
@@ -491,13 +551,13 @@ def _get_relations(r):
         expected_sub = relation_meta.get("scope", "") == "container"
         relations = _get_relations(endpoint)
         for relation in relations:
-            if relation.relation_id in seen_ids:
+            if relation.id in seen_ids:
                 errors.append(
-                    f"duplicate relation ID: {relation.relation_id} is claimed "
+                    f"duplicate relation ID: {relation.id} is claimed "
                     f"by multiple Relation instances",
                 )
 
-            seen_ids.add(relation.relation_id)
+            seen_ids.add(relation.id)
             is_sub = isinstance(relation, SubordinateRelation)
             if is_sub and not expected_sub:
                 errors.append(
@@ -526,17 +586,20 @@ def _get_relations(r):
 def check_containers_consistency(
     *,
     state: "State",
-    event: "Event",
+    event: "_Event",
     charm_spec: "_CharmSpec",
     **_kwargs,  # noqa: U101
 ) -> Results:
-    """Check the consistency of `state.containers` vs. `charm_spec.meta`."""
+    """Check the consistency of :class:`scenario.State` containers with the charm_spec metadata."""
 
     # event names will be normalized; need to compare against normalized container names.
     meta = charm_spec.meta
-    meta_containers = list(map(normalize_name, meta.get("containers", {})))
-    state_containers = [normalize_name(c.name) for c in state.containers]
+    meta_containers = list(map(_normalise_name, meta.get("containers", {})))
+    state_containers = [_normalise_name(c.name) for c in state.containers]
     all_notices = {notice.id for c in state.containers for notice in c.notices}
+    all_checks = {
+        (c.name, check.name) for c in state.containers for check in c.check_infos
+    }
     errors = []
 
     # it's fine if you have containers in meta that are not in state.containers (yet), but it's
@@ -556,11 +619,21 @@ def check_containers_consistency(
                 f"container with that name is not present in the state. It's odd, but "
                 f"consistent, if it cannot connect; but it should at least be there.",
             )
+        # - you're processing a Notice event and that notice is not in any of the containers
         if event.notice and event.notice.id not in all_notices:
             errors.append(
                 f"the event being processed concerns notice {event.notice!r}, but that "
                 "notice is not in any of the containers present in the state.",
             )
+        # - you're processing a Check event and that check is not in the check's container
+        if (
+            event.check_info
+            and (evt_container_name, event.check_info.name) not in all_checks
+        ):
+            errors.append(
+                f"the event being processed concerns check {event.check_info.name}, but that "
+                f"check is not in the {evt_container_name} container.",
+            )
 
     # - a container in state.containers is not in meta.containers
     if diff := (set(state_containers).difference(set(meta_containers))):
@@ -570,22 +643,17 @@ def check_containers_consistency(
             f"Missing from metadata: {diff}.",
         )
 
-    # guard against duplicate container names
-    names = Counter(state_containers)
-    if dupes := [n for n in names if names[n] > 1]:
-        errors.append(f"Duplicate container name(s): {dupes}.")
-
     return Results(errors, [])
 
 
 def check_cloudspec_consistency(
     *,
     state: "State",
-    event: "Event",
+    event: "_Event",
     charm_spec: "_CharmSpec",
     **_kwargs,  # noqa: U101
 ) -> Results:
-    """Check that Kubernetes charms/models don't have `state.cloud_spec`."""
+    """Check that Kubernetes models don't have :attr:`scenario.State.cloud_spec` set."""
 
     errors = []
     warnings = []
@@ -593,8 +661,38 @@ def check_cloudspec_consistency(
     if state.model.type == "kubernetes" and state.model.cloud_spec:
         errors.append(
             "CloudSpec is only available for machine charms, not Kubernetes charms. "
-            "Tell Scenario to simulate a machine substrate with: "
-            "`scenario.State(..., model=scenario.Model(type='lxd'))`.",
+            "Simulate a machine substrate with: `State(..., model=Model(type='lxd'))`.",
         )
 
     return Results(errors, warnings)
+
+
+def check_storedstate_consistency(
+    *,
+    state: "State",
+    **_kwargs,  # noqa: U101
+) -> Results:
+    """Check the internal consistency of any :class:`scenario.StoredState` in the :class:`scenario.State`."""
+    errors = []
+
+    # Attribute names must be unique on each object.
+    names = defaultdict(list)
+    for ss in state.stored_states:
+        names[ss.owner_path].append(ss.name)
+    for owner, owner_names in names.items():
+        if len(owner_names) != len(set(owner_names)):
+            errors.append(
+                f"{owner} has multiple StoredState objects with the same name.",
+            )
+
+    # The content must be marshallable.
+    for ss in state.stored_states:
+        # We don't need the marshalled state, just to know that it can be.
+        # This is the same "only simple types" check that ops does.
+        try:
+            marshal.dumps(ss.content)
+        except ValueError:
+            errors.append(
+                f"The StoredState object {ss.owner_path}.{ss.name} should contain only simple types.",
+            )
+    return Results(errors, [])
diff --git a/scenario/capture_events.py b/scenario/capture_events.py
deleted file mode 100644
index 3b094797..00000000
--- a/scenario/capture_events.py
+++ /dev/null
@@ -1,101 +0,0 @@
-#!/usr/bin/env python3
-# Copyright 2023 Canonical Ltd.
-# See LICENSE file for licensing details.
-
-import typing
-from contextlib import contextmanager
-from typing import Type, TypeVar
-
-from ops import CollectStatusEvent
-from ops.framework import (
-    CommitEvent,
-    EventBase,
-    Framework,
-    Handle,
-    NoTypeError,
-    PreCommitEvent,
-)
-
-_T = TypeVar("_T", bound=EventBase)
-
-
-@contextmanager
-def capture_events(
-    *types: Type[EventBase],
-    include_framework=False,
-    include_deferred=True,
-):
-    """Capture all events of type `*types` (using instance checks).
-
-    Arguments exposed so that you can define your own fixtures if you want to.
-
-    Example::
-    >>> from ops.charm import StartEvent
-    >>> from scenario import Event, State
-    >>> from charm import MyCustomEvent, MyCharm  # noqa
-    >>>
-    >>> def test_my_event():
-    >>>     with capture_events(StartEvent, MyCustomEvent) as captured:
-    >>>         trigger(State(), ("start", MyCharm, meta=MyCharm.META)
-    >>>
-    >>>     assert len(captured) == 2
-    >>>     e1, e2 = captured
-    >>>     assert isinstance(e2, MyCustomEvent)
-    >>>     assert e2.custom_attr == 'foo'
-    """
-    allowed_types = types or (EventBase,)
-
-    captured = []
-    _real_emit = Framework._emit
-    _real_reemit = Framework.reemit
-
-    def _wrapped_emit(self, evt):
-        if not include_framework and isinstance(
-            evt,
-            (PreCommitEvent, CommitEvent, CollectStatusEvent),
-        ):
-            return _real_emit(self, evt)
-
-        if isinstance(evt, allowed_types):
-            # dump/undump the event to ensure any custom attributes are (re)set by restore()
-            evt.restore(evt.snapshot())
-            captured.append(evt)
-
-        return _real_emit(self, evt)
-
-    def _wrapped_reemit(self):
-        # Framework calls reemit() before emitting the main juju event. We intercept that call
-        # and capture all events in storage.
-
-        if not include_deferred:
-            return _real_reemit(self)
-
-        # load all notices from storage as events.
-        for event_path, _, _ in self._storage.notices():
-            event_handle = Handle.from_path(event_path)
-            try:
-                event = self.load_snapshot(event_handle)
-            except NoTypeError:
-                continue
-            event = typing.cast(EventBase, event)
-            event.deferred = False
-            self._forget(event)  # prevent tracking conflicts
-
-            if not include_framework and isinstance(
-                event,
-                (PreCommitEvent, CommitEvent),
-            ):
-                continue
-
-            if isinstance(event, allowed_types):
-                captured.append(event)
-
-        return _real_reemit(self)
-
-    Framework._emit = _wrapped_emit  # type: ignore
-    Framework.reemit = _wrapped_reemit  # type: ignore
-
-    yield captured
-
-    Framework._emit = _real_emit  # type: ignore
-    Framework.reemit = _real_reemit  # type: ignore
diff --git a/scenario/context.py b/scenario/context.py
index 6e07576c..cb5331d5 100644
--- a/scenario/context.py
+++ b/scenario/context.py
@@ -1,78 +1,57 @@
 #!/usr/bin/env python3
 # Copyright 2023 Canonical Ltd.
 # See LICENSE file for licensing details.
-import dataclasses
+
+import functools
 import tempfile
 from contextlib import contextmanager
 from pathlib import Path
-from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Type, Union, cast
+from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Type, Union, cast
 
-from ops import CharmBase, EventBase
+import ops
+import ops.testing
 
+from scenario.errors import AlreadyEmittedError, ContextSetupError
 from scenario.logger import logger as scenario_logger
 from scenario.runtime import Runtime
-from scenario.state import Action, Event, MetadataNotFoundError, _CharmSpec
+from scenario.state import (
+    ActionFailed,
+    CheckInfo,
+    Container,
+    MetadataNotFoundError,
+    Notice,
+    Secret,
+    Storage,
+    _Action,
+    _CharmSpec,
+    _Event,
+)
 
 if TYPE_CHECKING:  # pragma: no cover
-    from ops.testing import CharmType
-
     from scenario.ops_main_mock import Ops
-    from scenario.state import JujuLogLine, State, _EntityStatus
-
-    PathLike = Union[str, Path]
+    from scenario.state import AnyJson, JujuLogLine, RelationBase, State, _EntityStatus
 
 logger = scenario_logger.getChild("runtime")
 
-DEFAULT_JUJU_VERSION = "3.4"
-
-
-@dataclasses.dataclass
-class ActionOutput:
-    """Wraps the results of running an action event with ``run_action``."""
-
-    state: "State"
-    """The charm state after the action has been handled.
-
-    In most cases, actions are not expected to be affecting it."""
-    logs: List[str]
-    """Any logs associated with the action output, set by the charm with
-    :meth:`ops.ActionEvent.log`."""
-    results: Optional[Dict[str, Any]]
-    """Key-value mapping assigned by the charm as a result of the action.
-    Will be None if the charm never calls :meth:`ops.ActionEvent.set_results`."""
-    failure: Optional[str] = None
-    """None if the action was successful, otherwise the message the charm set with
-    :meth:`ops.ActionEvent.fail`."""
-
-    @property
-    def success(self) -> bool:
-        """True if this action was a success, False otherwise."""
-        return self.failure is None
-
-
-class InvalidEventError(RuntimeError):
-    """raised when something is wrong with the event passed to Context.run_*"""
+_DEFAULT_JUJU_VERSION = "3.5"
 
 
-class InvalidActionError(InvalidEventError):
-    """raised when something is wrong with the action passed to Context.run_action"""
+class Manager:
+    """Context manager to offer test code some runtime charm object introspection.
 
+    This class should not be instantiated directly: use a :class:`Context`
+    in a ``with`` statement instead, for example::
 
-class ContextSetupError(RuntimeError):
-    """Raised by Context when setup fails."""
-
-
-class AlreadyEmittedError(RuntimeError):
-    """Raised when ``run()`` is called more than once."""
-
-
-class _Manager:
-    """Context manager to offer test code some runtime charm object introspection."""
+        ctx = Context(MyCharm)
+        with ctx(ctx.on.start(), State()) as manager:
+            manager.charm.setup()
+            manager.run()
+    """
 
     def __init__(
         self,
         ctx: "Context",
-        arg: Union[str, Action, Event],
+        arg: _Event,
         state_in: "State",
     ):
         self._ctx = ctx
@@ -80,25 +59,24 @@ def __init__(
         self._state_in = state_in
 
         self._emitted: bool = False
-        self._run = None
 
         self.ops: Optional["Ops"] = None
-        self.output: Optional[Union["State", ActionOutput]] = None
 
     @property
-    def charm(self) -> CharmBase:
+    def charm(self) -> ops.CharmBase:
+        """The charm object instantiated by ops to handle the event.
+
+        The charm is only available during the context manager scope.
+        """
         if not self.ops:
             raise RuntimeError(
-                "you should __enter__ this contextmanager before accessing this",
+                "you should __enter__ this context manager before accessing this",
             )
-        return cast(CharmBase, self.ops.charm)
+        return cast(ops.CharmBase, self.ops.charm)
 
     @property
     def _runner(self):
-        raise NotImplementedError("override in subclass")
-
-    def _get_output(self):
-        raise NotImplementedError("override in subclass")
+        return self._ctx._run  # noqa
 
     def __enter__(self):
         self._wrapped_ctx = wrapped_ctx = self._runner(self._arg, self._state_in)
@@ -106,137 +84,355 @@ def __enter__(self):
         self.ops = ops
         return self
 
-    def run(self) -> Union[ActionOutput, "State"]:
+    def run(self) -> "State":
         """Emit the event and proceed with charm execution.
 
         This can only be done once.
         """
         if self._emitted:
-            raise AlreadyEmittedError("Can only context.manager.run() once.")
+            raise AlreadyEmittedError("Can only run once.")
         self._emitted = True
 
         # wrap up Runtime.exec() so that we can gather the output state
         self._wrapped_ctx.__exit__(None, None, None)
 
-        self.output = out = self._get_output()
-        return out
+        assert self._ctx._output_state is not None
+        return self._ctx._output_state
 
     def __exit__(self, exc_type, exc_val, exc_tb):  # noqa: U100
         if not self._emitted:
-            logger.debug("manager not invoked. Doing so implicitly...")
+            logger.debug(
+                "user didn't emit the event within the context manager scope. Doing so implicitly upon exit...",
+            )
             self.run()
 
 
-class _EventManager(_Manager):
-    if TYPE_CHECKING:  # pragma: no cover
-        output: State  # pyright: ignore[reportIncompatibleVariableOverride]
+def _copy_doc(original_func):
+    """Copy the docstring from `original_func` to the wrapped function."""
 
-        def run(self) -> "State":
-            return cast("State", super().run())
+    def decorator(wrapper_func):
+        @functools.wraps(wrapper_func)
+        def wrapped(*args, **kwargs):
+            return wrapper_func(*args, **kwargs)
 
-    @property
-    def _runner(self):
-        return self._ctx._run_event  # noqa
+        wrapped.__doc__ = original_func.__doc__
+        return wrapped
 
-    def _get_output(self):
-        return self._ctx._output_state  # noqa
+    return decorator
 
 
-class _ActionManager(_Manager):
-    if TYPE_CHECKING:  # pragma: no cover
-        output: ActionOutput  # pyright: ignore[reportIncompatibleVariableOverride]
+class CharmEvents:
+    """Events generated by Juju or ops pertaining to the application lifecycle.
 
-        def run(self) -> "ActionOutput":
-            return cast("ActionOutput", super().run())
+    The events listed as attributes of this class should be accessed via the
+    :attr:`Context.on` attribute. For example::
 
-    @property
-    def _runner(self):
-        return self._ctx._run_action  # noqa
+        ctx.run(ctx.on.config_changed(), state)
+
+    This behaves similarly to the :class:`ops.CharmEvents` class but is much
+    simpler as there are no dynamically named attributes, and no ``__getattr__``
+    version to get events. In addition, all of the attributes are methods,
+    which are used to connect the event to the specific object that they relate
+    to (or, for simpler events like "start" or "stop", take no arguments).
+    """
+
+    @staticmethod
+    @_copy_doc(ops.InstallEvent)
+    def install():
+        return _Event("install")
+
+    @staticmethod
+    @_copy_doc(ops.StartEvent)
+    def start():
+        return _Event("start")
+
+    @staticmethod
+    @_copy_doc(ops.StopEvent)
+    def stop():
+        return _Event("stop")
+
+    @staticmethod
+    @_copy_doc(ops.RemoveEvent)
+    def remove():
+        return _Event("remove")
+
+    @staticmethod
+    @_copy_doc(ops.UpdateStatusEvent)
+    def update_status():
+        return _Event("update_status")
+
+    @staticmethod
+    @_copy_doc(ops.ConfigChangedEvent)
+    def config_changed():
+        return _Event("config_changed")
+
+    @staticmethod
+    @_copy_doc(ops.UpdateStatusEvent)
+    def upgrade_charm():
+        return _Event("upgrade_charm")
+
+    @staticmethod
+    @_copy_doc(ops.PreSeriesUpgradeEvent)
+    def pre_series_upgrade():
+        return _Event("pre_series_upgrade")
+
+    @staticmethod
+    @_copy_doc(ops.PostSeriesUpgradeEvent)
+    def post_series_upgrade():
+        return _Event("post_series_upgrade")
+
+    @staticmethod
+    @_copy_doc(ops.LeaderElectedEvent)
+    def leader_elected():
+        return _Event("leader_elected")
+
+    @staticmethod
+    @_copy_doc(ops.SecretChangedEvent)
+    def secret_changed(secret: Secret):
+        if secret.owner:
+            raise ValueError(
+                "This unit will never receive secret-changed for a secret it owns.",
+            )
+        return _Event("secret_changed", secret=secret)
+
+    @staticmethod
+    @_copy_doc(ops.SecretExpiredEvent)
+    def secret_expired(secret: Secret, *, revision: int):
+        if not secret.owner:
+            raise ValueError(
+                "This unit will never receive secret-expire for a secret it does not own.",
+            )
+        return _Event("secret_expired", secret=secret, secret_revision=revision)
+
+    @staticmethod
+    @_copy_doc(ops.SecretRotateEvent)
+    def secret_rotate(secret: Secret):
+        if not secret.owner:
+            raise ValueError(
+                "This unit will never receive secret-rotate for a secret it does not own.",
+            )
+        return _Event("secret_rotate", secret=secret)
+
+    @staticmethod
+    @_copy_doc(ops.SecretRemoveEvent)
+    def secret_remove(secret: Secret, *, revision: int):
+        if not secret.owner:
+            raise ValueError(
+                "This unit will never receive secret-removed for a secret it does not own.",
+            )
+        return _Event("secret_remove", secret=secret, secret_revision=revision)
+
+    @staticmethod
+    def collect_app_status():
+        """Event triggered at the end of every hook to collect app statuses for evaluation"""
+        return _Event("collect_app_status")
+
+    @staticmethod
+    def collect_unit_status():
+        """Event triggered at the end of every hook to collect unit statuses for evaluation"""
+        return _Event("collect_unit_status")
+
+    @staticmethod
+    @_copy_doc(ops.RelationCreatedEvent)
+    def relation_created(relation: "RelationBase"):
+        return _Event(f"{relation.endpoint}_relation_created", relation=relation)
+
+    @staticmethod
+    @_copy_doc(ops.RelationJoinedEvent)
+    def relation_joined(relation: "RelationBase", *, remote_unit: Optional[int] = None):
+        return _Event(
+            f"{relation.endpoint}_relation_joined",
+            relation=relation,
+            relation_remote_unit_id=remote_unit,
+        )
+
+    @staticmethod
+    @_copy_doc(ops.RelationChangedEvent)
+    def relation_changed(
+        relation: "RelationBase",
+        *,
+        remote_unit: Optional[int] = None,
+    ):
+        return _Event(
+            f"{relation.endpoint}_relation_changed",
+            relation=relation,
+            relation_remote_unit_id=remote_unit,
+        )
+
+    @staticmethod
+    @_copy_doc(ops.RelationDepartedEvent)
+    def relation_departed(
+        relation: "RelationBase",
+        *,
+        remote_unit: Optional[int] = None,
+        departing_unit: Optional[int] = None,
+    ):
+        return _Event(
+            f"{relation.endpoint}_relation_departed",
+            relation=relation,
+            relation_remote_unit_id=remote_unit,
+            relation_departed_unit_id=departing_unit,
+        )
+
+    @staticmethod
+    @_copy_doc(ops.RelationBrokenEvent)
+    def relation_broken(relation: "RelationBase"):
+        return _Event(f"{relation.endpoint}_relation_broken", relation=relation)
+
+    @staticmethod
+    @_copy_doc(ops.StorageAttachedEvent)
+    def storage_attached(storage: Storage):
+        return _Event(f"{storage.name}_storage_attached", storage=storage)
+
+    @staticmethod
+    @_copy_doc(ops.StorageDetachingEvent)
+    def storage_detaching(storage: Storage):
+        return _Event(f"{storage.name}_storage_detaching", storage=storage)
+
+    @staticmethod
+    @_copy_doc(ops.PebbleReadyEvent)
+    def pebble_ready(container: Container):
+        return _Event(f"{container.name}_pebble_ready", container=container)
+
+    @staticmethod
+    @_copy_doc(ops.PebbleCustomNoticeEvent)
+    def pebble_custom_notice(container: Container, notice: Notice):
+        return _Event(
+            f"{container.name}_pebble_custom_notice",
+            container=container,
+            notice=notice,
+        )
 
-    def _get_output(self):
-        return self._ctx._finalize_action(self._ctx.output_state)  # noqa
+    @staticmethod
+    @_copy_doc(ops.PebbleCheckFailedEvent)
+    def pebble_check_failed(container: Container, info: CheckInfo):
+        return _Event(
+            f"{container.name}_pebble_check_failed",
+            container=container,
+            check_info=info,
+        )
+
+    @staticmethod
+    @_copy_doc(ops.PebbleCheckRecoveredEvent)
+    def pebble_check_recovered(container: Container, info: CheckInfo):
+        return _Event(
+            f"{container.name}_pebble_check_recovered",
+            container=container,
+            check_info=info,
+        )
+
+    @staticmethod
+    @_copy_doc(ops.ActionEvent)
+    def action(
+        name: str,
+        params: Optional[Mapping[str, "AnyJson"]] = None,
+        id: Optional[str] = None,
+    ):
+        kwargs = {}
+        if params:
+            kwargs["params"] = params
+        if id:
+            kwargs["id"] = id
+        return _Event(f"{name}_action", action=_Action(name, **kwargs))
 
 
 class Context:
     """Represents a simulated charm's execution context.
 
-    It is the main entry point to running a scenario test.
+    The main entry point to running a test. It contains:
 
-    It contains: the charm source code being executed, the metadata files associated with it,
-    a charm project repository root, and the Juju version to be simulated.
+    - the charm source code being executed
+    - the metadata files associated with it
+    - a charm project repository root
+    - the Juju version to be simulated
 
-    After you have instantiated ``Context``, typically you will call one of ``run()`` or
-    ``run_action()`` to execute the charm once, write any assertions you like on the output
-    state returned by the call, write any assertions you like on the ``Context`` attributes,
-    then discard the ``Context``.
+    After you have instantiated ``Context``, typically you will call :meth:`run()` to execute the
+    charm once, write any assertions you like on the output state returned by the call, write any
+    assertions you like on the ``Context`` attributes, then discard the ``Context``.
 
     Each ``Context`` instance is in principle designed to be single-use:
     ``Context`` is not cleaned up automatically between charm runs.
-    You can call ``.clear()`` to do some clean up, but we don't guarantee all state will be gone.
 
     Any side effects generated by executing the charm, that are not rightful part of the
     ``State``, are in fact stored in the ``Context``:
 
-    - :attr:`juju_log`: record of what the charm has sent to juju-log
-    - :attr:`app_status_history`: record of the app statuses the charm has set
-    - :attr:`unit_status_history`: record of the unit statuses the charm has set
-    - :attr:`workload_version_history`: record of the workload versions the charm has set
-    - :attr:`emitted_events`: record of the events (including custom) that the charm has processed
+    - :attr:`juju_log`
+    - :attr:`app_status_history`
+    - :attr:`unit_status_history`
+    - :attr:`workload_version_history`
+    - :attr:`removed_secret_revisions`
+    - :attr:`requested_storages`
+    - :attr:`emitted_events`
+    - :attr:`action_logs`
+    - :attr:`action_results`
 
     This allows you to write assertions not only on the output state, but also, to some
     extent, on the path the charm took to get there.
 
-    A typical scenario test will look like::
+    A typical test will look like::
 
-        from scenario import Context, State
-        from ops import ActiveStatus
         from charm import MyCharm, MyCustomEvent  # noqa
 
         def test_foo():
             # Arrange: set the context up
-            c = Context(MyCharm)
+            ctx = Context(MyCharm)
             # Act: prepare the state and emit an event
-            state_out = c.run('update-status', State())
+            state_out = ctx.run(ctx.on.update_status(), State())
             # Assert: verify the output state is what you think it should be
             assert state_out.unit_status == ActiveStatus('foobar')
             # Assert: verify the Context contains what you think it should
             assert len(c.emitted_events) == 4
             assert isinstance(c.emitted_events[3], MyCustomEvent)
 
-    If the charm, say, expects a ``./src/foo/bar.yaml`` file present relative to the
-    execution cwd, you need to use the ``charm_root`` argument. For example::
+    If you need access to the charm object that will handle the event, use the
+    class in a ``with`` statement, like::
+
+        def test_foo():
+            ctx = Context(MyCharm)
+            with ctx(ctx.on.start(), State()) as manager:
+                manager.charm._some_private_setup()
+                manager.run()
+    """
+
+    juju_log: List["JujuLogLine"]
+    """A record of what the charm has sent to juju-log"""
+    app_status_history: List["_EntityStatus"]
+    """A record of the app statuses the charm has set"""
+    unit_status_history: List["_EntityStatus"]
+    """A record of the unit statuses the charm has set"""
+    workload_version_history: List[str]
+    """A record of the workload versions the charm has set"""
+    removed_secret_revisions: List[int]
+    """A record of the secret revisions the charm has removed"""
+    emitted_events: List[ops.EventBase]
+    """A record of the events (including custom) that the charm has processed"""
+    requested_storages: Dict[str, int]
+    """A record of the storages the charm has requested"""
+    action_logs: List[str]
+    """The logs associated with the action output, set by the charm with :meth:`ops.ActionEvent.log`
+
+    This will be empty when handling a non-action event.
+    """
+    action_results: Optional[Dict[str, Any]]
+    """A key-value mapping assigned by the charm as a result of the action.
 
-        import scenario
-        import tempfile
-        virtual_root = tempfile.TemporaryDirectory()
-        local_path = Path(local_path.name)
-        (local_path / 'foo').mkdir()
-        (local_path / 'foo' / 'bar.yaml').write_text('foo: bar')
-        scenario.Context(... charm_root=virtual_root).run(...)
+    This will be ``None`` if the charm never calls :meth:`ops.ActionEvent.set_results`
+    """
+    on: CharmEvents
+    """The events that this charm can respond to.
 
-    Args:
-        charm_type: the CharmBase subclass to call :meth:`ops.main` on.
-        meta: charm metadata to use. Needs to be a valid metadata.yaml format (as a dict).
-            If none is provided, we will search for a ``metadata.yaml`` file in the charm root.
-        actions: charm actions to use. Needs to be a valid actions.yaml format (as a dict).
-            If none is provided, we will search for a ``actions.yaml`` file in the charm root.
-        config: charm config to use. Needs to be a valid config.yaml format (as a dict).
-            If none is provided, we will search for a ``config.yaml`` file in the charm root.
-        juju_version: Juju agent version to simulate.
-        app_name: App name that this charm is deployed as. Defaults to the charm name as
-            defined in its metadata
-        unit_id: Unit ID that this charm is deployed as. Defaults to 0.
-        charm_root: virtual charm root the charm will be executed with.
+    Use this when calling :meth:`run` to specify the event to emit.
     """
 
     def __init__(
         self,
-        charm_type: Type["CharmType"],
+        charm_type: Type[ops.testing.CharmType],
         meta: Optional[Dict[str, Any]] = None,
+        *,
         actions: Optional[Dict[str, Any]] = None,
         config: Optional[Dict[str, Any]] = None,
-        charm_root: Optional["PathLike"] = None,
-        juju_version: str = DEFAULT_JUJU_VERSION,
+        charm_root: Optional[Union[str, Path]] = None,
+        juju_version: str = _DEFAULT_JUJU_VERSION,
         capture_deferred_events: bool = False,
         capture_framework_events: bool = False,
         app_name: Optional[str] = None,
@@ -245,49 +441,17 @@ def __init__(
     ):
         """Represents a simulated charm's execution context.
 
-        It is the main entry point to running a scenario test.
-
-        It contains: the charm source code being executed, the metadata files associated with it,
-        a charm project repository root, and the juju version to be simulated.
-
-        After you have instantiated Context, typically you will call one of `run()` or
-        `run_action()` to execute the charm once, write any assertions you like on the output
-        state returned by the call, write any assertions you like on the Context attributes,
-        then discard the Context.
-        Each Context instance is in principle designed to be single-use:
-        Context is not cleaned up automatically between charm runs.
-        You can call `.clear()` to do some clean up, but we don't guarantee all state will be gone.
-
-        Any side effects generated by executing the charm, that are not rightful part of the State,
-        are in fact stored in the Context:
-        - ``juju_log``: record of what the charm has sent to juju-log
-        - ``app_status_history``: record of the app statuses the charm has set
-        - ``unit_status_history``: record of the unit statuses the charm has set
-        - ``workload_version_history``: record of the workload versions the charm has set
-        - ``emitted_events``: record of the events (including custom ones) that the charm has
-            processed
-
-        This allows you to write assertions not only on the output state, but also, to some
-        extent, on the path the charm took to get there.
-
-        A typical scenario test will look like:
-
-        >>> from scenario import Context, State
-        >>> from ops import ActiveStatus
-        >>> from charm import MyCharm, MyCustomEvent  # noqa
-        >>>
-        >>> def test_foo():
-        >>>     # Arrange: set the context up
-        >>>     c = Context(MyCharm)
-        >>>     # Act: prepare the state and emit an event
-        >>>     state_out = c.run('update-status', State())
-        >>>     # Assert: verify the output state is what you think it should be
-        >>>     assert state_out.unit_status == ActiveStatus('foobar')
-        >>>     # Assert: verify the Context contains what you think it should
-        >>>     assert len(c.emitted_events) == 4
-        >>>     assert isinstance(c.emitted_events[3], MyCustomEvent)
-
-        :arg charm_type: the CharmBase subclass to call ``ops.main()`` on.
+        If the charm, say, expects a ``./src/foo/bar.yaml`` file present relative to the
+        execution cwd, you need to use the ``charm_root`` argument. For example::
+
+            import tempfile
+            virtual_root = tempfile.TemporaryDirectory()
+            local_path = Path(local_path.name)
+            (local_path / 'foo').mkdir()
+            (local_path / 'foo' / 'bar.yaml').write_text('foo: bar')
+            Context(... charm_root=virtual_root).run(...)
+
+        :arg charm_type: the :class:`ops.CharmBase` subclass to handle the event.
         :arg meta: charm metadata to use. Needs to be a valid metadata.yaml format (as a dict).
             If none is provided, we will search for a ``metadata.yaml`` file in the charm root.
         :arg actions: charm actions to use. Needs to be a valid actions.yaml format (as a dict).
@@ -296,21 +460,11 @@ def __init__(
             If none is provided, we will search for a ``config.yaml`` file in the charm root.
         :arg juju_version: Juju agent version to simulate.
         :arg app_name: App name that this charm is deployed as. Defaults to the charm name as
-            defined in metadata.yaml.
-        :arg unit_id: Unit ID that this charm is deployed as. Defaults to 0.
+            defined in the metadata.
+        :arg unit_id: Unit ID that this charm is deployed as.
         :arg app_trusted: whether the charm has Juju trust (deployed with ``--trust`` or added with
-            ``juju trust``). Defaults to False
-        :arg charm_root: virtual charm root the charm will be executed with.
-            If the charm, say, expects a `./src/foo/bar.yaml` file present relative to the
-            execution cwd, you need to use this. E.g.:
-
-            >>> import scenario
-            >>> import tempfile
-            >>> virtual_root = tempfile.TemporaryDirectory()
-            >>> local_path = Path(local_path.name)
-            >>> (local_path / 'foo').mkdir()
-            >>> (local_path / 'foo' / 'bar.yaml').write_text('foo: bar')
-            >>> scenario.Context(... charm_root=virtual_root).run(...)
+            ``juju trust``).
+        :arg charm_root: virtual charm filesystem root the charm will be executed with.
         """
 
         if not any((meta, actions, config)):
@@ -354,36 +508,26 @@ def __init__(
         self.juju_log: List["JujuLogLine"] = []
         self.app_status_history: List["_EntityStatus"] = []
         self.unit_status_history: List["_EntityStatus"] = []
+        self.exec_history: Dict[str, List[ops.testing.ExecArgs]] = {}
         self.workload_version_history: List[str] = []
-        self.emitted_events: List[EventBase] = []
+        self.removed_secret_revisions: List[int] = []
+        self.emitted_events: List[ops.EventBase] = []
         self.requested_storages: Dict[str, int] = {}
 
         # set by Runtime.exec() in self._run()
         self._output_state: Optional["State"] = None
 
-        # ephemeral side effects from running an action
+        # operations (and embedded tasks) from running actions
+        self.action_logs: List[str] = []
+        self.action_results: Optional[Dict[str, Any]] = None
+        self._action_failure_message: Optional[str] = None
 
-        self._action_logs: List[str] = []
-        self._action_results: Optional[Dict[str, str]] = None
-        self._action_failure: Optional[str] = None
+        self.on = CharmEvents()
 
     def _set_output_state(self, output_state: "State"):
         """Hook for Runtime to set the output state."""
         self._output_state = output_state
 
-    @property
-    def output_state(self) -> "State":
-        """The output state obtained by running an event on this context.
-
-        Raises:
-            RuntimeError: if this ``Context`` hasn't been :meth:`run` yet.
-        """
-        if not self._output_state:
-            raise RuntimeError(
-                "No output state available. ``.run()`` this Context first.",
-            )
-        return self._output_state
-
     def _get_container_root(self, container_name: str):
         """Get the path to a tempdir where this container's simulated root will live."""
         return Path(self._tmp.name) / "containers" / container_name
@@ -395,233 +539,116 @@ def _get_storage_root(self, name: str, index: int) -> Path:
         storage_root.mkdir(parents=True, exist_ok=True)
         return storage_root
 
-    def clear(self):
-        """Deprecated.
-
-        Use cleanup instead.
-        """
-        logger.warning(
-            "Context.clear() is deprecated and will be nuked in v6. "
-            "Use Context.cleanup() instead.",
-        )
-        self.cleanup()
-
-    def cleanup(self):
-        """Cleanup side effects histories and reset the simulated filesystem state."""
-        self.juju_log = []
-        self.app_status_history = []
-        self.unit_status_history = []
-        self.workload_version_history = []
-        self.emitted_events = []
-        self.requested_storages = {}
-        self._action_logs = []
-        self._action_results = None
-        self._action_failure = None
-        self._output_state = None
-
-        self._tmp.cleanup()
-        self._tmp = tempfile.TemporaryDirectory()
-
     def _record_status(self, state: "State", is_app: bool):
         """Record the previous status before a status change."""
         if is_app:
-            self.app_status_history.append(cast("_EntityStatus", state.app_status))
+            self.app_status_history.append(state.app_status)
         else:
-            self.unit_status_history.append(cast("_EntityStatus", state.unit_status))
+            self.unit_status_history.append(state.unit_status)
 
-    @staticmethod
-    def _coalesce_action(action: Union[str, Action]) -> Action:
-        """Validate the action argument and cast to Action."""
-        if isinstance(action, str):
-            return Action(action)
-
-        if not isinstance(action, Action):
-            raise InvalidActionError(
-                f"Expected Action or action name; got {type(action)}",
-            )
-        return action
-
-    @staticmethod
-    def _coalesce_event(event: Union[str, Event]) -> Event:
-        """Validate the event argument and cast to Event."""
-        if isinstance(event, str):
-            event = Event(event)
-
-        if not isinstance(event, Event):
-            raise InvalidEventError(f"Expected Event | str, got {type(event)}")
-
-        if event._is_action_event:  # noqa
-            raise InvalidEventError(
-                "Cannot Context.run() action events. "
-                "Use Context.run_action instead.",
-            )
-        return event
-
-    @staticmethod
-    def _warn_deprecation_if_pre_or_post_event(
-        pre_event: Optional[Callable],
-        post_event: Optional[Callable],
-    ):
-        # warn if pre/post event arguments are passed
-        legacy_mode = pre_event or post_event
-        if legacy_mode:
-            logger.warning(
-                "The [pre/post]_event syntax is deprecated and "
-                "will be removed in a future release. "
-                "Please use the ``Context.[action_]manager`` context manager.",
-            )
-
-    def manager(
-        self,
-        event: Union["Event", str],
-        state: "State",
-    ):
+    def __call__(self, event: "_Event", state: "State"):
         """Context manager to introspect live charm object before and after the event is emitted.
 
         Usage::
 
-            with Context().manager("start", State()) as manager:
-                assert manager.charm._some_private_attribute == "foo"  # noqa
+            ctx = Context(MyCharm)
+            with ctx(ctx.on.start(), State()) as manager:
+                manager.charm._some_private_setup()
                 manager.run()  # this will fire the event
                 assert manager.charm._some_private_attribute == "bar"  # noqa
 
         Args:
-            event: the :class:`Event` that the charm will respond to.
-            state: the :class:`State` instance to use when handling the Event.
+            event: the event that the charm will respond to.
+            state: the :class:`State` instance to use when handling the event.
         """
-        return _EventManager(self, event, state)
+        return Manager(self, event, state)
 
-    def action_manager(
-        self,
-        action: Union["Action", str],
-        state: "State",
-    ):
-        """Context manager to introspect live charm object before and after the event is emitted.
-
-        Usage:
-        >>> with Context().action_manager("foo-action", State()) as manager:
-        >>>     assert manager.charm._some_private_attribute == "foo"  # noqa
-        >>>     manager.run()  # this will fire the event
-        >>>     assert manager.charm._some_private_attribute == "bar"  # noqa
+    def run_action(self, action: str, state: "State"):
+        """Use `run()` instead.
 
-        :arg action: the Action that the charm will execute. Can be a string or an Action instance.
-        :arg state: the State instance to use as data source for the hook tool calls that the
-            charm will invoke when handling the Action (event).
+        :private:
         """
-        return _ActionManager(self, action, state)
-
-    @contextmanager
-    def _run_event(
-        self,
-        event: Union["Event", str],
-        state: "State",
-    ):
-        _event = self._coalesce_event(event)
-        with self._run(event=_event, state=state) as ops:
-            yield ops
+        raise AttributeError(
+            f"call with `ctx.run`, like `ctx.run(ctx.on.action({action!r})` "
+            "and find the results in `ctx.action_results`",
+        )
 
-    def run(
-        self,
-        event: Union["Event", str],
-        state: "State",
-        pre_event: Optional[Callable[[CharmBase], None]] = None,
-        post_event: Optional[Callable[[CharmBase], None]] = None,
-    ) -> "State":
-        """Trigger a charm execution with an Event and a State.
+    def run(self, event: "_Event", state: "State") -> "State":
+        """Trigger a charm execution with an event and a State.
 
         Calling this function will call ``ops.main`` and set up the context according to the
-        specified ``State``, then emit the event on the charm.
-
-        :arg event: the Event that the charm will respond to. Can be a string or an Event instance.
-        :arg state: the State instance to use as data source for the hook tool calls that the
-            charm will invoke when handling the Event.
-        :arg pre_event: callback to be invoked right before emitting the event on the newly
-            instantiated charm. Will receive the charm instance as only positional argument.
-            This argument is deprecated. Please use ``Context.manager`` instead.
-        :arg post_event: callback to be invoked right after emitting the event on the charm.
-            Will receive the charm instance as only positional argument.
-            This argument is deprecated. Please use ``Context.manager`` instead.
-        """
-        self._warn_deprecation_if_pre_or_post_event(pre_event, post_event)
-
-        with self._run_event(event=event, state=state) as ops:
-            if pre_event:
-                pre_event(cast(CharmBase, ops.charm))
-
-            ops.emit()
-
-            if post_event:
-                post_event(cast(CharmBase, ops.charm))
-
-        return self.output_state
+        specified :class:`State`, then emit the event on the charm.
 
-    def run_action(
-        self,
-        action: Union["Action", str],
-        state: "State",
-        pre_event: Optional[Callable[[CharmBase], None]] = None,
-        post_event: Optional[Callable[[CharmBase], None]] = None,
-    ) -> ActionOutput:
-        """Trigger a charm execution with an Action and a State.
-
-        Calling this function will call ``ops.main`` and set up the context according to the
-        specified ``State``, then emit the event on the charm.
-
-        :arg action: the Action that the charm will execute. Can be a string or an Action instance.
-        :arg state: the State instance to use as data source for the hook tool calls that the
-            charm will invoke when handling the Action (event).
-        :arg pre_event: callback to be invoked right before emitting the event on the newly
-            instantiated charm. Will receive the charm instance as only positional argument.
-            This argument is deprecated. Please use ``Context.action_manager`` instead.
-        :arg post_event: callback to be invoked right after emitting the event on the charm.
-            Will receive the charm instance as only positional argument.
-            This argument is deprecated. Please use ``Context.action_manager`` instead.
+        :arg event: the event that the charm will respond to. Use the :attr:`on` attribute to
+            specify the event; for example: ``ctx.on.start()``.
+        :arg state: the :class:`State` instance to use as data source for the hook tool calls that
+            the charm will invoke when handling the event.
         """
-        self._warn_deprecation_if_pre_or_post_event(pre_event, post_event)
-
-        _action = self._coalesce_action(action)
-        with self._run_action(action=_action, state=state) as ops:
-            if pre_event:
-                pre_event(cast(CharmBase, ops.charm))
+        # Help people transition from Scenario 6:
+        if isinstance(event, str):
+            event = event.replace("-", "_")  # type: ignore
+            if event in (
+                "install",
+                "start",
+                "stop",
+                "remove",
+                "update_status",
+                "config_changed",
+                "upgrade_charm",
+                "pre_series_upgrade",
+                "post_series_upgrade",
+                "leader_elected",
+                "collect_app_status",
+                "collect_unit_status",
+            ):
+                suggested = f"{event}()"
+            elif event in ("secret_changed", "secret_rotate"):
+                suggested = f"{event}(my_secret)"
+            elif event in ("secret_expired", "secret_remove"):
+                suggested = f"{event}(my_secret, revision=1)"
+            elif event in (
+                "relation_created",
+                "relation_joined",
+                "relation_changed",
+                "relation_departed",
+                "relation_broken",
+            ):
+                suggested = f"{event}(my_relation)"
+            elif event in ("storage_attached", "storage_detaching"):
+                suggested = f"{event}(my_storage)"
+            elif event == "pebble_ready":
+                suggested = f"{event}(my_container)"
+            elif event == "pebble_custom_notice":
+                suggested = f"{event}(my_container, my_notice)"
+            else:
+                suggested = "event()"
+            raise TypeError(
+                f"call with an event from `ctx.on`, like `ctx.on.{suggested}`",
+            )
+        if callable(event):
+            raise TypeError(
+                "You should call the event method. Did you forget to add parentheses?",
+            )
 
+        if event.action:
+            # Reset the logs, failure status, and results, in case the context
+            # is reused.
+            self.action_logs.clear()
+            if self.action_results is not None:
+                self.action_results.clear()
+            self._action_failure_message = None
+        with self._run(event=event, state=state) as ops:
             ops.emit()
-
-            if post_event:
-                post_event(cast(CharmBase, ops.charm))
-
-        return self._finalize_action(self.output_state)
-
-    def _finalize_action(self, state_out: "State"):
-        ao = ActionOutput(
-            state_out,
-            self._action_logs,
-            self._action_results,
-            self._action_failure,
-        )
-
-        # reset all action-related state
-        self._action_logs = []
-        self._action_results = None
-        self._action_failure = None
-
-        return ao
-
-    @contextmanager
-    def _run_action(
-        self,
-        action: Union["Action", str],
-        state: "State",
-    ):
-        _action = self._coalesce_action(action)
-        with self._run(event=_action.event, state=state) as ops:
-            yield ops
+        # We know that the output state will have been set by this point,
+        # so let the type checkers know that too.
+        assert self._output_state is not None
+        if event.action:
+            if self._action_failure_message is not None:
+                raise ActionFailed(self._action_failure_message, self._output_state)
+        return self._output_state
 
     @contextmanager
-    def _run(
-        self,
-        event: "Event",
-        state: "State",
-    ):
+    def _run(self, event: "_Event", state: "State"):
         runtime = Runtime(
             charm_spec=self.charm_spec,
             juju_version=self.juju_version,
diff --git a/scenario/errors.py b/scenario/errors.py
new file mode 100644
index 00000000..56a01d12
--- /dev/null
+++ b/scenario/errors.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+# Copyright 2024 Canonical Ltd.
+# See LICENSE file for licensing details.
+
+"""Exceptions raised by the framework.
+
+Note that these exceptions are not meant to be caught by charm authors. They are
+used by the framework to signal errors or inconsistencies in the charm tests
+themselves.
+"""
+
+
+class ContextSetupError(RuntimeError):
+    """Raised by Context when setup fails."""
+
+
+class AlreadyEmittedError(RuntimeError):
+    """Raised when ``run()`` is called more than once."""
+
+
+class ScenarioRuntimeError(RuntimeError):
+    """Base class for exceptions raised by the runtime module."""
+
+
+class UncaughtCharmError(ScenarioRuntimeError):
+    """Error raised if the charm raises while handling the event being dispatched."""
+
+
+class InconsistentScenarioError(ScenarioRuntimeError):
+    """Error raised when the combination of state and event is inconsistent."""
+
+
+class StateValidationError(RuntimeError):
+    """Raised when individual parts of the State are inconsistent."""
+
+    # as opposed to InconsistentScenario error where the **combination** of
+    # several parts of the State are.
+
+
+class MetadataNotFoundError(RuntimeError):
+    """Raised when Scenario can't find a metadata file in the provided charm root."""
+
+
+class ActionMissingFromContextError(Exception):
+    """Raised when the user attempts to invoke action hook tools outside an action context."""
+
+    # This is not an ops error: in ops, you'd have to go exceptionally out of
+    # your way to trigger this flow.
+
+
+class NoObserverError(RuntimeError):
+    """Error raised when the event being dispatched has no registered observers."""
+
+
+class BadOwnerPath(RuntimeError):
+    """Error raised when the owner path does not lead to a valid ObjectEvents instance."""
diff --git a/scenario/mocking.py b/scenario/mocking.py
index ac56d719..f5207a37 100644
--- a/scenario/mocking.py
+++ b/scenario/mocking.py
@@ -2,9 +2,7 @@
 # Copyright 2023 Canonical Ltd.
 # See LICENSE file for licensing details.
 import datetime
-import random
 import shutil
-from io import StringIO
 from pathlib import Path
 from typing import (
     TYPE_CHECKING,
@@ -15,13 +13,17 @@
     Mapping,
     Optional,
     Set,
+    TextIO,
     Tuple,
     Union,
     cast,
 )
 
-from ops import CloudSpec, JujuVersion, pebble
-from ops.model import ModelError, RelationNotFoundError
+from ops import JujuVersion, pebble
+from ops.model import CloudSpec as CloudSpec_Ops
+from ops.model import ModelError
+from ops.model import Port as Port_Ops
+from ops.model import RelationNotFoundError
 from ops.model import Secret as Secret_Ops  # lol
 from ops.model import (
     SecretInfo,
@@ -31,16 +33,21 @@
     _ModelBackend,
 )
 from ops.pebble import Client, ExecError
-from ops.testing import _TestingPebbleClient
+from ops.testing import ExecArgs, _TestingPebbleClient
 
+from scenario.errors import ActionMissingFromContextError
 from scenario.logger import logger as scenario_logger
 from scenario.state import (
     JujuLogLine,
     Mount,
     Network,
     PeerRelation,
-    Port,
+    Relation,
+    RelationBase,
     Storage,
+    SubordinateRelation,
+    _EntityStatus,
+    _port_cls_by_protocol,
     _RawPortProtocolLiteral,
     _RawStatusLiteral,
 )
@@ -48,47 +55,52 @@
 if TYPE_CHECKING:  # pragma: no cover
     from scenario.context import Context
     from scenario.state import Container as ContainerSpec
-    from scenario.state import (
-        Event,
-        ExecOutput,
-        Relation,
-        Secret,
-        State,
-        SubordinateRelation,
-        _CharmSpec,
-    )
+    from scenario.state import Exec, Secret, State, _CharmSpec, _Event
 
 logger = scenario_logger.getChild("mocking")
 
 
-class ActionMissingFromContextError(Exception):
-    """Raised when the user attempts to invoke action hook tools outside an action context."""
-
-    # This is not an ops error: in ops, you'd have to go exceptionally out of your way to trigger
-    # this flow.
-
-
 class _MockExecProcess:
-    def __init__(self, command: Tuple[str, ...], change_id: int, out: "ExecOutput"):
-        self._command = command
+    def __init__(
+        self,
+        change_id: int,
+        args: ExecArgs,
+        return_code: int,
+        stdin: Optional[TextIO],
+        stdout: Optional[TextIO],
+        stderr: Optional[TextIO],
+    ):
         self._change_id = change_id
-        self._out = out
+        self._args = args
+        self._return_code = return_code
         self._waited = False
-        self.stdout = StringIO(self._out.stdout)
-        self.stderr = StringIO(self._out.stderr)
+        self.stdin = stdin
+        self.stdout = stdout
+        self.stderr = stderr
+
+    def __del__(self):
+        if not self._waited:
+            self._close_stdin()
+
+    def _close_stdin(self):
+        if self._args.stdin is None and self.stdin is not None:
+            self.stdin.seek(0)
+            self._args.stdin = self.stdin.read()
 
     def wait(self):
+        self._close_stdin()
         self._waited = True
-        exit_code = self._out.return_code
-        if exit_code != 0:
-            raise ExecError(list(self._command), exit_code, None, None)
+        if self._return_code != 0:
+            raise ExecError(list(self._args.command), self._return_code, None, None)
 
     def wait_output(self):
-        out = self._out
-        exit_code = out.return_code
-        if exit_code != 0:
-            raise ExecError(list(self._command), exit_code, None, None)
-        return out.stdout, out.stderr
+        self._close_stdin()
+        self._waited = True
+        stdout = self.stdout.read() if self.stdout is not None else None
+        stderr = self.stderr.read() if self.stderr is not None else None
+        if self._return_code != 0:
+            raise ExecError(list(self._args.command), self._return_code, stdout, stderr)
+        return stdout, stderr
 
     def send_signal(self, sig: Union[int, str]):  # noqa: U100
         raise NotImplementedError()
@@ -102,7 +114,7 @@ class _MockModelBackend(_ModelBackend):
     def __init__(
         self,
         state: "State",
-        event: "Event",
+        event: "_Event",
         charm_spec: "_CharmSpec",
         context: "Context",
     ):
@@ -112,8 +124,11 @@ def __init__(
         self._context = context
         self._charm_spec = charm_spec
 
-    def opened_ports(self) -> Set[Port]:
-        return set(self._state.opened_ports)
+    def opened_ports(self) -> Set[Port_Ops]:
+        return {
+            Port_Ops(protocol=port.protocol, port=port.port)
+            for port in self._state.opened_ports
+        }
 
     def open_port(
         self,
@@ -122,20 +137,24 @@ def open_port(
     ):
         # fixme: the charm will get hit with a StateValidationError
         #  here, not the expected ModelError...
-        port_ = Port(protocol, port)
-        ports = self._state.opened_ports
+        port_ = _port_cls_by_protocol[protocol](port=port)
+        ports = set(self._state.opened_ports)
         if port_ not in ports:
-            ports.append(port_)
+            ports.add(port_)
+        if ports != self._state.opened_ports:
+            self._state._update_opened_ports(frozenset(ports))
 
     def close_port(
         self,
         protocol: "_RawPortProtocolLiteral",
         port: Optional[int] = None,
     ):
-        _port = Port(protocol, port)
-        ports = self._state.opened_ports
+        _port = _port_cls_by_protocol[protocol](port=port)
+        ports = set(self._state.opened_ports)
         if _port in ports:
             ports.remove(_port)
+        if ports != self._state.opened_ports:
+            self._state._update_opened_ports(frozenset(ports))
 
     def get_pebble(self, socket_path: str) -> "Client":
         container_name = socket_path.split("/")[
@@ -144,7 +163,7 @@ def get_pebble(self, socket_path: str) -> "Client":
         container_root = self._context._get_container_root(container_name)
         try:
             mounts = self._state.get_container(container_name).mounts
-        except ValueError:
+        except KeyError:
             # container not defined in state.
             mounts = {}
 
@@ -155,18 +174,15 @@ def get_pebble(self, socket_path: str) -> "Client":
             state=self._state,
             event=self._event,
             charm_spec=self._charm_spec,
+            context=self._context,
+            container_name=container_name,
         )
 
-    def _get_relation_by_id(
-        self,
-        rel_id,
-    ) -> Union["Relation", "SubordinateRelation", "PeerRelation"]:
+    def _get_relation_by_id(self, rel_id) -> "RelationBase":
         try:
-            return next(
-                filter(lambda r: r.relation_id == rel_id, self._state.relations),
-            )
-        except StopIteration:
-            raise RelationNotFoundError()
+            return self._state.get_relation(rel_id)
+        except ValueError:
+            raise RelationNotFoundError() from None
 
     def _get_secret(self, id=None, label=None):
         # FIXME: what error would a charm get IRL?
@@ -193,21 +209,16 @@ def _get_secret(self, id=None, label=None):
             return secrets[0]
 
         elif label:
-            secrets = [s for s in self._state.secrets if s.label == label]
-            if not secrets:
-                raise SecretNotFoundError(label)
-            return secrets[0]
+            try:
+                return self._state.get_secret(label=label)
+            except KeyError:
+                raise SecretNotFoundError(label) from None
 
         else:
             # if all goes well, this should never be reached. ops.model.Secret will check upon
             # instantiation that either an id or a label are set, and raise a TypeError if not.
             raise RuntimeError("need id or label.")
 
-    @staticmethod
-    def _generate_secret_id():
-        id = "".join(map(str, [random.choice(list(range(10))) for _ in range(20)]))
-        return f"secret:{id}"
-
     def _check_app_data_access(self, is_app: bool):
         if not isinstance(is_app, bool):
             raise TypeError("is_app parameter to relation_get must be a boolean")
@@ -229,7 +240,10 @@ def relation_get(self, relation_id: int, member_name: str, is_app: bool):
         elif is_app:
             if isinstance(relation, PeerRelation):
                 return relation.local_app_data
-            return relation.remote_app_data
+            elif isinstance(relation, (Relation, SubordinateRelation)):
+                return relation.remote_app_data
+            else:
+                raise TypeError("relation_get: unknown relation type")
         elif member_name == self.unit_name:
             return relation.local_unit_data
 
@@ -245,9 +259,7 @@ def status_get(self, *, is_app: bool = False):
 
     def relation_ids(self, relation_name):
         return [
-            rel.relation_id
-            for rel in self._state.relations
-            if rel.endpoint == relation_name
+            rel.id for rel in self._state.relations if rel.endpoint == relation_name
         ]
 
     def relation_list(self, relation_id: int) -> Tuple[str, ...]:
@@ -310,8 +322,11 @@ def network_get(self, binding_name: str, relation_id: Optional[int] = None):
             raise RelationNotFoundError()
 
         # We look in State.networks for an override. If not given, we return a default network.
-        network = self._state.networks.get(binding_name, Network.default())
-        return network.hook_tool_output_fmt()
+        try:
+            network = self._state.get_network(binding_name)
+        except KeyError:
+            network = Network("default")  # The name is not used in the output.
+        return network._hook_tool_output_fmt()
 
     # setter methods: these can mutate the state.
     def application_version_set(self, version: str):
@@ -329,7 +344,8 @@ def status_set(
         is_app: bool = False,
     ):
         self._context._record_status(self._state, is_app)
-        self._state._update_status(status, message, is_app)
+        status_obj = _EntityStatus.from_status_name(status, message)
+        self._state._update_status(status_obj, is_app)
 
     def juju_log(self, level: str, message: str):
         self._context.juju_log.append(JujuLogLine(level, message))
@@ -360,18 +376,18 @@ def secret_add(
     ) -> str:
         from scenario.state import Secret
 
-        secret_id = self._generate_secret_id()
         secret = Secret(
-            id=secret_id,
-            contents={0: content},
+            content,
             label=label,
             description=description,
             expire=expire,
             rotate=rotate,
             owner=owner,
         )
-        self._state.secrets.append(secret)
-        return secret_id
+        secrets = set(self._state.secrets)
+        secrets.add(secret)
+        self._state._update_secrets(frozenset(secrets))
+        return secret.id
 
     def _check_can_manage_secret(
         self,
@@ -399,21 +415,24 @@ def secret_get(
         peek: bool = False,
     ) -> Dict[str, str]:
         secret = self._get_secret(id, label)
+        # If both the id and label are provided, then update the label.
+        if id is not None and label is not None:
+            secret._set_label(label)
         juju_version = self._context.juju_version
         if not (juju_version == "3.1.7" or juju_version >= "3.3.1"):
-            # in this medieval juju chapter,
+            # In this medieval Juju chapter,
             # secret owners always used to track the latest revision.
             # ref: https://bugs.launchpad.net/juju/+bug/2037120
             if secret.owner is not None:
                 refresh = True
 
-        revision = secret.revision
         if peek or refresh:
-            revision = max(secret.contents.keys())
             if refresh:
-                secret._set_revision(revision)
+                secret._track_latest_revision()
+            assert secret.latest_content is not None
+            return secret.latest_content
 
-        return secret.contents[revision]
+        return secret.tracked_content
 
     def secret_info_get(
         self,
@@ -422,6 +441,9 @@ def secret_info_get(
         label: Optional[str] = None,
     ) -> SecretInfo:
         secret = self._get_secret(id, label)
+        # If both the id and label are provided, then update the label.
+        if id is not None and label is not None:
+            secret._set_label(label)
 
         # only "manage"=write access level can read secret info
         self._check_can_manage_secret(secret)
@@ -429,7 +451,7 @@ def secret_info_get(
         return SecretInfo(
             id=secret.id,
             label=secret.label,
-            revision=max(secret.contents),
+            revision=secret._latest_revision,
             expires=secret.expire,
             rotation=secret.rotate,
             rotates=None,  # not implemented yet.
@@ -448,6 +470,15 @@ def secret_set(
         secret = self._get_secret(id, label)
         self._check_can_manage_secret(secret)
 
+        if content == secret.latest_content:
+            # In Juju 3.6 and higher, this is a no-op, but it's good to warn
+            # charmers if they are doing this, because it's not generally good
+            # practice.
+            # https://bugs.launchpad.net/juju/+bug/2069238
+            logger.warning(
+                f"secret {id} contents set to the existing value: new revision not needed",
+            )
+
         secret._update_metadata(
             content=content,
             label=label,
@@ -486,10 +517,32 @@ def secret_remove(self, id: str, *, revision: Optional[int] = None):
         secret = self._get_secret(id)
         self._check_can_manage_secret(secret)
 
-        if revision:
-            del secret.contents[revision]
-        else:
-            secret.contents.clear()
+        # Removing all revisions means that the secret is removed, so is no
+        # longer in the state.
+        if revision is None:
+            secrets = set(self._state.secrets)
+            secrets.remove(secret)
+            self._state._update_secrets(frozenset(secrets))
+            return
+
+        # Juju does not prevent removing the tracked or latest revision, but it
+        # is always a mistake to do this. Rather than having the state model a
+        # secret where the tracked/latest revision cannot be retrieved but the
+        # secret still exists, we raise instead, so that charms know that there
+        # is a problem with their code.
+        if revision in (secret._tracked_revision, secret._latest_revision):
+            raise ValueError(
+                "Charms should not remove the latest revision of a secret. "
+                "Add a new revision with `set_content()` instead, and the previous "
+                "revision will be cleaned up by the secret owner when no longer in use.",
+            )
+
+        # For all other revisions, the content is not visible to the charm
+        # (this is as designed: the secret is being removed, so it should no
+        # longer be in use). That means that the state does not need to be
+        # modified - however, unit tests should be able to verify that the remove call was
+        # executed, so we track that in a history list in the context.
+        self._context.removed_secret_revisions.append(revision)
 
     def relation_remote_app_name(
         self,
@@ -506,8 +559,10 @@ def relation_remote_app_name(
 
         if isinstance(relation, PeerRelation):
             return self.app_name
-        else:
+        elif isinstance(relation, (Relation, SubordinateRelation)):
             return relation.remote_app_name
+        else:
+            raise TypeError("relation_remote_app_name: unknown relation type")
 
     def action_set(self, results: Dict[str, Any]):
         if not self._event.action:
@@ -518,21 +573,24 @@ def action_set(self, results: Dict[str, Any]):
         _format_action_result_dict(results)
         # but then we will store it in its unformatted,
         # original form for testing ease
-        self._context._action_results = results
+        if self._context.action_results:
+            self._context.action_results.update(results)
+        else:
+            self._context.action_results = results
 
     def action_fail(self, message: str = ""):
         if not self._event.action:
             raise ActionMissingFromContextError(
                 "not in the context of an action event: cannot action-fail",
             )
-        self._context._action_failure = message
+        self._context._action_failure_message = message
 
     def action_log(self, message: str):
         if not self._event.action:
             raise ActionMissingFromContextError(
                 "not in the context of an action event: cannot action-log",
             )
-        self._context._action_logs.append(message)
+        self._context.action_logs.append(message)
 
     def action_get(self):
         action = self._event.action
@@ -556,7 +614,7 @@ def storage_add(self, name: str, count: int = 1):
 
     def storage_list(self, name: str) -> List[int]:
         return [
-            storage.index for storage in self._state.storage if storage.name == name
+            storage.index for storage in self._state.storages if storage.name == name
         ]
 
     def _storage_event_details(self) -> Tuple[int, str]:
@@ -583,7 +641,7 @@ def storage_get(self, storage_name_id: str, attribute: str) -> str:
         name, index = storage_name_id.split("/")
         index = int(index)
         storages: List[Storage] = [
-            s for s in self._state.storage if s.name == name and s.index == index
+            s for s in self._state.storages if s.name == name and s.index == index
         ]
 
         # should not really happen: sanity checks. In practice, ops will guard against these paths.
@@ -623,18 +681,21 @@ def add_metrics(
             "it's deprecated API)",
         )
 
+    # TODO: It seems like this method has no tests.
     def resource_get(self, resource_name: str) -> str:
-        try:
-            return str(self._state.resources[resource_name])
-        except KeyError:
-            # ops will not let us get there if the resource name is unknown from metadata.
-            # but if the user forgot to add it in State, then we remind you of that.
-            raise RuntimeError(
-                f"Inconsistent state: "
-                f"resource {resource_name} not found in State. please pass it.",
-            )
+        # We assume that there are few enough resources that a linear search
+        # will perform well enough.
+        for resource in self._state.resources:
+            if resource.name == resource_name:
+                return str(resource.path)
+        # ops will not let us get there if the resource name is unknown from metadata.
+        # but if the user forgot to add it in State, then we remind you of that.
+        raise RuntimeError(
+            f"Inconsistent state: "
+            f"resource {resource_name} not found in State. please pass it.",
+        )
 
-    def credential_get(self) -> CloudSpec:
+    def credential_get(self) -> CloudSpec_Ops:
         if not self._context.app_trusted:
             raise ModelError(
                 "ERROR charm is not trusted, initialise Context with `app_trusted=True`",
@@ -655,13 +716,17 @@ def __init__(
         mounts: Dict[str, Mount],
         *,
         state: "State",
-        event: "Event",
+        event: "_Event",
         charm_spec: "_CharmSpec",
+        context: "Context",
+        container_name: str,
     ):
         self._state = state
         self.socket_path = socket_path
         self._event = event
         self._charm_spec = charm_spec
+        self._context = context
+        self._container_name = container_name
 
         # wipe just in case
         if container_root.exists():
@@ -674,12 +739,13 @@ def __init__(
             path = Path(mount.location).parts
             mounting_dir = container_root.joinpath(*path[1:])
             mounting_dir.parent.mkdir(parents=True, exist_ok=True)
-            mounting_dir.symlink_to(mount.src)
+            mounting_dir.symlink_to(mount.source)
 
         self._root = container_root
 
-        # load any existing notices from the state
+        # load any existing notices and check information from the state
         self._notices: Dict[Tuple[str, str], pebble.Notice] = {}
+        self._check_infos: Dict[str, pebble.CheckInfo] = {}
         for container in state.containers:
             for notice in container.notices:
                 if hasattr(notice.type, "value"):
@@ -687,6 +753,8 @@ def __init__(
                 else:
                     notice_type = str(notice.type)
                 self._notices[notice_type, notice.key] = notice._to_ops()
+            for check in container.check_infos:
+                self._check_infos[check.name] = check._to_ops()
 
     def get_plan(self) -> pebble.Plan:
         return self._container.plan
@@ -711,21 +779,100 @@ def _layers(self) -> Dict[str, pebble.Layer]:
 
     @property
     def _service_status(self) -> Dict[str, pebble.ServiceStatus]:
-        return self._container.service_status
+        return self._container.service_statuses
+
+    # Based on a method of the same name from ops.testing.
+    def _find_exec_handler(self, command) -> Optional["Exec"]:
+        handlers = {exec.command_prefix: exec for exec in self._container.execs}
+        # Start with the full command and, each loop iteration, drop the last
+        # element, until it matches one of the command prefixes in the execs.
+        # This includes matching against the empty list, which will match any
+        # command, if there is not a more specific match.
+        for prefix_len in reversed(range(len(command) + 1)):
+            command_prefix = tuple(command[:prefix_len])
+            if command_prefix in handlers:
+                return handlers[command_prefix]
+        # None of the command prefixes in the execs matched the command, no
+        # matter how much of it was used, so we have failed to find a handler.
+        return None
 
-    def exec(self, *args, **kwargs):  # noqa: U100 type: ignore
-        cmd = tuple(args[0])
-        out = self._container.exec_mock.get(cmd)
-        if not out:
-            raise RuntimeError(
-                f"mock for cmd {cmd} not found. Please pass to the Container "
-                f"{self._container.name} a scenario.ExecOutput mock for the "
-                f"command your charm is attempting to run, or patch "
-                f"out whatever leads to the call.",
+    def exec(
+        self,
+        command: List[str],
+        *,
+        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[Union[str, bytes, TextIO]] = None,
+        stdout: Optional[TextIO] = None,
+        stderr: Optional[TextIO] = None,
+        encoding: Optional[str] = "utf-8",
+        combine_stderr: bool = False,
+        **kwargs,
+    ):
+        handler = self._find_exec_handler(command)
+        if not handler:
+            raise ExecError(
+                command,
+                127,
+                "",
+                f"mock for cmd {command} not found. Please patch out whatever "
+                f"leads to the call, or pass to the Container {self._container.name} "
+                f"a scenario.Exec mock for the command your charm is attempting "
+                f"to run, such as "
+                f"'Container(..., execs={{scenario.Exec({list(command)}, ...)}})'",
             )
 
-        change_id = out._run()
-        return _MockExecProcess(change_id=change_id, command=cmd, out=out)
+        if stdin is None:
+            proc_stdin = self._transform_exec_handler_output("", encoding)
+        else:
+            proc_stdin = None
+            stdin = stdin.read() if hasattr(stdin, "read") else stdin  # type: ignore
+        if stdout is None:
+            proc_stdout = self._transform_exec_handler_output(handler.stdout, encoding)
+        else:
+            proc_stdout = None
+            stdout.write(handler.stdout)
+        if stderr is None:
+            proc_stderr = self._transform_exec_handler_output(handler.stderr, encoding)
+        else:
+            proc_stderr = None
+            stderr.write(handler.stderr)
+
+        args = ExecArgs(
+            command=command,
+            environment=environment or {},
+            working_dir=working_dir,
+            timeout=timeout,
+            user_id=user_id,
+            user=user,
+            group_id=group_id,
+            group=group,
+            stdin=stdin,  # type:ignore  # If None, will be replaced by proc_stdin.read() later.
+            encoding=encoding,
+            combine_stderr=combine_stderr,
+        )
+        try:
+            self._context.exec_history[self._container_name].append(args)
+        except KeyError:
+            self._context.exec_history[self._container_name] = [args]
+
+        change_id = handler._run()
+        return cast(
+            pebble.ExecProcess[Any],
+            _MockExecProcess(
+                change_id=change_id,
+                args=args,
+                return_code=handler.return_code,
+                stdin=proc_stdin,
+                stdout=proc_stdout,
+                stderr=proc_stderr,
+            ),
+        )
 
     def _check_connection(self):
         if not self._container.can_connect:
diff --git a/scenario/ops_main_mock.py b/scenario/ops_main_mock.py
index c090aef9..27f00a8b 100644
--- a/scenario/ops_main_mock.py
+++ b/scenario/ops_main_mock.py
@@ -19,21 +19,15 @@
 from ops.main import CHARM_STATE_FILE, _Dispatcher, _get_event_args
 from ops.main import logger as ops_logger
 
+from scenario.errors import BadOwnerPath, NoObserverError
+
 if TYPE_CHECKING:  # pragma: no cover
     from scenario.context import Context
-    from scenario.state import Event, State, _CharmSpec
+    from scenario.state import State, _CharmSpec, _Event
 
 # pyright: reportPrivateUsage=false
 
 
-class NoObserverError(RuntimeError):
-    """Error raised when the event being dispatched has no registered observers."""
-
-
-class BadOwnerPath(RuntimeError):
-    """Error raised when the owner path does not lead to a valid ObjectEvents instance."""
-
-
 # TODO: Use ops.jujucontext's _JujuContext.charm_dir.
 def _get_charm_dir():
     charm_dir = os.environ.get("JUJU_CHARM_DIR")
@@ -66,7 +60,7 @@ def _get_owner(root: Any, path: Sequence[str]) -> ops.ObjectEvents:
 def _emit_charm_event(
     charm: "CharmBase",
     event_name: str,
-    event: Optional["Event"] = None,
+    event: Optional["_Event"] = None,
 ):
     """Emits a charm event based on a Juju event name.
 
@@ -83,8 +77,7 @@ def _emit_charm_event(
         ops_logger.debug("Event %s not defined for %s.", event_name, charm)
         raise NoObserverError(
             f"Cannot fire {event_name!r} on {owner}: "
-            f"invalid event (not on charm.on). "
-            f"Use Context.run_custom instead.",
+            f"invalid event (not on charm.on).",
         )
 
     try:
@@ -105,7 +98,7 @@ def _emit_charm_event(
 def setup_framework(
     charm_dir,
     state: "State",
-    event: "Event",
+    event: "_Event",
     context: "Context",
     charm_spec: "_CharmSpec",
 ):
@@ -141,7 +134,7 @@ def setup_framework(
         # If we are in a RelationBroken event, we want to know which relation is
         # broken within the model, not only in the event's `.relation` attribute.
         broken_relation_id = (
-            event.relation.relation_id  # type: ignore
+            event.relation.id  # type: ignore
             if event.name.endswith("_relation_broken")
             else None
         )
@@ -176,7 +169,12 @@ def setup_charm(charm_class, framework, dispatcher):
     return charm
 
 
-def setup(state: "State", event: "Event", context: "Context", charm_spec: "_CharmSpec"):
+def setup(
+    state: "State",
+    event: "_Event",
+    context: "Context",
+    charm_spec: "_CharmSpec",
+):
     """Setup dispatcher, framework and charm objects."""
     charm_class = charm_spec.charm_type
     charm_dir = _get_charm_dir()
@@ -204,7 +202,7 @@ class Ops:
     def __init__(
         self,
         state: "State",
-        event: "Event",
+        event: "_Event",
         context: "Context",
         charm_spec: "_CharmSpec",
     ):
diff --git a/scenario/runtime.py b/scenario/runtime.py
index 71b109a5..f4df73db 100644
--- a/scenario/runtime.py
+++ b/scenario/runtime.py
@@ -1,6 +1,8 @@
 #!/usr/bin/env python3
 # Copyright 2023 Canonical Ltd.
 # See LICENSE file for licensing details.
+import copy
+import dataclasses
 import marshal
 import os
 import re
@@ -8,47 +10,48 @@
 import typing
 from contextlib import contextmanager
 from pathlib import Path
-from typing import TYPE_CHECKING, Dict, List, Optional, Type, Union
+from typing import TYPE_CHECKING, Dict, FrozenSet, List, Optional, Type, TypeVar, Union
 
 import yaml
-from ops import pebble
-from ops.framework import _event_regex
+from ops import CollectStatusEvent, pebble
+from ops.framework import (
+    CommitEvent,
+    EventBase,
+    Framework,
+    Handle,
+    NoTypeError,
+    PreCommitEvent,
+    _event_regex,
+)
 from ops.storage import NoSnapshotError, SQLiteStorage
 
-from scenario.capture_events import capture_events
+from scenario.errors import UncaughtCharmError
 from scenario.logger import logger as scenario_logger
 from scenario.ops_main_mock import NoObserverError
-from scenario.state import DeferredEvent, PeerRelation, StoredState
+from scenario.state import (
+    ActionFailed,
+    DeferredEvent,
+    PeerRelation,
+    Relation,
+    StoredState,
+    SubordinateRelation,
+)
 
 if TYPE_CHECKING:  # pragma: no cover
     from ops.testing import CharmType
 
     from scenario.context import Context
-    from scenario.state import Event, State, _CharmSpec
-
-    PathLike = Union[str, Path]
+    from scenario.state import State, _CharmSpec, _Event
 
 logger = scenario_logger.getChild("runtime")
 STORED_STATE_REGEX = re.compile(
-    r"((?P<owner_path>.*)\/)?(?P<data_type_name>\D+)\[(?P<name>.*)\]",
+    r"((?P<owner_path>.*)\/)?(?P<_data_type_name>\D+)\[(?P<name>.*)\]",
 )
 EVENT_REGEX = re.compile(_event_regex)
 
 RUNTIME_MODULE = Path(__file__).parent
 
 
-class ScenarioRuntimeError(RuntimeError):
-    """Base class for exceptions raised by scenario.runtime."""
-
-
-class UncaughtCharmError(ScenarioRuntimeError):
-    """Error raised if the charm raises while handling the event being dispatched."""
-
-
-class InconsistentScenarioError(ScenarioRuntimeError):
-    """Error raised when the combination of state and event is inconsistent."""
-
-
 class UnitStateDB:
     """Represents the unit-state.db."""
 
@@ -60,12 +63,12 @@ def _open_db(self) -> SQLiteStorage:
         """Open the db."""
         return SQLiteStorage(self._state_file)
 
-    def get_stored_state(self) -> List["StoredState"]:
+    def get_stored_states(self) -> FrozenSet["StoredState"]:
         """Load any StoredState data structures from the db."""
 
         db = self._open_db()
 
-        stored_state = []
+        stored_states = set()
         for handle_path in db.list_snapshots():
             if not EVENT_REGEX.match(handle_path) and (
                 match := STORED_STATE_REGEX.match(handle_path)
@@ -73,10 +76,10 @@ def get_stored_state(self) -> List["StoredState"]:
                 stored_state_snapshot = db.load_snapshot(handle_path)
                 kwargs = match.groupdict()
                 sst = StoredState(content=stored_state_snapshot, **kwargs)
-                stored_state.append(sst)
+                stored_states.add(sst)
 
         db.close()
-        return stored_state
+        return frozenset(stored_states)
 
     def get_deferred_events(self) -> List["DeferredEvent"]:
         """Load any DeferredEvent data structures from the db."""
@@ -117,8 +120,8 @@ def apply_state(self, state: "State"):
                 ) from e
             db.save_snapshot(event.handle_path, event.snapshot_data)
 
-        for stored_state in state.stored_state:
-            db.save_snapshot(stored_state.handle_path, stored_state.content)
+        for stored_state in state.stored_states:
+            db.save_snapshot(stored_state._handle_path, stored_state.content)
 
         db.close()
 
@@ -154,7 +157,7 @@ class Runtime:
     def __init__(
         self,
         charm_spec: "_CharmSpec",
-        charm_root: Optional["PathLike"] = None,
+        charm_root: Optional[Union[str, Path]] = None,
         juju_version: str = "3.0.0",
         app_name: Optional[str] = None,
         unit_id: Optional[int] = 0,
@@ -179,7 +182,7 @@ def _cleanup_env(env):
             # os.unsetenv does not always seem to work !?
             del os.environ[key]
 
-    def _get_event_env(self, state: "State", event: "Event", charm_root: Path):
+    def _get_event_env(self, state: "State", event: "_Event", charm_root: Path):
         """Build the simulated environment the operator framework expects."""
         env = {
             "JUJU_VERSION": self._juju_version,
@@ -204,12 +207,14 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path):
         if event._is_relation_event and (relation := event.relation):
             if isinstance(relation, PeerRelation):
                 remote_app_name = self._app_name
-            else:
+            elif isinstance(relation, (Relation, SubordinateRelation)):
                 remote_app_name = relation.remote_app_name
+            else:
+                raise ValueError(f"Unknown relation type: {relation}")
             env.update(
                 {
                     "JUJU_RELATION": relation.endpoint,
-                    "JUJU_RELATION_ID": str(relation.relation_id),
+                    "JUJU_RELATION_ID": str(relation.id),
                     "JUJU_REMOTE_APP": remote_app_name,
                 },
             )
@@ -217,7 +222,9 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path):
             remote_unit_id = event.relation_remote_unit_id
 
             # don't check truthiness because remote_unit_id could be 0
-            if remote_unit_id is None:
+            if remote_unit_id is None and not event.name.endswith(
+                ("_relation_created", "relation_broken"),
+            ):
                 remote_unit_ids = relation._remote_unit_ids  # pyright: ignore
 
                 if len(remote_unit_ids) == 1:
@@ -244,7 +251,12 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path):
                 remote_unit = f"{remote_app_name}/{remote_unit_id}"
                 env["JUJU_REMOTE_UNIT"] = remote_unit
                 if event.name.endswith("_relation_departed"):
-                    env["JUJU_DEPARTING_UNIT"] = remote_unit
+                    if event.relation_departed_unit_id:
+                        env[
+                            "JUJU_DEPARTING_UNIT"
+                        ] = f"{remote_app_name}/{event.relation_departed_unit_id}"
+                    else:
+                        env["JUJU_DEPARTING_UNIT"] = remote_unit
 
         if container := event.container:
             env.update({"JUJU_WORKLOAD_NAME": container.name})
@@ -262,6 +274,9 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path):
                 },
             )
 
+        if check_info := event.check_info:
+            env["JUJU_PEBBLE_CHECK_NAME"] = check_info.name
+
         if storage := event.storage:
             env.update({"JUJU_STORAGE_ID": f"{storage.name}/{storage.index}"})
 
@@ -272,8 +287,9 @@ def _get_event_env(self, state: "State", event: "Event", charm_root: Path):
                     "JUJU_SECRET_LABEL": secret.label or "",
                 },
             )
-            if event.name in ("secret_remove", "secret_expired"):
-                env["JUJU_SECRET_REVISION"] = str(secret.revision)
+            # Don't check truthiness because revision could be 0.
+            if event.secret_revision is not None:
+                env["JUJU_SECRET_REVISION"] = str(event.secret_revision)
 
         return env
 
@@ -337,7 +353,7 @@ def _virtual_charm_root(self):
         elif (
             not spec.is_autoloaded and any_metadata_files_present_in_charm_virtual_root
         ):
-            logger.warn(
+            logger.warning(
                 f"Some metadata files found in custom user-provided charm_root "
                 f"{charm_virtual_root} while you have passed meta, config or actions to "
                 f"Context.run(). "
@@ -378,15 +394,15 @@ def _close_storage(self, state: "State", temporary_charm_root: Path):
         """Now that we're done processing this event, read the charm state and expose it."""
         store = self._get_state_db(temporary_charm_root)
         deferred = store.get_deferred_events()
-        stored_state = store.get_stored_state()
-        return state.replace(deferred=deferred, stored_state=stored_state)
+        stored_state = store.get_stored_states()
+        return dataclasses.replace(state, deferred=deferred, stored_states=stored_state)
 
     @contextmanager
     def _exec_ctx(self, ctx: "Context"):
         """python 3.8 compatibility shim"""
         with self._virtual_charm_root() as temporary_charm_root:
-            # todo allow customizing capture_events
-            with capture_events(
+            # TODO: allow customising capture_events
+            with _capture_events(
                 include_deferred=ctx.capture_deferred_events,
                 include_framework=ctx.capture_framework_events,
             ) as captured:
@@ -396,7 +412,7 @@ def _exec_ctx(self, ctx: "Context"):
     def exec(
         self,
         state: "State",
-        event: "Event",
+        event: "_Event",
         context: "Context",
     ):
         """Runs an event with this state as initial state on a charm.
@@ -410,7 +426,7 @@ def exec(
         # todo consider forking out a real subprocess and do the mocking by
         #  mocking hook tool executables
 
-        from scenario.consistency_checker import check_consistency  # avoid cycles
+        from scenario._consistency_checker import check_consistency  # avoid cycles
 
         check_consistency(state, event, self._charm_spec, self._juju_version)
 
@@ -418,7 +434,7 @@ def exec(
         logger.info(f"Preparing to fire {event.name} on {charm_type.__name__}")
 
         # we make a copy to avoid mutating the input state
-        output_state = state.copy()
+        output_state = copy.deepcopy(state)
 
         logger.info(" - generating virtual charm root")
         with self._exec_ctx(context) as (temporary_charm_root, captured):
@@ -441,7 +457,8 @@ def exec(
                     state=output_state,
                     event=event,
                     context=context,
-                    charm_spec=self._charm_spec.replace(
+                    charm_spec=dataclasses.replace(
+                        self._charm_spec,
                         charm_type=self._wrap(charm_type),
                     ),
                 )
@@ -452,7 +469,7 @@ def exec(
                 # if the caller did not manually emit or commit: do that.
                 ops.finalize()
 
-            except NoObserverError:
+            except (NoObserverError, ActionFailed):
                 raise  # propagate along
             except Exception as e:
                 raise UncaughtCharmError(
@@ -471,3 +488,88 @@ def exec(
         context.emitted_events.extend(captured)
         logger.info("event dispatched. done.")
         context._set_output_state(output_state)
+
+
+_T = TypeVar("_T", bound=EventBase)
+
+
+@contextmanager
+def _capture_events(
+    *types: Type[EventBase],
+    include_framework=False,
+    include_deferred=True,
+):
+    """Capture all events of type `*types` (using instance checks).
+
+    Arguments exposed so that you can define your own fixtures if you want to.
+
+    Example::
+    >>> from ops.charm import StartEvent
+    >>> from scenario import Event, State
+    >>> from charm import MyCustomEvent, MyCharm  # noqa
+    >>>
+    >>> def test_my_event():
+    >>>     with capture_events(StartEvent, MyCustomEvent) as captured:
+    >>>         trigger(State(), ("start", MyCharm, meta=MyCharm.META)
+    >>>
+    >>>     assert len(captured) == 2
+    >>>     e1, e2 = captured
+    >>>     assert isinstance(e2, MyCustomEvent)
+    >>>     assert e2.custom_attr == 'foo'
+    """
+    allowed_types = types or (EventBase,)
+
+    captured = []
+    _real_emit = Framework._emit
+    _real_reemit = Framework.reemit
+
+    def _wrapped_emit(self, evt):
+        if not include_framework and isinstance(
+            evt,
+            (PreCommitEvent, CommitEvent, CollectStatusEvent),
+        ):
+            return _real_emit(self, evt)
+
+        if isinstance(evt, allowed_types):
+            # dump/undump the event to ensure any custom attributes are (re)set by restore()
+            evt.restore(evt.snapshot())
+            captured.append(evt)
+
+        return _real_emit(self, evt)
+
+    def _wrapped_reemit(self):
+        # Framework calls reemit() before emitting the main juju event. We intercept that call
+        # and capture all events in storage.
+
+        if not include_deferred:
+            return _real_reemit(self)
+
+        # load all notices from storage as events.
+        for event_path, _, _ in self._storage.notices():
+            event_handle = Handle.from_path(event_path)
+            try:
+                event = self.load_snapshot(event_handle)
+            except NoTypeError:
+                continue
+            event = typing.cast(EventBase, event)
+            event.deferred = False
+            self._forget(event)  # prevent tracking conflicts
+
+            if not include_framework and isinstance(
+                event,
+                (PreCommitEvent, CommitEvent),
+            ):
+                continue
+
+            if isinstance(event, allowed_types):
+                captured.append(event)
+
+        return _real_reemit(self)
+
+    Framework._emit = _wrapped_emit  # type: ignore
+    Framework.reemit = _wrapped_reemit  # type: ignore
+
+    yield captured
+
+    Framework._emit = _real_emit  # type: ignore
+    Framework.reemit = _real_reemit  # type: ignore
diff --git a/scenario/sequences.py b/scenario/sequences.py
deleted file mode 100644
index 49c01b33..00000000
--- a/scenario/sequences.py
+++ /dev/null
@@ -1,128 +0,0 @@
-#!/usr/bin/env python3
-# Copyright 2023 Canonical Ltd.
-# See LICENSE file for licensing details.
-import typing
-from itertools import chain
-from typing import Any, Callable, Dict, Iterable, Optional, TextIO, Type, Union
-
-from scenario import Context
-from scenario.logger import logger as scenario_logger
-from scenario.state import (
-    ATTACH_ALL_STORAGES,
-    BREAK_ALL_RELATIONS,
-    CREATE_ALL_RELATIONS,
-    DETACH_ALL_STORAGES,
-    Event,
-    State,
-)
-
-if typing.TYPE_CHECKING:  # pragma: no cover
-    from ops.testing import CharmType
-
-CharmMeta = Optional[Union[str, TextIO, dict]]
-logger = scenario_logger.getChild("scenario")
-
-
-def decompose_meta_event(meta_event: Event, state: State):
-    # decompose the meta event
-
-    if meta_event.name in [ATTACH_ALL_STORAGES, DETACH_ALL_STORAGES]:
-        logger.warning(f"meta-event {meta_event.name} not supported yet")
-        return
-
-    is_rel_created_meta_event = meta_event.name == CREATE_ALL_RELATIONS
-    is_rel_broken_meta_event = meta_event.name == BREAK_ALL_RELATIONS
-    if is_rel_broken_meta_event:
-        for relation in state.relations:
-            event = relation.broken_event
-            logger.debug(f"decomposed meta {meta_event.name}: {event}")
-            yield event, state.copy()
-    elif is_rel_created_meta_event:
-        for relation in state.relations:
-            event = relation.created_event
-            logger.debug(f"decomposed meta {meta_event.name}: {event}")
-            yield event, state.copy()
-    else:
-        raise RuntimeError(f"unknown meta-event {meta_event.name}")
-
-
-def generate_startup_sequence(state_template: State):
-    yield from chain(
-        decompose_meta_event(Event(ATTACH_ALL_STORAGES), state_template.copy()),
-        ((Event("start"), state_template.copy()),),
-        decompose_meta_event(Event(CREATE_ALL_RELATIONS), state_template.copy()),
-        (
-            (
-                Event(
-                    (
-                        "leader_elected"
-                        if state_template.leader
-                        else "leader_settings_changed"
-                    ),
-                ),
-                state_template.copy(),
-            ),
-            (Event("config_changed"), state_template.copy()),
-            (Event("install"), state_template.copy()),
-        ),
-    )
-
-
-def generate_teardown_sequence(state_template: State):
-    yield from chain(
-        decompose_meta_event(Event(BREAK_ALL_RELATIONS), state_template.copy()),
-        decompose_meta_event(Event(DETACH_ALL_STORAGES), state_template.copy()),
-        (
-            (Event("stop"), state_template.copy()),
-            (Event("remove"), state_template.copy()),
-        ),
-    )
-
-
-def generate_builtin_sequences(template_states: Iterable[State]):
-    for template_state in template_states:
-        yield from chain(
-            generate_startup_sequence(template_state),
-            generate_teardown_sequence(template_state),
-        )
-
-
-def check_builtin_sequences(
-    charm_type: Type["CharmType"],
-    meta: Optional[Dict[str, Any]] = None,
-    actions: Optional[Dict[str, Any]] = None,
-    config: Optional[Dict[str, Any]] = None,
-    template_state: State = None,
-    pre_event: Optional[Callable[["CharmType"], None]] = None,
-    post_event: Optional[Callable[["CharmType"], None]] = None,
-) -> object:
-    """Test that all the builtin startup and teardown events can fire without errors.
-
-    This will play both scenarios with and without leadership, and raise any exceptions.
-
-    This is a baseline check that in principle all charms (except specific use-cases perhaps),
-    should pass out of the box.
-
-    If you want to, you can inject more stringent state checks using the
-    pre_event and post_event hooks.
-    """
-
-    template = template_state if template_state else State()
-    out = []
-
-    for event, state in generate_builtin_sequences(
-        (
-            template.replace(leader=True),
-            template.replace(leader=False),
-        ),
-    ):
-        ctx = Context(charm_type=charm_type, meta=meta, actions=actions, config=config)
-        out.append(
-            ctx.run(
-                event,
-                state=state,
-                pre_event=pre_event,
-                post_event=post_event,
-            ),
-        )
-    return out
diff --git a/scenario/state.py b/scenario/state.py
index d8be99cb..e7190ea5 100644
--- a/scenario/state.py
+++ b/scenario/state.py
@@ -4,13 +4,12 @@
 
 """The core Scenario State object, and the components inside it."""
 
-import copy
 import dataclasses
 import datetime
 import inspect
+import random
 import re
-import warnings
-from collections import namedtuple
+import string
 from enum import Enum
 from itertools import chain
 from pathlib import Path, PurePosixPath
@@ -18,11 +17,18 @@
     TYPE_CHECKING,
     Any,
     Callable,
+    ClassVar,
     Dict,
+    Final,
+    FrozenSet,
     Generic,
+    Iterable,
     List,
     Literal,
+    Mapping,
+    NoReturn,
     Optional,
+    Sequence,
     Set,
     Tuple,
     Type,
@@ -36,22 +42,16 @@
 import yaml
 from ops import pebble
 from ops.charm import CharmBase, CharmEvents
+from ops.model import CloudCredential as CloudCredential_Ops
+from ops.model import CloudSpec as CloudSpec_Ops
 from ops.model import SecretRotate, StatusBase
 
+from scenario.errors import MetadataNotFoundError, StateValidationError
 from scenario.logger import logger as scenario_logger
 
-JujuLogLine = namedtuple("JujuLogLine", ("level", "message"))
-
 if TYPE_CHECKING:  # pragma: no cover
-    try:
-        from typing import Self  # type: ignore
-    except ImportError:
-        from typing_extensions import Self
-
     from scenario import Context
 
-PathLike = Union[str, Path]
-AnyRelation = Union["Relation", "PeerRelation", "SubordinateRelation"]
 AnyJson = Union[str, bool, dict, int, float, list]
 RawSecretRevisionContents = RawDataBagContents = Dict[str, str]
 UnitID = int
@@ -65,9 +65,9 @@
 BREAK_ALL_RELATIONS = "BREAK_ALL_RELATIONS"
 DETACH_ALL_STORAGES = "DETACH_ALL_STORAGES"
 
-ACTION_EVENT_SUFFIX = "_action"
+_ACTION_EVENT_SUFFIX = "_action"
 # all builtin events except secret events. They're special because they carry secret metadata.
-BUILTIN_EVENTS = {
+_BUILTIN_EVENTS = {
     "start",
     "stop",
     "install",
@@ -84,69 +84,127 @@
     "leader_settings_changed",
     "collect_metrics",
 }
-FRAMEWORK_EVENTS = {
+_FRAMEWORK_EVENTS = {
     "pre_commit",
     "commit",
     "collect_app_status",
     "collect_unit_status",
 }
-PEBBLE_READY_EVENT_SUFFIX = "_pebble_ready"
-PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX = "_pebble_custom_notice"
-RELATION_EVENTS_SUFFIX = {
+_PEBBLE_READY_EVENT_SUFFIX = "_pebble_ready"
+_PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX = "_pebble_custom_notice"
+_PEBBLE_CHECK_FAILED_EVENT_SUFFIX = "_pebble_check_failed"
+_PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX = "_pebble_check_recovered"
+_RELATION_EVENTS_SUFFIX = {
     "_relation_changed",
     "_relation_broken",
     "_relation_joined",
     "_relation_departed",
     "_relation_created",
 }
-STORAGE_EVENTS_SUFFIX = {
+_STORAGE_EVENTS_SUFFIX = {
     "_storage_detaching",
     "_storage_attached",
 }
 
-SECRET_EVENTS = {
+_SECRET_EVENTS = {
     "secret_changed",
     "secret_remove",
     "secret_rotate",
     "secret_expired",
 }
 
-META_EVENTS = {
-    "CREATE_ALL_RELATIONS": "_relation_created",
-    "BREAK_ALL_RELATIONS": "_relation_broken",
-    "DETACH_ALL_STORAGES": "_storage_detaching",
-    "ATTACH_ALL_STORAGES": "_storage_attached",
-}
 
+class ActionFailed(Exception):
+    """Raised at the end of the hook if the charm has called ``event.fail()``."""
 
-class StateValidationError(RuntimeError):
-    """Raised when individual parts of the State are inconsistent."""
+    def __init__(self, message: str, state: "State"):
+        self.message = message
+        self.state = state
 
-    # as opposed to InconsistentScenario error where the
-    # **combination** of several parts of the State are.
 
+# This can be replaced with the KW_ONLY dataclasses functionality in Python 3.10+.
+def _max_posargs(n: int):
+    class _MaxPositionalArgs:
+        """Raises TypeError when instantiating objects if arguments are not passed as keywords.
 
-class MetadataNotFoundError(RuntimeError):
-    """Raised when Scenario can't find a metadata.yaml file in the provided charm root."""
+        Looks for a `_max_positional_args` class attribute, which should be an int
+        indicating the maximum number of positional arguments that can be passed to
+        `__init__` (excluding `self`).
+        """
 
+        _max_positional_args = n
+
+        def __new__(cls, *args, **kwargs):
+            # inspect.signature guarantees the order of parameters is as
+            # declared, which aligns with dataclasses. Simpler ways of
+            # getting the arguments (like __annotations__) do not have that
+            # guarantee, although in practice it is the case.
+            parameters = inspect.signature(cls).parameters
+            required_args = [
+                name
+                for name in tuple(parameters)
+                if parameters[name].default is inspect.Parameter.empty
+                and name not in kwargs
+            ]
+            n_posargs = len(args)
+            max_n_posargs = cls._max_positional_args
+            kw_only = {
+                name
+                for name in tuple(parameters)[max_n_posargs:]
+                if not name.startswith("_")
+            }
+            if n_posargs > max_n_posargs:
+                raise TypeError(
+                    f"{cls.__name__} takes {max_n_posargs} positional "
+                    f"argument{'' if max_n_posargs == 1 else 's'} but "
+                    f"{n_posargs} {'was' if n_posargs == 1 else 'were'} "
+                    f"given. The following arguments are keyword-only: "
+                    f"{', '.join(kw_only)}",
+                ) from None
+            # Also check if there are just not enough arguments at all, because
+            # the default TypeError message will incorrectly describe some of
+            # the arguments as positional.
+            elif n_posargs < len(required_args):
+                required_pos = [
+                    f"'{arg}'"
+                    for arg in required_args[n_posargs:]
+                    if arg not in kw_only
+                ]
+                required_kw = {
+                    f"'{arg}'" for arg in required_args[n_posargs:] if arg in kw_only
+                }
+                if required_pos and required_kw:
+                    details = f"positional: {', '.join(required_pos)} and keyword: {', '.join(required_kw)} arguments"
+                elif required_pos:
+                    details = f"positional argument{'' if len(required_pos) == 1 else 's'}: {', '.join(required_pos)}"
+                else:
+                    details = f"keyword argument{'' if len(required_kw) == 1 else 's'}: {', '.join(required_kw)}"
+                raise TypeError(f"{cls.__name__} missing required {details}") from None
+            return super().__new__(cls)
+
+        def __reduce__(self):
+            # The default __reduce__ doesn't understand that some arguments have
+            # to be passed as keywords, so using the copy module fails.
+            attrs = cast(Dict[str, Any], super().__reduce__()[2])
+            return (lambda: self.__class__(**attrs), ())
 
-class BindFailedError(RuntimeError):
-    """Raised when Event.bind fails."""
+    return _MaxPositionalArgs
 
 
 @dataclasses.dataclass(frozen=True)
-class _DCBase:
-    def replace(self, *args, **kwargs):
-        """Produce a deep copy of this class, with some arguments replaced with new ones."""
-        return dataclasses.replace(self.copy(), *args, **kwargs)
+class JujuLogLine(_max_posargs(2)):
+    """An entry in the Juju debug-log."""
 
-    def copy(self) -> "Self":
-        """Produce a deep copy of this object."""
-        return copy.deepcopy(self)
+    level: str
+    """The level of the message, for example ``INFO`` or ``ERROR``."""
+    message: str
+    """The log message."""
 
 
 @dataclasses.dataclass(frozen=True)
-class CloudCredential:
+class CloudCredential(_max_posargs(0)):
+    __doc__ = ops.CloudCredential.__doc__
+
     auth_type: str
     """Authentication type."""
 
@@ -160,8 +218,8 @@ class CloudCredential:
     redacted: List[str] = dataclasses.field(default_factory=list)
     """A list of redacted generic cloud API secrets."""
 
-    def _to_ops(self) -> ops.CloudCredential:
-        return ops.CloudCredential(
+    def _to_ops(self) -> CloudCredential_Ops:
+        return CloudCredential_Ops(
             auth_type=self.auth_type,
             attributes=self.attributes,
             redacted=self.redacted,
@@ -169,7 +227,9 @@ def _to_ops(self) -> ops.CloudCredential:
 
 
 @dataclasses.dataclass(frozen=True)
-class CloudSpec:
+class CloudSpec(_max_posargs(1)):
+    __doc__ = ops.CloudSpec.__doc__
+
     type: str
     """Type of the cloud."""
 
@@ -200,8 +260,8 @@ class CloudSpec:
     is_controller_cloud: bool = False
     """If this is the cloud used by the controller."""
 
-    def _to_ops(self) -> ops.CloudSpec:
-        return ops.CloudSpec(
+    def _to_ops(self) -> CloudSpec_Ops:
+        return CloudSpec_Ops(
             type=self.type,
             name=self.name,
             region=self.region,
@@ -215,100 +275,86 @@ def _to_ops(self) -> ops.CloudSpec:
         )
 
 
+def _generate_secret_id():
+    # This doesn't account for collisions, but the odds are so low that it
+    # should not be possible in any realistic test run.
+    secret_id = "".join(
+        random.choice(string.ascii_lowercase + string.digits) for _ in range(20)
+    )
+    return f"secret:{secret_id}"
+
+
 @dataclasses.dataclass(frozen=True)
-class Secret(_DCBase):
-    id: str
-    # CAUTION: ops-created Secrets (via .add_secret()) will have a canonicalized
-    #  secret id (`secret:` prefix)
-    #  but user-created ones will not. Using post-init to patch it in feels bad, but requiring the user to
-    #  add the prefix manually every time seems painful as well.
-
-    # mapping from revision IDs to each revision's contents
-    contents: Dict[int, "RawSecretRevisionContents"]
-
-    # indicates if the secret is owned by THIS unit, THIS app or some other app/unit.
-    # if None, the implication is that the secret has been granted to this unit.
-    owner: Literal["unit", "app", None] = None
+class Secret(_max_posargs(1)):
+    """A Juju secret.
 
-    # deprecated! if a secret is not granted to this unit, omit it from State.secrets altogether.
-    # this attribute will be removed in Scenario 7+
-    granted: Any = "<DEPRECATED>"  # noqa
+    This class is used for both user and charm secrets.
+    """
 
-    # what revision is currently tracked by this charm. Only meaningful if owner=False
-    revision: int = 0
+    tracked_content: "RawSecretRevisionContents"
+    """The content of the secret that the charm is currently tracking.
+
+    This is the content the charm will receive with a
+    :meth:`ops.Secret.get_content` call."""
+    latest_content: Optional["RawSecretRevisionContents"] = None
+    """The content of the latest revision of the secret.
+
+    This is the content the charm will receive with a
+    :meth:`ops.Secret.peek_content` call."""
+
+    id: str = dataclasses.field(default_factory=_generate_secret_id)
+    """The Juju ID of the secret.
+
+    This is automatically assigned and should not usually need to be explicitly set.
+    """
+
+    owner: Literal["unit", "app", None] = None
+    """Indicates if the secret is owned by *this* unit, *this* application, or
+    another application/unit.
+
+    If None, the implication is that read access to the secret has been granted
+    to this unit.
+    """
 
-    # mapping from relation IDs to remote unit/apps to which this secret has been granted.
-    # Only applicable if owner
     remote_grants: Dict[int, Set[str]] = dataclasses.field(default_factory=dict)
+    """Mapping from relation IDs to remote units and applications to which this
+    secret has been granted."""
 
     label: Optional[str] = None
+    """A human-readable label the charm can use to retrieve the secret.
+
+    If this is set, it implies that the charm has previously set the label.
+    """
     description: Optional[str] = None
+    """A human-readable description of the secret."""
     expire: Optional[datetime.datetime] = None
+    """The time at which the secret will expire."""
     rotate: Optional[SecretRotate] = None
+    """The rotation policy for the secret."""
 
-    def __post_init__(self):
-        if self.granted != "<DEPRECATED>":
-            msg = (
-                "``state.Secret.granted`` is deprecated and will be removed in Scenario 7+. "
-                "If a Secret is not owned by the app/unit you are testing, nor has been granted to "
-                "it by the (remote) owner, then omit it from ``State.secrets`` altogether."
-            )
-            logger.warning(msg)
-            warnings.warn(msg, DeprecationWarning, stacklevel=2)
-
-        if self.owner == "application":
-            msg = (
-                "Secret.owner='application' is deprecated in favour of 'app' "
-                "and will be removed in Scenario 7+."
-            )
-            logger.warning(msg)
-            warnings.warn(msg, DeprecationWarning, stacklevel=2)
-
-            # bypass frozen dataclass
-            object.__setattr__(self, "owner", "app")
+    # what revision is currently tracked by this charm. Only meaningful if owner=False
+    _tracked_revision: int = 1
 
-    # consumer-only events
-    @property
-    def changed_event(self):
-        """Sugar to generate a secret-changed event."""
-        if self.owner:
-            raise ValueError(
-                "This unit will never receive secret-changed for a secret it owns.",
-            )
-        return Event("secret_changed", secret=self)
+    # what revision is the latest for this secret.
+    _latest_revision: int = 1
 
-    # owner-only events
-    @property
-    def rotate_event(self):
-        """Sugar to generate a secret-rotate event."""
-        if not self.owner:
-            raise ValueError(
-                "This unit will never receive secret-rotate for a secret it does not own.",
-            )
-        return Event("secret_rotate", secret=self)
+    def __hash__(self) -> int:
+        return hash(self.id)
 
-    @property
-    def expired_event(self):
-        """Sugar to generate a secret-expired event."""
-        if not self.owner:
-            raise ValueError(
-                "This unit will never receive secret-expired for a secret it does not own.",
-            )
-        return Event("secret_expired", secret=self)
+    def __post_init__(self):
+        if self.latest_content is None:
+            # bypass frozen dataclass
+            object.__setattr__(self, "latest_content", self.tracked_content)
 
-    @property
-    def remove_event(self):
-        """Sugar to generate a secret-remove event."""
-        if not self.owner:
-            raise ValueError(
-                "This unit will never receive secret-remove for a secret it does not own.",
-            )
-        return Event("secret_remove", secret=self)
+    def _set_label(self, label):
+        # bypass frozen dataclass
+        object.__setattr__(self, "label", label)
 
-    def _set_revision(self, revision: int):
-        """Set a new tracked revision."""
+    def _track_latest_revision(self):
+        """Set the current revision to the tracked revision."""
         # bypass frozen dataclass
-        object.__setattr__(self, "revision", revision)
+        object.__setattr__(self, "_tracked_revision", self._latest_revision)
+        object.__setattr__(self, "tracked_content", self.latest_content)
 
     def _update_metadata(
         self,
@@ -319,11 +365,13 @@ def _update_metadata(
         rotate: Optional[SecretRotate] = None,
     ):
         """Update the metadata."""
-        revision = max(self.contents.keys())
-        if content:
-            self.contents[revision + 1] = content
-
         # bypass frozen dataclass
+        object.__setattr__(self, "_latest_revision", self._latest_revision + 1)
+        # TODO: if this is done twice in the same hook, then Juju ignores the
+        # first call, it doesn't continue to update like this does.
+        # Fix when https://github.com/canonical/operator/issues/1288 is resolved.
+        if content:
+            object.__setattr__(self, "latest_content", content)
         if label:
             object.__setattr__(self, "label", label)
         if description:
@@ -336,20 +384,20 @@ def _update_metadata(
             object.__setattr__(self, "rotate", rotate)
 
 
-def normalize_name(s: str):
+def _normalise_name(s: str):
     """Event names, in Scenario, uniformly use underscores instead of dashes."""
     return s.replace("-", "_")
 
 
 @dataclasses.dataclass(frozen=True)
-class Address(_DCBase):
+class Address(_max_posargs(1)):
     """An address in a Juju network space."""
 
-    hostname: str
-    """A host name that maps to the address in :attr:`value`."""
     value: str
     """The IP address in the space."""
-    cidr: str
+    hostname: str = ""
+    """A host name that maps to the address in :attr:`value`."""
+    cidr: str = ""
     """The CIDR of the address in :attr:`value`."""
 
     @property
@@ -363,14 +411,17 @@ def address(self, value):
 
 
 @dataclasses.dataclass(frozen=True)
-class BindAddress(_DCBase):
+class BindAddress(_max_posargs(1)):
     """An address bound to a network interface in a Juju space."""
 
-    interface_name: str
     addresses: List[Address]
+    """The addresses in the space."""
+    interface_name: str = ""
+    """The name of the network interface."""
     mac_address: Optional[str] = None
+    """The MAC address of the interface."""
 
-    def hook_tool_output_fmt(self):
+    def _hook_tool_output_fmt(self):
         # dumps itself to dict in the same format the hook tool would
         # todo support for legacy (deprecated) `interfacename` and `macaddress` fields?
         dct = {
@@ -383,50 +434,47 @@ def hook_tool_output_fmt(self):
 
 
 @dataclasses.dataclass(frozen=True)
-class Network(_DCBase):
-    bind_addresses: List[BindAddress]
-    ingress_addresses: List[str]
-    egress_subnets: List[str]
+class Network(_max_posargs(2)):
+    """A Juju network space."""
+
+    binding_name: str
+    """The name of the network space."""
+    bind_addresses: List[BindAddress] = dataclasses.field(
+        default_factory=lambda: [BindAddress([Address("192.0.2.0")])],
+    )
+    """Addresses that the charm's application should bind to."""
+    ingress_addresses: List[str] = dataclasses.field(
+        default_factory=lambda: ["192.0.2.0"],
+    )
+    """Addresses other applications should use to connect to the unit."""
+    egress_subnets: List[str] = dataclasses.field(
+        default_factory=lambda: ["192.0.2.0/24"],
+    )
+    """Subnets that other units will see the charm connecting from."""
 
-    def hook_tool_output_fmt(self):
+    def __hash__(self) -> int:
+        return hash(self.binding_name)
+
+    def _hook_tool_output_fmt(self):
         # dumps itself to dict in the same format the hook tool would
         return {
-            "bind-addresses": [ba.hook_tool_output_fmt() for ba in self.bind_addresses],
+            "bind-addresses": [
+                ba._hook_tool_output_fmt() for ba in self.bind_addresses
+            ],
             "egress-subnets": self.egress_subnets,
             "ingress-addresses": self.ingress_addresses,
         }
 
-    @classmethod
-    def default(
-        cls,
-        private_address: str = "192.0.2.0",
-        hostname: str = "",
-        cidr: str = "",
-        interface_name: str = "",
-        mac_address: Optional[str] = None,
-        egress_subnets=("192.0.2.0/24",),
-        ingress_addresses=("192.0.2.0",),
-    ) -> "Network":
-        """Helper to create a minimal, heavily defaulted Network."""
-        return cls(
-            bind_addresses=[
-                BindAddress(
-                    interface_name=interface_name,
-                    mac_address=mac_address,
-                    addresses=[
-                        Address(hostname=hostname, value=private_address, cidr=cidr),
-                    ],
-                ),
-            ],
-            egress_subnets=list(egress_subnets),
-            ingress_addresses=list(ingress_addresses),
-        )
-
 
 _next_relation_id_counter = 1
 
 
-def next_relation_id(update=True):
+def _next_relation_id(*, update=True):
+    """Get the ID the next relation to be created will get.
+
+    Pass update=False if you're only inspecting it.
+    Pass update=True if you also want to bump it.
+    """
     global _next_relation_id_counter
     cur = _next_relation_id_counter
     if update:
@@ -435,15 +483,15 @@ def next_relation_id(update=True):
 
 
 @dataclasses.dataclass(frozen=True)
-class RelationBase(_DCBase):
+class RelationBase(_max_posargs(2)):
     endpoint: str
-    """Relation endpoint name. Must match some endpoint name defined in metadata.yaml."""
+    """Relation endpoint name. Must match some endpoint name defined in the metadata."""
 
     interface: Optional[str] = None
-    """Interface name. Must match the interface name attached to this endpoint in metadata.yaml.
-    If left empty, it will be automatically derived from metadata.yaml."""
+    """Interface name. Must match the interface name attached to this endpoint in the metadata.
+    If left empty, it will be automatically derived from the metadata."""
 
-    relation_id: int = dataclasses.field(default_factory=next_relation_id)
+    id: int = dataclasses.field(default_factory=_next_relation_id)
     """Juju relation ID. Every new Relation instance gets a unique one,
     if there's trouble, override."""
 
@@ -451,10 +499,18 @@ class RelationBase(_DCBase):
     """This application's databag for this relation."""
 
     local_unit_data: "RawDataBagContents" = dataclasses.field(
-        default_factory=lambda: DEFAULT_JUJU_DATABAG.copy(),
+        default_factory=lambda: _DEFAULT_JUJU_DATABAG.copy(),
     )
     """This unit's databag for this relation."""
 
+    @property
+    def relation_id(self) -> NoReturn:
+        """Use `.id` instead of `.relation_id`.
+
+        :private:
+        """
+        raise AttributeError("use .id instead of .relation_id")
+
     @property
     def _databags(self):
         """Yield all databags in this relation."""
@@ -483,6 +539,9 @@ def __post_init__(self):
         for databag in self._databags:
             self._validate_databag(databag)
 
+    def __hash__(self) -> int:
+        return hash(self.id)
+
     def _validate_databag(self, databag: dict):
         if not isinstance(databag, dict):
             raise StateValidationError(
@@ -495,49 +554,9 @@ def _validate_databag(self, databag: dict):
                     f"found a value of type {type(v)}",
                 )
 
-    @property
-    def changed_event(self) -> "Event":
-        """Sugar to generate a <this relation>-relation-changed event."""
-        return Event(
-            path=normalize_name(self.endpoint + "-relation-changed"),
-            relation=cast("AnyRelation", self),
-        )
-
-    @property
-    def joined_event(self) -> "Event":
-        """Sugar to generate a <this relation>-relation-joined event."""
-        return Event(
-            path=normalize_name(self.endpoint + "-relation-joined"),
-            relation=cast("AnyRelation", self),
-        )
-
-    @property
-    def created_event(self) -> "Event":
-        """Sugar to generate a <this relation>-relation-created event."""
-        return Event(
-            path=normalize_name(self.endpoint + "-relation-created"),
-            relation=cast("AnyRelation", self),
-        )
-
-    @property
-    def departed_event(self) -> "Event":
-        """Sugar to generate a <this relation>-relation-departed event."""
-        return Event(
-            path=normalize_name(self.endpoint + "-relation-departed"),
-            relation=cast("AnyRelation", self),
-        )
 
-    @property
-    def broken_event(self) -> "Event":
-        """Sugar to generate a <this relation>-relation-broken event."""
-        return Event(
-            path=normalize_name(self.endpoint + "-relation-broken"),
-            relation=cast("AnyRelation", self),
-        )
-
-
-_DEFAULT_IP = " 192.0.2.0"
-DEFAULT_JUJU_DATABAG = {
+_DEFAULT_IP = "192.0.2.0"
+_DEFAULT_JUJU_DATABAG = {
     "egress-subnets": _DEFAULT_IP,
     "ingress-address": _DEFAULT_IP,
     "private-address": _DEFAULT_IP,
@@ -558,10 +577,13 @@ class Relation(RelationBase):
     remote_app_data: "RawDataBagContents" = dataclasses.field(default_factory=dict)
     """The current content of the application databag."""
     remote_units_data: Dict["UnitID", "RawDataBagContents"] = dataclasses.field(
-        default_factory=lambda: {0: DEFAULT_JUJU_DATABAG.copy()},  # dedup
+        default_factory=lambda: {0: _DEFAULT_JUJU_DATABAG.copy()},  # dedup
     )
     """The current content of the databag for each unit in the relation."""
 
+    def __hash__(self) -> int:
+        return hash(self.id)
+
     @property
     def _remote_app_name(self) -> str:
         """Who is on the other end of this relation?"""
@@ -587,14 +609,22 @@ def _databags(self):
 
 @dataclasses.dataclass(frozen=True)
 class SubordinateRelation(RelationBase):
+    """A relation to share data between a subordinate and a principal charm."""
+
     remote_app_data: "RawDataBagContents" = dataclasses.field(default_factory=dict)
+    """The current content of the remote application databag."""
     remote_unit_data: "RawDataBagContents" = dataclasses.field(
-        default_factory=lambda: DEFAULT_JUJU_DATABAG.copy(),
+        default_factory=lambda: _DEFAULT_JUJU_DATABAG.copy(),
     )
+    """The current content of the remote unit databag."""
 
-    # app name and ID of the remote unit that *this unit* is attached to.
     remote_app_name: str = "remote"
+    """The name of the remote application that *this unit* is attached to."""
     remote_unit_id: int = 0
+    """The ID of the remote unit that *this unit* is attached to."""
+
+    def __hash__(self) -> int:
+        return hash(self.id)
 
     @property
     def _remote_unit_ids(self) -> Tuple[int]:
@@ -620,6 +650,7 @@ def _databags(self):
 
     @property
     def remote_unit_name(self) -> str:
+        """The full name of the remote unit, in the form ``remote/0``."""
         return f"{self.remote_app_name}/{self.remote_unit_id}"
 
 
@@ -628,11 +659,14 @@ class PeerRelation(RelationBase):
     """A relation to share data between units of the charm."""
 
     peers_data: Dict["UnitID", "RawDataBagContents"] = dataclasses.field(
-        default_factory=lambda: {0: DEFAULT_JUJU_DATABAG.copy()},
+        default_factory=lambda: {0: _DEFAULT_JUJU_DATABAG.copy()},
     )
     """Current contents of the peer databags."""
     # Consistency checks will validate that *this unit*'s ID is not in here.
 
+    def __hash__(self) -> int:
+        return hash(self.id)
+
     @property
     def _databags(self):
         """Yield all databags in this relation."""
@@ -659,7 +693,7 @@ def _random_model_name():
 
 
 @dataclasses.dataclass(frozen=True)
-class Model(_DCBase):
+class Model(_max_posargs(1)):
     """The Juju model in which the charm is deployed."""
 
     name: str = dataclasses.field(default_factory=_random_model_name)
@@ -694,33 +728,49 @@ def _generate_new_change_id():
 
 
 @dataclasses.dataclass(frozen=True)
-class ExecOutput:
+class Exec(_max_posargs(1)):
     """Mock data for simulated :meth:`ops.Container.exec` calls."""
 
+    command_prefix: Sequence[str]
     return_code: int = 0
-    """The return code of the process (0 is success)."""
+    """The return code of the process.
+
+    Use 0 to mock the process ending successfully, and other values for failure.
+    """
     stdout: str = ""
-    """Any content written to stdout by the process."""
+    """Any content written to stdout by the process.
+
+    Provide content that the real process would write to stdout, which can be
+    read by the charm.
+    """
     stderr: str = ""
-    """Any content written to stderr by the process."""
+    """Any content written to stderr by the process.
+
+    Provide content that the real process would write to stderr, which can be
+    read by the charm.
+    """
 
     # change ID: used internally to keep track of mocked processes
     _change_id: int = dataclasses.field(default_factory=_generate_new_change_id)
 
+    def __post_init__(self):
+        # The command prefix can be any sequence type, and a list is tidier to
+        # write when there's only one string. However, this object needs to be
+        # hashable, so can't contain a list. We 'freeze' the sequence to a tuple
+        # to support that.
+        object.__setattr__(self, "command_prefix", tuple(self.command_prefix))
+
     def _run(self) -> int:
         return self._change_id
 
 
-_ExecMock = Dict[Tuple[str, ...], ExecOutput]
-
-
 @dataclasses.dataclass(frozen=True)
-class Mount(_DCBase):
+class Mount(_max_posargs(0)):
     """Maps local files to a :class:`Container` filesystem."""
 
     location: Union[str, PurePosixPath]
     """The location inside of the container."""
-    src: Union[str, Path]
+    source: Union[str, Path]
     """The content to provide when the charm does :meth:`ops.Container.pull`."""
 
 
@@ -731,7 +781,12 @@ def _now_utc():
 _next_notice_id_counter = 1
 
 
-def next_notice_id(update=True):
+def _next_notice_id(*, update=True):
+    """Get the ID the next Pebble notice to be created will get.
+
+    Pass update=False if you're only inspecting it.
+    Pass update=True if you also want to bump it.
+    """
     global _next_notice_id_counter
     cur = _next_notice_id_counter
     if update:
@@ -740,7 +795,9 @@ def next_notice_id(update=True):
 
 
 @dataclasses.dataclass(frozen=True)
-class Notice(_DCBase):
+class Notice(_max_posargs(1)):
+    """A Pebble notice."""
+
     key: str
     """The notice key, a string that differentiates notices of this type.
 
@@ -748,7 +805,7 @@ class Notice(_DCBase):
     ``canonical.com/postgresql/backup`` or ``example.com/mycharm/notice``.
     """
 
-    id: str = dataclasses.field(default_factory=next_notice_id)
+    id: str = dataclasses.field(default_factory=_next_notice_id)
     """Unique ID for this notice."""
 
     user_id: Optional[int] = None
@@ -799,23 +856,45 @@ def _to_ops(self) -> pebble.Notice:
 
 
 @dataclasses.dataclass(frozen=True)
-class _BoundNotice(_DCBase):
-    notice: Notice
-    container: "Container"
+class CheckInfo(_max_posargs(1)):
+    """A health check for a Pebble workload container."""
 
-    @property
-    def event(self):
-        """Sugar to generate a <container's name>-pebble-custom-notice event for this notice."""
-        suffix = PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX
-        return Event(
-            path=normalize_name(self.container.name) + suffix,
-            container=self.container,
-            notice=self.notice,
+    name: str
+    """Name of the check."""
+
+    level: Optional[pebble.CheckLevel] = None
+    """Level of the check."""
+
+    status: pebble.CheckStatus = pebble.CheckStatus.UP
+    """Status of the check.
+
+    :attr:`ops.pebble.CheckStatus.UP` means the check is healthy (the number of
+    failures is fewer than the threshold), :attr:`ops.pebble.CheckStatus.DOWN`
+    means the check is unhealthy (the number of failures has reached the
+    threshold).
+    """
+
+    failures: int = 0
+    """Number of failures since the check last succeeded."""
+
+    threshold: int = 3
+    """Failure threshold.
+
+    This is how many consecutive failures for the check to be considered 'down'.
+    """
+
+    def _to_ops(self) -> pebble.CheckInfo:
+        return pebble.CheckInfo(
+            name=self.name,
+            level=self.level,
+            status=self.status,
+            failures=self.failures,
+            threshold=self.threshold,
         )
 
 
 @dataclasses.dataclass(frozen=True)
-class Container(_DCBase):
+class Container(_max_posargs(1)):
     """A Kubernetes container where a charm's workload runs."""
 
     name: str
@@ -831,16 +910,27 @@ class Container(_DCBase):
     # will be unknown. all that we can know is the resulting plan (the 'computed plan').
     _base_plan: dict = dataclasses.field(default_factory=dict)
     # We expect most of the user-facing testing to be covered by this 'layers' attribute,
-    # as all will be known when unit-testing.
+    # as it is all that will be known when unit-testing.
     layers: Dict[str, pebble.Layer] = dataclasses.field(default_factory=dict)
     """All :class:`ops.pebble.Layer` definitions that have already been added to the container."""
 
-    service_status: Dict[str, pebble.ServiceStatus] = dataclasses.field(
+    service_statuses: Dict[str, pebble.ServiceStatus] = dataclasses.field(
         default_factory=dict,
     )
     """The current status of each Pebble service running in the container."""
 
-    # when the charm runs `pebble.pull`, it will return .open() from one of these paths.
+    # this is how you specify the contents of the filesystem: suppose you want to express that your
+    # container has:
+    # - /home/foo/bar.py
+    # - /bin/bash
+    # - /bin/baz
+    #
+    # this becomes:
+    # mounts = {
+    #     'foo': Mount(location='/home/foo/', source=Path('/path/to/local/dir/containing/bar/py/'))
+    #     'bin': Mount(location='/bin/', source=Path('/path/to/local/dir/containing/bash/and/baz/'))
+    # }
+    # when the charm runs `pebble.pull`, it will return .open() from one of those paths.
     # when the charm pushes, it will either overwrite one of those paths (careful!) or it will
     # create a tempfile and insert its path in the mock filesystem tree
     mounts: Dict[str, Mount] = dataclasses.field(default_factory=dict)
@@ -855,30 +945,45 @@ class Container(_DCBase):
     this becomes::
 
         mounts = {
-            'foo': scenario.Mount('/home/foo',  Path('/path/to/local/dir/containing/bar/py/')),
-            'bin': Mount('/bin/', Path('/path/to/local/dir/containing/bash/and/baz/')),
+            'foo': Mount('/home/foo', pathlib.Path('/path/to/local/dir/containing/bar/py/')),
+            'bin': Mount('/bin/', pathlib.Path('/path/to/local/dir/containing/bash/and/baz/')),
         }
     """
 
-    exec_mock: _ExecMock = dataclasses.field(default_factory=dict)
+    execs: Iterable[Exec] = frozenset()
     """Simulate executing commands in the container.
 
-    Specify each command the charm might run in the container and a :class:`ExecOutput`
+    Specify each command the charm might run in the container and an :class:`Exec`
     containing its return code and any stdout/stderr.
 
     For example::
 
-        container = scenario.Container(
+        container = Container(
             name='foo',
-            exec_mock={
-                ('whoami', ): scenario.ExecOutput(return_code=0, stdout='ubuntu')
-                ('dig', '+short', 'canonical.com'):
-                    scenario.ExecOutput(return_code=0, stdout='185.125.190.20\\n185.125.190.21')
+            execs={
+                scenario.Exec(['whoami'], return_code=0, stdout='ubuntu'),
+                scenario.Exec(
+                    ['dig', '+short', 'canonical.com'],
+                    return_code=0,
+                    stdout='185.125.190.20\\n185.125.190.21',
+                ),
             }
         )
     """
 
     notices: List[Notice] = dataclasses.field(default_factory=list)
+    """Any Pebble notices that already exist in the container."""
+
+    check_infos: FrozenSet[CheckInfo] = frozenset()
+    """All Pebble health checks that have been added to the container."""
+
+    def __hash__(self) -> int:
+        return hash(self.name)
+
+    def __post_init__(self):
+        if not isinstance(self.execs, frozenset):
+            # Allow passing a regular set (or other iterable) of Execs.
+            object.__setattr__(self, "execs", frozenset(self.execs))
 
     def _render_services(self):
         # copied over from ops.testing._TestingPebbleClient._render_services()
@@ -893,9 +998,9 @@ def _render_services(self):
     def plan(self) -> pebble.Plan:
         """The 'computed' Pebble plan.
 
-        i.e. the base plan plus the layers that have been added on top.
-        You should run your assertions on this plan, not so much on the layers, as those are
-        input data.
+        This is the base plan plus the layers that have been added on top.
+        You should run your assertions on this plan, not so much on the layers,
+        as those are input data.
         """
 
         # copied over from ops.testing._TestingPebbleClient.get_plan().
@@ -920,7 +1025,7 @@ def services(self) -> Dict[str, pebble.ServiceInfo]:
                 # in pebble, it just returns "nothing matched" if there are 0 matches,
                 # but it ignores services it doesn't recognize
                 continue
-            status = self.service_status.get(name, pebble.ServiceStatus.INACTIVE)
+            status = self.service_statuses.get(name, pebble.ServiceStatus.INACTIVE)
             if service.startup == "":
                 startup = pebble.ServiceStartup.DISABLED
             else:
@@ -942,33 +1047,6 @@ def get_filesystem(self, ctx: "Context") -> Path:
         """
         return ctx._get_container_root(self.name)
 
-    @property
-    def pebble_ready_event(self):
-        """Sugar to generate a <this container's name>-pebble-ready event."""
-        if not self.can_connect:
-            logger.warning(
-                "you **can** fire pebble-ready while the container cannot connect, "
-                "but that's most likely not what you want.",
-            )
-        return Event(path=normalize_name(self.name + "-pebble-ready"), container=self)
-
-    def get_notice(
-        self,
-        key: str,
-        notice_type: pebble.NoticeType = pebble.NoticeType.CUSTOM,
-    ) -> _BoundNotice:
-        """Get a Pebble notice by key and type.
-
-        Raises:
-            KeyError: if the notice is not found.
-        """
-        for notice in self.notices:
-            if notice.key == key and notice.type == notice_type:
-                return _BoundNotice(notice, self)
-        raise KeyError(
-            f"{self.name} does not have a notice with key {key} and type {notice_type}",
-        )
-
 
 _RawStatusLiteral = Literal[
     "waiting",
@@ -981,26 +1059,20 @@ def get_notice(
 
 
 @dataclasses.dataclass(frozen=True)
-class _EntityStatus(_DCBase):
+class _EntityStatus:
     """This class represents StatusBase and should not be interacted with directly."""
 
-    # Why not use StatusBase directly? Because that's not json-serializable.
+    # Why not use StatusBase directly? Because that can't be used with
+    # dataclasses.asdict to then be JSON-serializable.
 
     name: _RawStatusLiteral
     message: str = ""
 
+    _entity_statuses: ClassVar[Dict[str, Type["_EntityStatus"]]] = {}
+
     def __eq__(self, other):
-        if isinstance(other, Tuple):
-            logger.warning(
-                "Comparing Status with Tuples is deprecated and will be removed soon.",
-            )
-            return (self.name, self.message) == other
         if isinstance(other, (StatusBase, _EntityStatus)):
             return (self.name, self.message) == (other.name, other.message)
-        logger.warning(
-            f"Comparing Status with {other} is not stable and will be forbidden soon."
-            f"Please compare with StatusBase directly.",
-        )
         return super().__eq__(other)
 
     def __repr__(self):
@@ -1009,69 +1081,228 @@ def __repr__(self):
             return f"{status_type_name}()"
         return f"{status_type_name}('{self.message}')"
 
+    @classmethod
+    def from_status_name(
+        cls,
+        name: _RawStatusLiteral,
+        message: str = "",
+    ) -> "_EntityStatus":
+        # Note that this won't work for UnknownStatus.
+        # All subclasses have a default 'name' attribute, but the type checker can't tell that.
+        return cls._entity_statuses[name](message=message)  # type:ignore
+
+    @classmethod
+    def from_ops(cls, obj: StatusBase) -> "_EntityStatus":
+        return cls.from_status_name(cast(_RawStatusLiteral, obj.name), obj.message)
+
+
+@dataclasses.dataclass(frozen=True, eq=False, repr=False)
+class UnknownStatus(_EntityStatus, ops.UnknownStatus):
+    __doc__ = ops.UnknownStatus.__doc__
+
+    name: Literal["unknown"] = "unknown"
 
-def _status_to_entitystatus(obj: StatusBase) -> _EntityStatus:
-    """Convert StatusBase to _EntityStatus."""
-    statusbase_subclass = type(StatusBase.from_name(obj.name, obj.message))
+    def __init__(self):
+        super().__init__(name=self.name)
 
-    class _MyClass(_EntityStatus, statusbase_subclass):
-        # Custom type inheriting from a specific StatusBase subclass to support instance checks:
-        #  isinstance(state.unit_status, ops.ActiveStatus)
-        pass
 
-    return _MyClass(cast(_RawStatusLiteral, obj.name), obj.message)
+@dataclasses.dataclass(frozen=True, eq=False, repr=False)
+class ErrorStatus(_EntityStatus, ops.ErrorStatus):
+    __doc__ = ops.ErrorStatus.__doc__
+
+    name: Literal["error"] = "error"
+
+    def __init__(self, message: str = ""):
+        super().__init__(name="error", message=message)
+
+
+@dataclasses.dataclass(frozen=True, eq=False, repr=False)
+class ActiveStatus(_EntityStatus, ops.ActiveStatus):
+    __doc__ = ops.ActiveStatus.__doc__
+
+    name: Literal["active"] = "active"
+
+    def __init__(self, message: str = ""):
+        super().__init__(name="active", message=message)
+
+
+@dataclasses.dataclass(frozen=True, eq=False, repr=False)
+class BlockedStatus(_EntityStatus, ops.BlockedStatus):
+    __doc__ = ops.BlockedStatus.__doc__
+
+    name: Literal["blocked"] = "blocked"
+
+    def __init__(self, message: str = ""):
+        super().__init__(name="blocked", message=message)
+
+
+@dataclasses.dataclass(frozen=True, eq=False, repr=False)
+class MaintenanceStatus(_EntityStatus, ops.MaintenanceStatus):
+    __doc__ = ops.MaintenanceStatus.__doc__
+
+    name: Literal["maintenance"] = "maintenance"
+
+    def __init__(self, message: str = ""):
+        super().__init__(name="maintenance", message=message)
+
+
+@dataclasses.dataclass(frozen=True, eq=False, repr=False)
+class WaitingStatus(_EntityStatus, ops.WaitingStatus):
+    __doc__ = ops.WaitingStatus.__doc__
+
+    name: Literal["waiting"] = "waiting"
+
+    def __init__(self, message: str = ""):
+        super().__init__(name="waiting", message=message)
+
+
+_EntityStatus._entity_statuses.update(
+    unknown=UnknownStatus,
+    error=ErrorStatus,
+    active=ActiveStatus,
+    blocked=BlockedStatus,
+    maintenance=MaintenanceStatus,
+    waiting=WaitingStatus,
+)
 
 
 @dataclasses.dataclass(frozen=True)
-class StoredState(_DCBase):
-    # /-separated Object names. E.g. MyCharm/MyCharmLib.
-    # if None, this StoredState instance is owned by the Framework.
-    owner_path: Optional[str]
+class StoredState(_max_posargs(1)):
+    """Represents unit-local state that persists across events."""
 
     name: str = "_stored"
+    """The attribute in the parent Object where the state is stored.
+
+    For example, ``_stored`` in this class::
+
+        class MyCharm(ops.CharmBase):
+            _stored = ops.StoredState()
+
+    """
+
+    owner_path: Optional[str] = None
+    """The path to the owner of this StoredState instance.
+
+    If None, the owner is the Framework. Otherwise, /-separated object names,
+    for example MyCharm/MyCharmLib.
+    """
+
+    # Ideally, the type here would be only marshallable types, rather than Any.
+    # However, it's complex to describe those types, since it's a recursive
+    # definition - even in TypeShed the _Marshallable type includes containers
+    # like list[Any], which seems to defeat the point.
     content: Dict[str, Any] = dataclasses.field(default_factory=dict)
+    """The content of the :class:`ops.StoredState` instance."""
 
-    data_type_name: str = "StoredStateData"
+    _data_type_name: str = "StoredStateData"
 
     @property
-    def handle_path(self):
-        return f"{self.owner_path or ''}/{self.data_type_name}[{self.name}]"
+    def _handle_path(self):
+        return f"{self.owner_path or ''}/{self._data_type_name}[{self.name}]"
+
+    def __hash__(self) -> int:
+        return hash(self._handle_path)
 
 
 _RawPortProtocolLiteral = Literal["tcp", "udp", "icmp"]
 
 
 @dataclasses.dataclass(frozen=True)
-class Port(_DCBase):
-    """Represents a port on the charm host."""
+class Port(_max_posargs(1)):
+    """Represents a port on the charm host.
+
+    Port objects should not be instantiated directly: use :class:`TCPPort`,
+    :class:`UDPPort`, or :class:`ICMPPort` instead.
+    """
 
-    protocol: _RawPortProtocolLiteral
-    """The protocol that data transferred over the port will use."""
     port: Optional[int] = None
     """The port to open. Required for TCP and UDP; not allowed for ICMP."""
 
+    protocol: _RawPortProtocolLiteral = "tcp"
+    """The protocol that data transferred over the port will use."""
+
     def __post_init__(self):
-        port = self.port
-        is_icmp = self.protocol == "icmp"
-        if port:
-            if is_icmp:
-                raise StateValidationError(
-                    "`port` arg not supported with `icmp` protocol",
-                )
-            if not (1 <= port <= 65535):
-                raise StateValidationError(
-                    f"`port` outside bounds [1:65535], got {port}",
-                )
-        elif not is_icmp:
+        if type(self) is Port:
+            raise RuntimeError(
+                "Port cannot be instantiated directly; "
+                "please use TCPPort, UDPPort, or ICMPPort",
+            )
+
+    def __eq__(self, other: object) -> bool:
+        if isinstance(other, (Port, ops.Port)):
+            return (self.protocol, self.port) == (other.protocol, other.port)
+        return False
+
+
+@dataclasses.dataclass(frozen=True)
+class TCPPort(Port):
+    """Represents a TCP port on the charm host."""
+
+    port: int
+    """The port to open."""
+    protocol: _RawPortProtocolLiteral = "tcp"
+    """The protocol that data transferred over the port will use.
+
+    :meta private:
+    """
+
+    def __post_init__(self):
+        super().__post_init__()
+        if not (1 <= self.port <= 65535):
             raise StateValidationError(
-                f"`port` arg required with `{self.protocol}` protocol",
+                f"`port` outside bounds [1:65535], got {self.port}",
             )
 
 
+@dataclasses.dataclass(frozen=True)
+class UDPPort(Port):
+    """Represents a UDP port on the charm host."""
+
+    port: int
+    """The port to open."""
+    protocol: _RawPortProtocolLiteral = "udp"
+    """The protocol that data transferred over the port will use.
+
+    :meta private:
+    """
+
+    def __post_init__(self):
+        super().__post_init__()
+        if not (1 <= self.port <= 65535):
+            raise StateValidationError(
+                f"`port` outside bounds [1:65535], got {self.port}",
+            )
+
+
+@dataclasses.dataclass(frozen=True)
+class ICMPPort(Port):
+    """Represents an ICMP port on the charm host."""
+
+    protocol: _RawPortProtocolLiteral = "icmp"
+    """The protocol that data transferred over the port will use.
+
+    :meta private:
+    """
+
+    _max_positional_args: Final = 0
+
+    def __post_init__(self):
+        super().__post_init__()
+        if self.port is not None:
+            raise StateValidationError("`port` cannot be set for `ICMPPort`")
+
+
+_port_cls_by_protocol = {
+    "tcp": TCPPort,
+    "udp": UDPPort,
+    "icmp": ICMPPort,
+}
+
+
 _next_storage_index_counter = 0  # storage indices start at 0
 
 
-def next_storage_index(update=True):
+def _next_storage_index(*, update=True):
     """Get the index (used to be called ID) the next Storage to be created will get.
 
     Pass update=False if you're only inspecting it.
@@ -1085,41 +1316,44 @@ def next_storage_index(update=True):
 
 
 @dataclasses.dataclass(frozen=True)
-class Storage(_DCBase):
-    """Represents an (attached!) storage made available to the charm container."""
+class Storage(_max_posargs(1)):
+    """Represents an (attached) storage made available to the charm container."""
 
     name: str
+    """The name of the storage, as found in the charm metadata."""
+
+    index: int = dataclasses.field(default_factory=_next_storage_index)
+    """The index of this storage instance.
 
-    index: int = dataclasses.field(default_factory=next_storage_index)
-    # Every new Storage instance gets a new one, if there's trouble, override.
+    For Kubernetes charms, this will always be 1. For machine charms, each new
+    Storage instance gets a new index."""
+
+    def __eq__(self, other: object) -> bool:
+        if isinstance(other, (Storage, ops.Storage)):
+            return (self.name, self.index) == (other.name, other.index)
+        return False
 
     def get_filesystem(self, ctx: "Context") -> Path:
         """Simulated filesystem root in this context."""
         return ctx._get_storage_root(self.name, self.index)
 
-    @property
-    def attached_event(self) -> "Event":
-        """Sugar to generate a <this storage>-storage-attached event."""
-        return Event(
-            path=normalize_name(self.name + "-storage-attached"),
-            storage=self,
-        )
 
-    @property
-    def detaching_event(self) -> "Event":
-        """Sugar to generate a <this storage>-storage-detached event."""
-        return Event(
-            path=normalize_name(self.name + "-storage-detaching"),
-            storage=self,
-        )
+@dataclasses.dataclass(frozen=True)
+class Resource(_max_posargs(0)):
+    """Represents a resource made available to the charm."""
+
+    name: str
+    """The name of the resource, as found in the charm metadata."""
+    path: Union[str, Path]
+    """A local path that will be provided to the charm as the content of the resource."""
 
 
 @dataclasses.dataclass(frozen=True)
-class State(_DCBase):
-    """Represents the juju-owned portion of a unit's state.
+class State(_max_posargs(0)):
+    """Represents the Juju-owned portion of a unit's state.
 
     Roughly speaking, it wraps all hook-tool- and pebble-mediated data a charm can access in its
-    lifecycle. For example, status-get will return data from `State.status`, is-leader will
+    lifecycle. For example, status-get will return data from `State.unit_status`, is-leader will
     return data from `State.leader`, and so on.
     """
 
@@ -1127,65 +1361,112 @@ class State(_DCBase):
         default_factory=dict,
     )
     """The present configuration of this charm."""
-    relations: List["AnyRelation"] = dataclasses.field(default_factory=list)
+    relations: Iterable["RelationBase"] = dataclasses.field(default_factory=frozenset)
     """All relations that currently exist for this charm."""
-    networks: Dict[str, Network] = dataclasses.field(default_factory=dict)
+    networks: Iterable[Network] = dataclasses.field(default_factory=frozenset)
     """Manual overrides for any relation and extra bindings currently provisioned for this charm.
     If a metadata-defined relation endpoint is not explicitly mapped to a Network in this field,
     it will be defaulted.
-    [CAVEAT: `extra-bindings` is a deprecated, regretful feature in juju/ops. For completeness we
-    support it, but use at your own risk.] If a metadata-defined extra-binding is left empty,
-    it will be defaulted.
+
+    .. warning::
+        `extra-bindings` is a deprecated, regretful feature in Juju/ops. For completeness we
+        support it, but use at your own risk. If a metadata-defined extra-binding is left empty,
+        it will be defaulted.
     """
-    containers: List[Container] = dataclasses.field(default_factory=list)
+    containers: Iterable[Container] = dataclasses.field(default_factory=frozenset)
     """All containers (whether they can connect or not) that this charm is aware of."""
-    storage: List[Storage] = dataclasses.field(default_factory=list)
-    """All ATTACHED storage instances for this charm.
+    storages: Iterable[Storage] = dataclasses.field(default_factory=frozenset)
+    """All **attached** storage instances for this charm.
+
     If a storage is not attached, omit it from this listing."""
 
     # we don't use sets to make json serialization easier
-    opened_ports: List[Port] = dataclasses.field(default_factory=list)
-    """Ports opened by juju on this charm."""
+    opened_ports: Iterable[Port] = dataclasses.field(default_factory=frozenset)
+    """Ports opened by Juju on this charm."""
     leader: bool = False
     """Whether this charm has leadership."""
     model: Model = Model()
     """The model this charm lives in."""
-    secrets: List[Secret] = dataclasses.field(default_factory=list)
+    secrets: Iterable[Secret] = dataclasses.field(default_factory=frozenset)
     """The secrets this charm has access to (as an owner, or as a grantee).
+
     The presence of a secret in this list entails that the charm can read it.
     Whether it can manage it or not depends on the individual secret's `owner` flag."""
-    resources: Dict[str, "PathLike"] = dataclasses.field(default_factory=dict)
-    """Mapping from resource name to path at which the resource can be found."""
+    resources: Iterable[Resource] = dataclasses.field(default_factory=frozenset)
+    """All resources that this charm can access."""
     planned_units: int = 1
     """Number of non-dying planned units that are expected to be running this application.
+
     Use with caution."""
 
-    # represents the OF's event queue. These events will be emitted before the event being
+    # Represents the OF's event queue. These events will be emitted before the event being
     # dispatched, and represent the events that had been deferred during the previous run.
     # If the charm defers any events during "this execution", they will be appended
     # to this list.
     deferred: List["DeferredEvent"] = dataclasses.field(default_factory=list)
     """Events that have been deferred on this charm by some previous execution."""
-    stored_state: List["StoredState"] = dataclasses.field(default_factory=list)
+    stored_states: Iterable["StoredState"] = dataclasses.field(
+        default_factory=frozenset,
+    )
     """Contents of a charm's stored state."""
 
-    # the current statuses. Will be cast to _EntitiyStatus in __post_init__
-    app_status: Union[StatusBase, _EntityStatus] = _EntityStatus("unknown")
+    # the current statuses.
+    app_status: _EntityStatus = UnknownStatus()
     """Status of the application."""
-    unit_status: Union[StatusBase, _EntityStatus] = _EntityStatus("unknown")
+    unit_status: _EntityStatus = UnknownStatus()
     """Status of the unit."""
     workload_version: str = ""
     """Workload version."""
 
     def __post_init__(self):
+        # Let people pass in the ops classes, and convert them to the appropriate Scenario classes.
         for name in ["app_status", "unit_status"]:
             val = getattr(self, name)
             if isinstance(val, _EntityStatus):
                 pass
             elif isinstance(val, StatusBase):
-                object.__setattr__(self, name, _status_to_entitystatus(val))
+                object.__setattr__(self, name, _EntityStatus.from_ops(val))
             else:
                 raise TypeError(f"Invalid status.{name}: {val!r}")
+        normalised_ports = [
+            Port(protocol=port.protocol, port=port.port)
+            if isinstance(port, ops.Port)
+            else port
+            for port in self.opened_ports
+        ]
+        if self.opened_ports != normalised_ports:
+            object.__setattr__(self, "opened_ports", normalised_ports)
+        normalised_storage = [
+            Storage(name=storage.name, index=storage.index)
+            if isinstance(storage, ops.Storage)
+            else storage
+            for storage in self.storages
+        ]
+        if self.storages != normalised_storage:
+            object.__setattr__(self, "storages", normalised_storage)
+
+        # ops.Container, ops.Model, ops.Relation, ops.Secret should not be instantiated by charmers.
+        # ops.Network does not have the relation name, so cannot be converted.
+        # ops.Resources does not contain the source of the resource, so cannot be converted.
+        # ops.StoredState is not convenient to initialise with data, so not useful here.
+
+        # It's convenient to pass a set, but we really want the attributes to be
+        # frozen sets to increase the immutability of State objects.
+        for name in [
+            "relations",
+            "containers",
+            "storages",
+            "networks",
+            "opened_ports",
+            "secrets",
+            "resources",
+            "stored_states",
+        ]:
+            val = getattr(self, name)
+            # It's ok to pass any iterable (of hashable objects), but you'll get
+            # a frozenset as the actual attribute.
+            if not isinstance(val, frozenset):
+                object.__setattr__(self, name, frozenset(val))
 
     def _update_workload_version(self, new_workload_version: str):
         """Update the current app version and record the previous one."""
@@ -1197,46 +1478,93 @@ def _update_workload_version(self, new_workload_version: str):
 
     def _update_status(
         self,
-        new_status: _RawStatusLiteral,
-        new_message: str = "",
+        new_status: _EntityStatus,
         is_app: bool = False,
     ):
-        """Update the current app/unit status and add the previous one to the history."""
+        """Update the current app/unit status."""
         name = "app_status" if is_app else "unit_status"
         # bypass frozen dataclass
-        object.__setattr__(self, name, _EntityStatus(new_status, new_message))
-
-    def with_can_connect(self, container_name: str, can_connect: bool) -> "State":
-        def replacer(container: Container):
-            if container.name == container_name:
-                return container.replace(can_connect=can_connect)
-            return container
-
-        ctrs = tuple(map(replacer, self.containers))
-        return self.replace(containers=ctrs)
+        object.__setattr__(self, name, new_status)
 
-    def with_leadership(self, leader: bool) -> "State":
-        return self.replace(leader=leader)
+    def _update_opened_ports(self, new_ports: FrozenSet[Port]):
+        """Update the current opened ports."""
+        # bypass frozen dataclass
+        object.__setattr__(self, "opened_ports", new_ports)
 
-    def with_unit_status(self, status: StatusBase) -> "State":
-        return self.replace(
-            status=dataclasses.replace(
-                cast(_EntityStatus, self.unit_status),
-                unit=_status_to_entitystatus(status),
-            ),
+    def _update_secrets(self, new_secrets: FrozenSet[Secret]):
+        """Update the current secrets."""
+        # bypass frozen dataclass
+        object.__setattr__(self, "secrets", new_secrets)
+
+    def get_container(self, container: str, /) -> Container:
+        """Get container from this State, based on its name."""
+        for state_container in self.containers:
+            if state_container.name == container:
+                return state_container
+        raise KeyError(f"container: {container} not found in the State")
+
+    def get_network(self, binding_name: str, /) -> Network:
+        """Get network from this State, based on its binding name."""
+        for network in self.networks:
+            if network.binding_name == binding_name:
+                return network
+        raise KeyError(f"network: {binding_name} not found in the State")
+
+    def get_secret(
+        self,
+        *,
+        id: Optional[str] = None,
+        label: Optional[str] = None,
+    ) -> Secret:
+        """Get secret from this State, based on the secret's id or label."""
+        if id is None and label is None:
+            raise ValueError("An id or label must be provided.")
+
+        for secret in self.secrets:
+            if (
+                (id and label and secret.id == id and secret.label == label)
+                or (id and label is None and secret.id == id)
+                or (id is None and label and secret.label == label)
+            ):
+                return secret
+        raise KeyError("secret: not found in the State")
+
+    def get_stored_state(
+        self,
+        stored_state: str,
+        /,
+        *,
+        owner_path: Optional[str] = None,
+    ) -> StoredState:
+        """Get stored state from this State, based on the stored state's name and owner_path."""
+        for ss in self.stored_states:
+            if ss.name == stored_state and ss.owner_path == owner_path:
+                return ss
+        raise ValueError(f"stored state: {stored_state} not found in the State")
+
+    def get_storage(
+        self,
+        storage: str,
+        /,
+        *,
+        index: Optional[int] = 0,
+    ) -> Storage:
+        """Get storage from this State, based on the storage's name and index."""
+        for state_storage in self.storages:
+            if state_storage.name == storage and storage.index == index:
+                return state_storage
+        raise ValueError(
+            f"storage: name={storage}, index={index} not found in the State",
         )
 
-    def get_container(self, container: Union[str, Container]) -> Container:
-        """Get container from this State, based on an input container or its name."""
-        container_name = (
-            container.name if isinstance(container, Container) else container
-        )
-        containers = [c for c in self.containers if c.name == container_name]
-        if not containers:
-            raise ValueError(f"container: {container_name} not found in the State")
-        return containers[0]
+    def get_relation(self, relation: int, /) -> "RelationBase":
+        """Get relation from this State, based on the relation's id."""
+        for state_relation in self.relations:
+            if state_relation.id == relation:
+                return state_relation
+        raise KeyError(f"relation: id={relation} not found in the State")
 
-    def get_relations(self, endpoint: str) -> Tuple["AnyRelation", ...]:
+    def get_relations(self, endpoint: str) -> Tuple["RelationBase", ...]:
         """Get all relations on this endpoint from the current state."""
 
         # we rather normalize the endpoint than worry about cursed metadata situations such as:
@@ -1244,34 +1572,13 @@ def get_relations(self, endpoint: str) -> Tuple["AnyRelation", ...]:
         #   foo-bar: ...
         #   foo_bar: ...
 
-        normalized_endpoint = normalize_name(endpoint)
+        normalized_endpoint = _normalise_name(endpoint)
         return tuple(
             r
             for r in self.relations
-            if normalize_name(r.endpoint) == normalized_endpoint
+            if _normalise_name(r.endpoint) == normalized_endpoint
         )
 
-    def get_storages(self, name: str) -> Tuple["Storage", ...]:
-        """Get all storages with this name."""
-        return tuple(s for s in self.storage if s.name == name)
-
-    # FIXME: not a great way to obtain a delta, but is "complete". todo figure out a better way.
-    def jsonpatch_delta(self, other: "State"):
-        try:
-            import jsonpatch  # type: ignore
-        except ModuleNotFoundError:
-            logger.error(
-                "cannot import jsonpatch: using the .delta() "
-                "extension requires jsonpatch to be installed."
-                "Fetch it with pip install jsonpatch.",
-            )
-            return NotImplemented
-        patch = jsonpatch.make_patch(
-            dataclasses.asdict(other),
-            dataclasses.asdict(self),
-        ).patch
-        return sort_patch(patch)
-
 
 def _is_valid_charmcraft_25_metadata(meta: Dict[str, Any]):
     # Check whether this dict has the expected mandatory metadata fields according to the
@@ -1288,7 +1595,7 @@ def _is_valid_charmcraft_25_metadata(meta: Dict[str, Any]):
 
 
 @dataclasses.dataclass(frozen=True)
-class _CharmSpec(_DCBase, Generic[CharmType]):
+class _CharmSpec(Generic[CharmType]):
     """Charm spec."""
 
     charm_type: Type[CharmBase]
@@ -1369,16 +1676,17 @@ def get_all_relations(self) -> List[Tuple[str, Dict[str, str]]]:
         )
 
 
-def sort_patch(patch: List[Dict], key=lambda obj: obj["path"] + obj["op"]):
-    return sorted(patch, key=key)
-
-
 @dataclasses.dataclass(frozen=True)
-class DeferredEvent(_DCBase):
+class DeferredEvent:
     """An event that has been deferred to run prior to the next Juju event.
 
-    In most cases, the :func:`deferred` function should be used to create a
-    ``DeferredEvent`` instance."""
+    Tests should not instantiate this class directly: use the `deferred` method
+    of the event instead. For example:
+
+        ctx = Context(MyCharm)
+        deferred_start = ctx.on.start().deferred(handler=MyCharm._on_start)
+        state = State(deferred=[deferred_start])
+    """
 
     handle_path: str
     owner: str
@@ -1387,6 +1695,12 @@ class DeferredEvent(_DCBase):
     # needs to be marshal.dumps-able.
     snapshot_data: Dict = dataclasses.field(default_factory=dict)
 
+    # It would be nicer if people could do something like:
+    #   `isinstance(state.deferred[0], ops.StartEvent)`
+    # than comparing with the string names, but there's only one `_Event`
+    # class in Scenario, and it also needs to be created from the context,
+    # which is not available here. For the ops classes, it's complex to create
+    # them because they need a Handle.
     @property
     def name(self):
         return self.handle_path.split("/")[-1].split("[")[0]
@@ -1413,7 +1727,7 @@ class _EventPath(str):
         type: _EventType
 
     def __new__(cls, string):
-        string = normalize_name(string)
+        string = _normalise_name(string)
         instance = super().__new__(cls, string)
 
         instance.name = name = string.split(".")[-1]
@@ -1432,38 +1746,42 @@ def __new__(cls, string):
 
     @staticmethod
     def _get_suffix_and_type(s: str) -> Tuple[str, _EventType]:
-        for suffix in RELATION_EVENTS_SUFFIX:
+        for suffix in _RELATION_EVENTS_SUFFIX:
             if s.endswith(suffix):
                 return suffix, _EventType.relation
 
-        if s.endswith(ACTION_EVENT_SUFFIX):
-            return ACTION_EVENT_SUFFIX, _EventType.action
+        if s.endswith(_ACTION_EVENT_SUFFIX):
+            return _ACTION_EVENT_SUFFIX, _EventType.action
 
-        if s in SECRET_EVENTS:
+        if s in _SECRET_EVENTS:
             return s, _EventType.secret
 
-        if s in FRAMEWORK_EVENTS:
+        if s in _FRAMEWORK_EVENTS:
             return s, _EventType.framework
 
         # Whether the event name indicates that this is a storage event.
-        for suffix in STORAGE_EVENTS_SUFFIX:
+        for suffix in _STORAGE_EVENTS_SUFFIX:
             if s.endswith(suffix):
                 return suffix, _EventType.storage
 
         # Whether the event name indicates that this is a workload event.
-        if s.endswith(PEBBLE_READY_EVENT_SUFFIX):
-            return PEBBLE_READY_EVENT_SUFFIX, _EventType.workload
-        if s.endswith(PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX):
-            return PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX, _EventType.workload
-
-        if s in BUILTIN_EVENTS:
+        if s.endswith(_PEBBLE_READY_EVENT_SUFFIX):
+            return _PEBBLE_READY_EVENT_SUFFIX, _EventType.workload
+        if s.endswith(_PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX):
+            return _PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX, _EventType.workload
+        if s.endswith(_PEBBLE_CHECK_FAILED_EVENT_SUFFIX):
+            return _PEBBLE_CHECK_FAILED_EVENT_SUFFIX, _EventType.workload
+        if s.endswith(_PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX):
+            return _PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX, _EventType.workload
+
+        if s in _BUILTIN_EVENTS:
             return "", _EventType.builtin
 
         return "", _EventType.custom
 
 
 @dataclasses.dataclass(frozen=True)
-class Event(_DCBase):
+class _Event:
     """A Juju, ops, or custom event that can be run against a charm.
 
     Typically, for simple events, the string name (e.g. ``install``) can be used,
@@ -1477,38 +1795,31 @@ class Event(_DCBase):
 
     storage: Optional["Storage"] = None
     """If this is a storage event, the storage it refers to."""
-    relation: Optional["AnyRelation"] = None
+    relation: Optional["RelationBase"] = None
     """If this is a relation event, the relation it refers to."""
     relation_remote_unit_id: Optional[int] = None
-    """If this is a relation event, the name of the remote unit the event is about."""
+    relation_departed_unit_id: Optional[int] = None
 
     secret: Optional[Secret] = None
     """If this is a secret event, the secret it refers to."""
 
+    # if this is a secret-removed or secret-expired event, the secret revision it refers to
+    secret_revision: Optional[int] = None
+
     container: Optional[Container] = None
     """If this is a workload (container) event, the container it refers to."""
 
     notice: Optional[Notice] = None
     """If this is a Pebble notice event, the notice it refers to."""
 
-    action: Optional["Action"] = None
-    """If this is an action event, the :class:`Action` it refers to."""
+    check_info: Optional[CheckInfo] = None
+    """If this is a Pebble check event, the check info it provides."""
 
-    # TODO: add other meta for
-    #  - secret events
-    #  - pebble?
-    #  - action?
+    action: Optional["_Action"] = None
+    """If this is an action event, the :class:`Action` it refers to."""
 
     _owner_path: List[str] = dataclasses.field(default_factory=list)
 
-    def __call__(self, remote_unit_id: Optional[int] = None) -> "Event":
-        if remote_unit_id and not self._is_relation_event:
-            raise ValueError(
-                "cannot pass param `remote_unit_id` to a "
-                "non-relation event constructor.",
-            )
-        return self.replace(relation_remote_unit_id=remote_unit_id)
-
     def __post_init__(self):
         path = _EventPath(self.path)
         # bypass frozen dataclass
@@ -1586,68 +1897,6 @@ def _is_builtin_event(self, charm_spec: "_CharmSpec"):
         # assuming it is owned by the charm, LOOKS LIKE that of a builtin event or not.
         return self._path.type is not _EventType.custom
 
-    def bind(self, state: State):
-        """Attach to this event the state component it needs.
-
-        For example, a relation event initialized without a Relation instance will search for
-        a suitable relation in the provided state and return a copy of itself with that
-        relation attached.
-
-        In case of ambiguity (e.g. multiple relations found on 'foo' for event
-        'foo-relation-changed', we pop a warning and bind the first one. Use with care!
-        """
-        entity_name = self._path.prefix
-
-        if self._is_workload_event and not self.container:
-            try:
-                container = state.get_container(entity_name)
-            except ValueError:
-                raise BindFailedError(f"no container found with name {entity_name}")
-            return self.replace(container=container)
-
-        if self._is_secret_event and not self.secret:
-            if len(state.secrets) < 1:
-                raise BindFailedError(f"no secrets found in state: cannot bind {self}")
-            if len(state.secrets) > 1:
-                raise BindFailedError(
-                    f"too many secrets found in state: cannot automatically bind {self}",
-                )
-            return self.replace(secret=state.secrets[0])
-
-        if self._is_storage_event and not self.storage:
-            storages = state.get_storages(entity_name)
-            if len(storages) < 1:
-                raise BindFailedError(
-                    f"no storages called {entity_name} found in state",
-                )
-            if len(storages) > 1:
-                logger.warning(
-                    f"too many storages called {entity_name}: binding to first one",
-                )
-            storage = storages[0]
-            return self.replace(storage=storage)
-
-        if self._is_relation_event and not self.relation:
-            ep_name = entity_name
-            relations = state.get_relations(ep_name)
-            if len(relations) < 1:
-                raise BindFailedError(f"no relations on {ep_name} found in state")
-            if len(relations) > 1:
-                logger.warning(f"too many relations on {ep_name}: binding to first one")
-            return self.replace(relation=relations[0])
-
-        if self._is_action_event and not self.action:
-            raise BindFailedError(
-                "cannot automatically bind action events: if the action has mandatory parameters "
-                "this would probably result in horrible, undebuggable failures downstream.",
-            )
-
-        else:
-            raise BindFailedError(
-                f"cannot bind {self}: only relation, secret, "
-                f"or workload events can be bound.",
-            )
-
     def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent:
         """Construct a DeferredEvent from this Event."""
         handler_repr = repr(handler)
@@ -1660,17 +1909,18 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent:
         owner_name, handler_name = match.groups()[0].split(".")[-2:]
         handle_path = f"{owner_name}/on/{self.name}[{event_id}]"
 
+        # Many events have no snapshot data: install, start, stop, remove, config-changed,
+        # upgrade-charm, pre-series-upgrade, post-series-upgrade, leader-elected,
+        # leader-settings-changed, collect-metrics
         snapshot_data = {}
 
         # fixme: at this stage we can't determine if the event is a builtin one or not; if it is
         #  not, then the coming checks are meaningless: the custom event could be named like a
         #  relation event but not *be* one.
         if self._is_workload_event:
-            # this is a WorkloadEvent. The snapshot:
-            container = cast(Container, self.container)
-            snapshot_data = {
-                "container_name": container.name,
-            }
+            # Enforced by the consistency checker, but for type checkers:
+            assert self.container is not None
+            snapshot_data["container_name"] = self.container.name
             if self.notice:
                 if hasattr(self.notice.type, "value"):
                     notice_type = cast(pebble.NoticeType, self.notice.type).value
@@ -1683,23 +1933,62 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent:
                         "notice_type": notice_type,
                     },
                 )
+            elif self.check_info:
+                snapshot_data["check_name"] = self.check_info.name
 
         elif self._is_relation_event:
-            # this is a RelationEvent.
-            relation = cast("AnyRelation", self.relation)
+            # Enforced by the consistency checker, but for type checkers:
+            assert self.relation is not None
+            relation = self.relation
             if isinstance(relation, PeerRelation):
                 # FIXME: relation.unit for peers should point to <this unit>, but we
                 #  don't have access to the local app name in this context.
                 remote_app = "local"
-            else:
+            elif isinstance(relation, (Relation, SubordinateRelation)):
                 remote_app = relation.remote_app_name
+            else:
+                raise RuntimeError(f"unexpected relation type: {relation!r}")
+
+            snapshot_data.update(
+                {
+                    "relation_name": relation.endpoint,
+                    "relation_id": relation.id,
+                    "app_name": remote_app,
+                },
+            )
+            if not self.name.endswith(("_created", "_broken")):
+                snapshot_data[
+                    "unit_name"
+                ] = f"{remote_app}/{self.relation_remote_unit_id}"
+            if self.name.endswith("_departed"):
+                snapshot_data[
+                    "departing_unit"
+                ] = f"{remote_app}/{self.relation_departed_unit_id}"
+
+        elif self._is_storage_event:
+            # Enforced by the consistency checker, but for type checkers:
+            assert self.storage is not None
+            snapshot_data.update(
+                {
+                    "storage_name": self.storage.name,
+                    "storage_index": self.storage.index,
+                    # "storage_location": str(self.storage.get_filesystem(self._context)),
+                },
+            )
 
-            snapshot_data = {
-                "relation_name": relation.endpoint,
-                "relation_id": relation.relation_id,
-                "app_name": remote_app,
-                "unit_name": f"{remote_app}/{self.relation_remote_unit_id}",
-            }
+        elif self._is_secret_event:
+            # Enforced by the consistency checker, but for type checkers:
+            assert self.secret is not None
+            snapshot_data.update(
+                {"secret_id": self.secret.id, "secret_label": self.secret.label},
+            )
+            if self.name.endswith(("_remove", "_expired")):
+                snapshot_data["secret_revision"] = self.secret_revision
+
+        elif self._is_action_event:
+            # Enforced by the consistency checker, but for type checkers:
+            assert self.action is not None
+            snapshot_data["id"] = self.action.id
 
         return DeferredEvent(
             handle_path,
@@ -1712,7 +2001,12 @@ def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent:
 _next_action_id_counter = 1
 
 
-def next_action_id(update=True):
+def _next_action_id(*, update=True):
+    """Get the ID the next action to be created will get.
+
+    Pass update=False if you're only inspecting it.
+    Pass update=True if you also want to bump it.
+    """
     global _next_action_id_counter
     cur = _next_action_id_counter
     if update:
@@ -1723,44 +2017,28 @@ def next_action_id(update=True):
 
 
 @dataclasses.dataclass(frozen=True)
-class Action(_DCBase):
+class _Action(_max_posargs(1)):
     """A ``juju run`` command.
 
     Used to simulate ``juju run``, passing in any parameters. For example::
 
         def test_backup_action():
-            action = scenario.Action('do_backup', params={'filename': 'foo'})
             ctx = scenario.Context(MyCharm)
-            out: scenario.ActionOutput = ctx.run_action(action, scenario.State())
+            state = ctx.run(
+                ctx.on.action('do_backup', params={'filename': 'foo'}),
+                scenario.State()
+            )
+            assert ctx.action_results == ...
     """
 
     name: str
     """Juju action name, as found in the charm metadata."""
 
-    params: Dict[str, "AnyJson"] = dataclasses.field(default_factory=dict)
+    params: Mapping[str, "AnyJson"] = dataclasses.field(default_factory=dict)
     """Parameter values passed to the action."""
 
-    id: str = dataclasses.field(default_factory=next_action_id)
+    id: str = dataclasses.field(default_factory=_next_action_id)
     """Juju action ID.
 
     Every action invocation is automatically assigned a new one. Override in
     the rare cases where a specific ID is required."""
-
-    @property
-    def event(self) -> Event:
-        """Helper to generate an action event from this action."""
-        return Event(self.name + ACTION_EVENT_SUFFIX, action=self)
-
-
-def deferred(
-    event: Union[str, Event],
-    handler: Callable,
-    event_id: int = 1,
-    relation: Optional["Relation"] = None,
-    container: Optional["Container"] = None,
-    notice: Optional["Notice"] = None,
-):
-    """Construct a DeferredEvent from an Event or an event name."""
-    if isinstance(event, str):
-        event = Event(event, relation=relation, container=container, notice=notice)
-    return event.deferred(handler=handler, event_id=event_id)
diff --git a/scenario/strategies.py b/scenario/strategies.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tox.ini b/tox.ini
index 30f40fca..d3155731 100644
--- a/tox.ini
+++ b/tox.ini
@@ -2,10 +2,9 @@
 requires =
     tox>=4.2
 env_list =
+    py312
     py311
     py38
-    py37
-    py36
     unit
     lint
     lint-tests
@@ -44,7 +43,7 @@ commands =
 description = Static typing checks.
 skip_install = true
 deps =
-    ops
+    ops~=2.15
     pyright==1.1.347
 commands =
     pyright scenario
@@ -57,7 +56,7 @@ deps =
     coverage[toml]
     isort
 commands =
-    black --check {[vars]tst_path} {[vars]src_path}
+    black --check {[vars]tst_path}
     isort --check-only --profile black {[vars]tst_path}
 
 [testenv:fmt]
@@ -83,3 +82,19 @@ commands_pre =
     pip-sync {toxinidir}/docs/requirements.txt
 commands =
     sphinx-build -W --keep-going docs/ docs/_build/html
+
+[testenv:test-readme]
+description = Test code snippets in the README.
+skip_install = true
+allowlist_externals =
+    mkdir
+    cp
+deps =
+    -e .
+    pytest
+    pytest-markdown-docs
+commands =
+    mkdir -p {envtmpdir}/test-readme
+    cp {toxinidir}/README.md {envtmpdir}/test-readme/README.md
+    cp {toxinidir}/tests/readme-conftest.py {envtmpdir}/test-readme/conftest.py
+    pytest -v --tb native --log-cli-level=INFO -s {posargs} --markdown-docs {envtmpdir}/test-readme/README.md


Test patch:
diff --git a/tests/helpers.py b/tests/helpers.py
index a8b2f551..5ceffa9d 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -1,3 +1,4 @@
+import dataclasses
 import logging
 from pathlib import Path
 from typing import (
@@ -5,38 +6,38 @@
     Any,
     Callable,
     Dict,
+    List,
     Optional,
-    Sequence,
     Type,
     TypeVar,
     Union,
 )
 
-from scenario.context import DEFAULT_JUJU_VERSION, Context
+import jsonpatch
+
+from scenario.context import _DEFAULT_JUJU_VERSION, Context
 
 if TYPE_CHECKING:  # pragma: no cover
     from ops.testing import CharmType
 
-    from scenario.state import Event, State
+    from scenario.state import State, _Event
 
     _CT = TypeVar("_CT", bound=Type[CharmType])
 
-    PathLike = Union[str, Path]
-
 logger = logging.getLogger()
 
 
 def trigger(
     state: "State",
-    event: Union["Event", str],
+    event: Union[str, "_Event"],
     charm_type: Type["CharmType"],
     pre_event: Optional[Callable[["CharmType"], None]] = None,
     post_event: Optional[Callable[["CharmType"], None]] = None,
     meta: Optional[Dict[str, Any]] = None,
     actions: Optional[Dict[str, Any]] = None,
     config: Optional[Dict[str, Any]] = None,
-    charm_root: Optional["PathLike"] = None,
-    juju_version: str = DEFAULT_JUJU_VERSION,
+    charm_root: Optional[Union[str, Path]] = None,
+    juju_version: str = _DEFAULT_JUJU_VERSION,
 ) -> "State":
     ctx = Context(
         charm_type=charm_type,
@@ -46,9 +47,42 @@ def trigger(
         charm_root=charm_root,
         juju_version=juju_version,
     )
-    return ctx.run(
-        event,
-        state=state,
-        pre_event=pre_event,
-        post_event=post_event,
-    )
+    if isinstance(event, str):
+        if event.startswith("relation_"):
+            assert len(state.relations) == 1, "shortcut only works with one relation"
+            event = getattr(ctx.on, event)(tuple(state.relations)[0])
+        elif event.startswith("pebble_"):
+            assert len(state.containers) == 1, "shortcut only works with one container"
+            event = getattr(ctx.on, event)(tuple(state.containers)[0])
+        else:
+            event = getattr(ctx.on, event)()
+    with ctx(event, state=state) as mgr:
+        if pre_event:
+            pre_event(mgr.charm)
+        state_out = mgr.run()
+        if post_event:
+            post_event(mgr.charm)
+    return state_out
+
+
+def jsonpatch_delta(self, other: "State"):
+    dict_other = dataclasses.asdict(other)
+    dict_self = dataclasses.asdict(self)
+    for attr in (
+        "relations",
+        "containers",
+        "storages",
+        "opened_ports",
+        "secrets",
+        "resources",
+        "stored_states",
+        "networks",
+    ):
+        dict_other[attr] = [dataclasses.asdict(o) for o in dict_other[attr]]
+        dict_self[attr] = [dataclasses.asdict(o) for o in dict_self[attr]]
+    patch = jsonpatch.make_patch(dict_other, dict_self).patch
+    return sort_patch(patch)
+
+
+def sort_patch(patch: List[Dict], key=lambda obj: obj["path"] + obj["op"]):
+    return sorted(patch, key=key)
diff --git a/tests/readme-conftest.py b/tests/readme-conftest.py
new file mode 100644
index 00000000..d125b930
--- /dev/null
+++ b/tests/readme-conftest.py
@@ -0,0 +1,56 @@
+"""pytest configuration for testing the README"""
+
+import ops
+
+import scenario
+
+
+def pytest_markdown_docs_globals():
+    class MyCharm(ops.CharmBase):
+        META = {"name": "mycharm", "storage": {"foo": {"type": "filesystem"}}}
+
+    class SimpleCharm(ops.CharmBase):
+        META = {"name": "simplecharm"}
+
+        def __init__(self, framework: ops.Framework):
+            super().__init__(framework)
+            framework.observe(self.on.start, self._on_start)
+
+        def _on_start(self, _: ops.StartEvent):
+            pass
+
+    class HistoryCharm(ops.CharmBase):
+        META = {"name": "historycharm"}
+
+        def __init__(self, framework: ops.Framework):
+            super().__init__(framework)
+            framework.observe(self.on.start, self._on_start)
+
+        def _on_start(self, _: ops.StartEvent):
+            self.unit.set_workload_version("1")
+            self.unit.set_workload_version("1.2")
+            self.unit.set_workload_version("1.5")
+            self.unit.set_workload_version("2.0")
+
+    class PortCharm(ops.CharmBase):
+        META = {"name": "portcharm"}
+
+        def __init__(self, framework: ops.Framework):
+            super().__init__(framework)
+            framework.observe(self.on.start, self._on_start)
+            framework.observe(self.on.stop, self._on_stop)
+
+        def _on_start(self, _: ops.StartEvent):
+            self.unit.open_port(protocol="tcp", port=42)
+
+        def _on_stop(self, _: ops.StopEvent):
+            self.unit.close_port(protocol="tcp", port=42)
+
+    return {
+        "ops": ops,
+        "scenario": scenario,
+        "MyCharm": MyCharm,
+        "HistoryCharm": HistoryCharm,
+        "PortCharm": PortCharm,
+        "SimpleCharm": SimpleCharm,
+    }
diff --git a/tests/test_charm_spec_autoload.py b/tests/test_charm_spec_autoload.py
index 89a9cfdf..57b93a31 100644
--- a/tests/test_charm_spec_autoload.py
+++ b/tests/test_charm_spec_autoload.py
@@ -115,7 +115,7 @@ def test_meta_autoload(tmp_path, legacy):
         meta={"type": "charm", "name": "foo", "summary": "foo", "description": "foo"},
     ) as charm:
         ctx = Context(charm)
-        ctx.run("start", State())
+        ctx.run(ctx.on.start(), State())
 
 
 @pytest.mark.parametrize("legacy", (True, False))
@@ -143,7 +143,8 @@ def test_relations_ok(tmp_path, legacy):
         },
     ) as charm:
         # this would fail if there were no 'cuddles' relation defined in meta
-        Context(charm).run("start", State(relations=[Relation("cuddles")]))
+        ctx = Context(charm)
+        ctx.run(ctx.on.start(), State(relations={Relation("cuddles")}))
 
 
 @pytest.mark.parametrize("legacy", (True, False))
@@ -160,6 +161,7 @@ def test_config_defaults(tmp_path, legacy):
         config={"options": {"foo": {"type": "bool", "default": True}}},
     ) as charm:
         # this would fail if there were no 'cuddles' relation defined in meta
-        with Context(charm).manager("start", State()) as mgr:
+        ctx = Context(charm)
+        with ctx(ctx.on.start(), State()) as mgr:
             mgr.run()
             assert mgr.charm.config["foo"] is True
diff --git a/tests/test_consistency_checker.py b/tests/test_consistency_checker.py
index 707cc7f4..e585d10e 100644
--- a/tests/test_consistency_checker.py
+++ b/tests/test_consistency_checker.py
@@ -1,25 +1,31 @@
+import dataclasses
+
 import pytest
 from ops.charm import CharmBase
 
-from scenario import Model
-from scenario.consistency_checker import check_consistency
-from scenario.runtime import InconsistentScenarioError
+from scenario._consistency_checker import check_consistency
+from scenario.context import Context
+from scenario.errors import InconsistentScenarioError
 from scenario.state import (
-    RELATION_EVENTS_SUFFIX,
-    Action,
+    _RELATION_EVENTS_SUFFIX,
+    CheckInfo,
     CloudCredential,
     CloudSpec,
     Container,
-    Event,
+    Exec,
+    Model,
     Network,
     Notice,
     PeerRelation,
     Relation,
+    Resource,
     Secret,
     State,
     Storage,
+    StoredState,
     SubordinateRelation,
     _CharmSpec,
+    _Event,
 )
 
 
@@ -29,7 +35,7 @@ class MyCharm(CharmBase):
 
 def assert_inconsistent(
     state: "State",
-    event: "Event",
+    event: "_Event",
     charm_spec: "_CharmSpec",
     juju_version="3.0",
 ):
@@ -39,7 +45,7 @@ def assert_inconsistent(
 
 def assert_consistent(
     state: "State",
-    event: "Event",
+    event: "_Event",
     charm_spec: "_CharmSpec",
     juju_version="3.0",
 ):
@@ -48,7 +54,7 @@ def assert_consistent(
 
 def test_base():
     state = State()
-    event = Event("update_status")
+    event = _Event("update_status")
     spec = _CharmSpec(MyCharm, {})
     assert_consistent(state, event, spec)
 
@@ -56,54 +62,108 @@ def test_base():
 def test_workload_event_without_container():
     assert_inconsistent(
         State(),
-        Event("foo-pebble-ready", container=Container("foo")),
+        _Event("foo-pebble-ready", container=Container("foo")),
         _CharmSpec(MyCharm, {}),
     )
     assert_consistent(
-        State(containers=[Container("foo")]),
-        Event("foo-pebble-ready", container=Container("foo")),
+        State(containers={Container("foo")}),
+        _Event("foo-pebble-ready", container=Container("foo")),
         _CharmSpec(MyCharm, {"containers": {"foo": {}}}),
     )
     assert_inconsistent(
         State(),
-        Event("foo-pebble-custom-notice", container=Container("foo")),
+        _Event("foo-pebble-custom-notice", container=Container("foo")),
         _CharmSpec(MyCharm, {}),
     )
     notice = Notice("example.com/foo")
     assert_consistent(
-        State(containers=[Container("foo", notices=[notice])]),
-        Event("foo-pebble-custom-notice", container=Container("foo"), notice=notice),
+        State(containers={Container("foo", notices=[notice])}),
+        _Event("foo-pebble-custom-notice", container=Container("foo"), notice=notice),
+        _CharmSpec(MyCharm, {"containers": {"foo": {}}}),
+    )
+    assert_inconsistent(
+        State(containers={Container("foo")}),
+        _Event("foo-pebble-custom-notice", container=Container("foo"), notice=notice),
+        _CharmSpec(MyCharm, {"containers": {"foo": {}}}),
+    )
+    check = CheckInfo("http-check")
+    assert_consistent(
+        State(containers={Container("foo", check_infos={check})}),
+        _Event("foo-pebble-check-failed", container=Container("foo"), check_info=check),
         _CharmSpec(MyCharm, {"containers": {"foo": {}}}),
     )
     assert_inconsistent(
-        State(containers=[Container("foo")]),
-        Event("foo-pebble-custom-notice", container=Container("foo"), notice=notice),
+        State(containers={Container("foo")}),
+        _Event("foo-pebble-check-failed", container=Container("foo"), check_info=check),
+        _CharmSpec(MyCharm, {"containers": {"foo": {}}}),
+    )
+    assert_consistent(
+        State(containers={Container("foo", check_infos={check})}),
+        _Event(
+            "foo-pebble-check-recovered", container=Container("foo"), check_info=check
+        ),
         _CharmSpec(MyCharm, {"containers": {"foo": {}}}),
     )
+    assert_inconsistent(
+        State(containers={Container("foo")}),
+        _Event(
+            "foo-pebble-check-recovered", container=Container("foo"), check_info=check
+        ),
+        _CharmSpec(MyCharm, {"containers": {"foo": {}}}),
+    )
+    # Ensure the check is in the correct container.
+    assert_inconsistent(
+        State(containers={Container("foo", check_infos={check}), Container("bar")}),
+        _Event(
+            "foo-pebble-check-recovered", container=Container("bar"), check_info=check
+        ),
+        _CharmSpec(MyCharm, {"containers": {"foo": {}, "bar": {}}}),
+    )
+    assert_inconsistent(
+        State(containers={Container("foo", check_infos={check}), Container("bar")}),
+        _Event(
+            "bar-pebble-check-recovered", container=Container("bar"), check_info=check
+        ),
+        _CharmSpec(MyCharm, {"containers": {"foo": {}, "bar": {}}}),
+    )
 
 
 def test_container_meta_mismatch():
     assert_inconsistent(
-        State(containers=[Container("bar")]),
-        Event("foo"),
+        State(containers={Container("bar")}),
+        _Event("foo"),
         _CharmSpec(MyCharm, {"containers": {"baz": {}}}),
     )
     assert_consistent(
-        State(containers=[Container("bar")]),
-        Event("foo"),
+        State(containers={Container("bar")}),
+        _Event("foo"),
         _CharmSpec(MyCharm, {"containers": {"bar": {}}}),
     )
 
 
 def test_container_in_state_but_no_container_in_meta():
     assert_inconsistent(
-        State(containers=[Container("bar")]),
-        Event("foo"),
+        State(containers={Container("bar")}),
+        _Event("foo"),
         _CharmSpec(MyCharm, {}),
     )
     assert_consistent(
-        State(containers=[Container("bar")]),
-        Event("foo"),
+        State(containers={Container("bar")}),
+        _Event("foo"),
+        _CharmSpec(MyCharm, {"containers": {"bar": {}}}),
+    )
+
+
+def test_container_not_in_state():
+    container = Container("bar")
+    assert_inconsistent(
+        State(),
+        _Event("bar_pebble_ready", container=container),
+        _CharmSpec(MyCharm, {"containers": {"bar": {}}}),
+    )
+    assert_consistent(
+        State(containers={container}),
+        _Event("bar_pebble_ready", container=container),
         _CharmSpec(MyCharm, {"containers": {"bar": {}}}),
     )
 
@@ -111,36 +171,38 @@ def test_container_in_state_but_no_container_in_meta():
 def test_evt_bad_container_name():
     assert_inconsistent(
         State(),
-        Event("foo-pebble-ready", container=Container("bar")),
+        _Event("foo-pebble-ready", container=Container("bar")),
         _CharmSpec(MyCharm, {}),
     )
     assert_consistent(
-        State(containers=[Container("bar")]),
-        Event("bar-pebble-ready", container=Container("bar")),
+        State(containers={Container("bar")}),
+        _Event("bar-pebble-ready", container=Container("bar")),
         _CharmSpec(MyCharm, {"containers": {"bar": {}}}),
     )
 
 
-@pytest.mark.parametrize("suffix", RELATION_EVENTS_SUFFIX)
+@pytest.mark.parametrize("suffix", _RELATION_EVENTS_SUFFIX)
 def test_evt_bad_relation_name(suffix):
     assert_inconsistent(
         State(),
-        Event(f"foo{suffix}", relation=Relation("bar")),
+        _Event(f"foo{suffix}", relation=Relation("bar")),
         _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "xxx"}}}),
     )
+    relation = Relation("bar")
     assert_consistent(
-        State(relations=[Relation("bar")]),
-        Event(f"bar{suffix}", relation=Relation("bar")),
+        State(relations={relation}),
+        _Event(f"bar{suffix}", relation=relation),
         _CharmSpec(MyCharm, {"requires": {"bar": {"interface": "xxx"}}}),
     )
 
 
-@pytest.mark.parametrize("suffix", RELATION_EVENTS_SUFFIX)
+@pytest.mark.parametrize("suffix", _RELATION_EVENTS_SUFFIX)
 def test_evt_no_relation(suffix):
-    assert_inconsistent(State(), Event(f"foo{suffix}"), _CharmSpec(MyCharm, {}))
+    assert_inconsistent(State(), _Event(f"foo{suffix}"), _CharmSpec(MyCharm, {}))
+    relation = Relation("bar")
     assert_consistent(
-        State(relations=[Relation("bar")]),
-        Event(f"bar{suffix}", relation=Relation("bar")),
+        State(relations={relation}),
+        _Event(f"bar{suffix}", relation=relation),
         _CharmSpec(MyCharm, {"requires": {"bar": {"interface": "xxx"}}}),
     )
 
@@ -148,12 +210,12 @@ def test_evt_no_relation(suffix):
 def test_config_key_missing_from_meta():
     assert_inconsistent(
         State(config={"foo": True}),
-        Event("bar"),
+        _Event("bar"),
         _CharmSpec(MyCharm, {}),
     )
     assert_consistent(
         State(config={"foo": True}),
-        Event("bar"),
+        _Event("bar"),
         _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "boolean"}}}),
     )
 
@@ -161,17 +223,17 @@ def test_config_key_missing_from_meta():
 def test_bad_config_option_type():
     assert_inconsistent(
         State(config={"foo": True}),
-        Event("bar"),
+        _Event("bar"),
         _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "string"}}}),
     )
     assert_inconsistent(
         State(config={"foo": True}),
-        Event("bar"),
+        _Event("bar"),
         _CharmSpec(MyCharm, {}, config={"options": {"foo": {}}}),
     )
     assert_consistent(
         State(config={"foo": True}),
-        Event("bar"),
+        _Event("bar"),
         _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "boolean"}}}),
     )
 
@@ -189,12 +251,12 @@ def test_config_types(config_type):
     type_name, valid_value, invalid_value = config_type
     assert_consistent(
         State(config={"foo": valid_value}),
-        Event("bar"),
+        _Event("bar"),
         _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": type_name}}}),
     )
     assert_inconsistent(
         State(config={"foo": invalid_value}),
-        Event("bar"),
+        _Event("bar"),
         _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": type_name}}}),
     )
 
@@ -203,28 +265,28 @@ def test_config_types(config_type):
 def test_config_secret(juju_version):
     assert_consistent(
         State(config={"foo": "secret:co28kefmp25c77utl3n0"}),
-        Event("bar"),
+        _Event("bar"),
         _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}),
         juju_version=juju_version,
     )
     assert_inconsistent(
         State(config={"foo": 1}),
-        Event("bar"),
+        _Event("bar"),
         _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}),
     )
     assert_inconsistent(
         State(config={"foo": "co28kefmp25c77utl3n0"}),
-        Event("bar"),
+        _Event("bar"),
         _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}),
     )
     assert_inconsistent(
         State(config={"foo": "secret:secret"}),
-        Event("bar"),
+        _Event("bar"),
         _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}),
     )
     assert_inconsistent(
         State(config={"foo": "secret:co28kefmp25c77utl3n!"}),
-        Event("bar"),
+        _Event("bar"),
         _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}),
     )
 
@@ -233,7 +295,7 @@ def test_config_secret(juju_version):
 def test_config_secret_old_juju(juju_version):
     assert_inconsistent(
         State(config={"foo": "secret:co28kefmp25c77utl3n0"}),
-        Event("bar"),
+        _Event("bar"),
         _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}),
         juju_version=juju_version,
     )
@@ -241,23 +303,23 @@ def test_config_secret_old_juju(juju_version):
 
 @pytest.mark.parametrize("bad_v", ("1.0", "0", "1.2", "2.35.42", "2.99.99", "2.99"))
 def test_secrets_jujuv_bad(bad_v):
-    secret = Secret("secret:foo", {0: {"a": "b"}})
+    secret = Secret({"a": "b"})
     assert_inconsistent(
-        State(secrets=[secret]),
-        Event("bar"),
+        State(secrets={secret}),
+        _Event("bar"),
         _CharmSpec(MyCharm, {}),
         bad_v,
     )
     assert_inconsistent(
-        State(secrets=[secret]),
-        secret.changed_event,
+        State(secrets={secret}),
+        _Event("secret_changed", secret=secret),
         _CharmSpec(MyCharm, {}),
         bad_v,
     )
 
     assert_inconsistent(
         State(),
-        secret.changed_event,
+        _Event("secret_changed", secret=secret),
         _CharmSpec(MyCharm, {}),
         bad_v,
     )
@@ -266,22 +328,36 @@ def test_secrets_jujuv_bad(bad_v):
 @pytest.mark.parametrize("good_v", ("3.0", "3.1", "3", "3.33", "4", "100"))
 def test_secrets_jujuv_bad(good_v):
     assert_consistent(
-        State(secrets=[Secret("secret:foo", {0: {"a": "b"}})]),
-        Event("bar"),
+        State(secrets={Secret({"a": "b"})}),
+        _Event("bar"),
         _CharmSpec(MyCharm, {}),
         good_v,
     )
 
 
+def test_secret_not_in_state():
+    secret = Secret({"a": "b"})
+    assert_inconsistent(
+        State(),
+        _Event("secret_changed", secret=secret),
+        _CharmSpec(MyCharm, {}),
+    )
+    assert_consistent(
+        State(secrets={secret}),
+        _Event("secret_changed", secret=secret),
+        _CharmSpec(MyCharm, {}),
+    )
+
+
 def test_peer_relation_consistency():
     assert_inconsistent(
-        State(relations=[Relation("foo")]),
-        Event("bar"),
+        State(relations={Relation("foo")}),
+        _Event("bar"),
         _CharmSpec(MyCharm, {"peers": {"foo": {"interface": "bar"}}}),
     )
     assert_consistent(
-        State(relations=[PeerRelation("foo")]),
-        Event("bar"),
+        State(relations={PeerRelation("foo")}),
+        _Event("bar"),
         _CharmSpec(MyCharm, {"peers": {"foo": {"interface": "bar"}}}),
     )
 
@@ -289,7 +365,7 @@ def test_peer_relation_consistency():
 def test_duplicate_endpoints_inconsistent():
     assert_inconsistent(
         State(),
-        Event("bar"),
+        _Event("bar"),
         _CharmSpec(
             MyCharm,
             {
@@ -302,8 +378,8 @@ def test_duplicate_endpoints_inconsistent():
 
 def test_sub_relation_consistency():
     assert_inconsistent(
-        State(relations=[Relation("foo")]),
-        Event("bar"),
+        State(relations={Relation("foo")}),
+        _Event("bar"),
         _CharmSpec(
             MyCharm,
             {"requires": {"foo": {"interface": "bar", "scope": "container"}}},
@@ -311,8 +387,8 @@ def test_sub_relation_consistency():
     )
 
     assert_consistent(
-        State(relations=[SubordinateRelation("foo")]),
-        Event("bar"),
+        State(relations={SubordinateRelation("foo")}),
+        _Event("bar"),
         _CharmSpec(
             MyCharm,
             {"requires": {"foo": {"interface": "bar", "scope": "container"}}},
@@ -322,43 +398,40 @@ def test_sub_relation_consistency():
 
 def test_relation_sub_inconsistent():
     assert_inconsistent(
-        State(relations=[SubordinateRelation("foo")]),
-        Event("bar"),
+        State(relations={SubordinateRelation("foo")}),
+        _Event("bar"),
         _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "bar"}}}),
     )
 
 
-def test_dupe_containers_inconsistent():
+def test_relation_not_in_state():
+    relation = Relation("foo")
     assert_inconsistent(
-        State(containers=[Container("foo"), Container("foo")]),
-        Event("bar"),
-        _CharmSpec(MyCharm, {"containers": {"foo": {}}}),
+        State(),
+        _Event("foo_relation_changed", relation=relation),
+        _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "bar"}}}),
     )
-
-
-def test_container_pebble_evt_consistent():
-    container = Container("foo-bar-baz")
     assert_consistent(
-        State(containers=[container]),
-        container.pebble_ready_event,
-        _CharmSpec(MyCharm, {"containers": {"foo-bar-baz": {}}}),
+        State(relations={relation}),
+        _Event("foo_relation_changed", relation=relation),
+        _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "bar"}}}),
     )
 
 
 def test_action_not_in_meta_inconsistent():
-    action = Action("foo", params={"bar": "baz"})
+    ctx = Context(MyCharm, meta={"name": "foo"}, actions={"foo": {}})
     assert_inconsistent(
         State(),
-        action.event,
+        ctx.on.action("foo", params={"bar": "baz"}),
         _CharmSpec(MyCharm, meta={}, actions={}),
     )
 
 
 def test_action_meta_type_inconsistent():
-    action = Action("foo", params={"bar": "baz"})
+    ctx = Context(MyCharm, meta={"name": "foo"}, actions={"foo": {}})
     assert_inconsistent(
         State(),
-        action.event,
+        ctx.on.action("foo", params={"bar": "baz"}),
         _CharmSpec(
             MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": "zabazaba"}}}}
         ),
@@ -366,24 +439,24 @@ def test_action_meta_type_inconsistent():
 
     assert_inconsistent(
         State(),
-        action.event,
+        ctx.on.action("foo", params={"bar": "baz"}),
         _CharmSpec(MyCharm, meta={}, actions={"foo": {"params": {"bar": {}}}}),
     )
 
 
 def test_action_name():
-    action = Action("foo", params={"bar": "baz"})
+    ctx = Context(MyCharm, meta={"name": "foo"}, actions={"foo": {}})
 
     assert_consistent(
         State(),
-        action.event,
+        ctx.on.action("foo", params={"bar": "baz"}),
         _CharmSpec(
             MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": "string"}}}}
         ),
     )
     assert_inconsistent(
         State(),
-        Event("box_action", action=action),
+        _Event("box_action", action=ctx.on.action("foo", params={"bar": "baz"})),
         _CharmSpec(MyCharm, meta={}, actions={"foo": {}}),
     )
 
@@ -400,19 +473,18 @@ def test_action_name():
 
 @pytest.mark.parametrize("ptype,good,bad", _ACTION_TYPE_CHECKS)
 def test_action_params_type(ptype, good, bad):
-    action = Action("foo", params={"bar": good})
+    ctx = Context(MyCharm, meta={"name": "foo"}, actions={"foo": {}})
     assert_consistent(
         State(),
-        action.event,
+        ctx.on.action("foo", params={"bar": good}),
         _CharmSpec(
             MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": ptype}}}}
         ),
     )
     if bad is not None:
-        action = Action("foo", params={"bar": bad})
         assert_inconsistent(
             State(),
-            action.event,
+            ctx.on.action("foo", params={"bar": bad}),
             _CharmSpec(
                 MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": ptype}}}}
             ),
@@ -421,10 +493,8 @@ def test_action_params_type(ptype, good, bad):
 
 def test_duplicate_relation_ids():
     assert_inconsistent(
-        State(
-            relations=[Relation("foo", relation_id=1), Relation("bar", relation_id=1)]
-        ),
-        Event("start"),
+        State(relations={Relation("foo", id=1), Relation("bar", id=1)}),
+        _Event("start"),
         _CharmSpec(
             MyCharm,
             meta={
@@ -436,18 +506,14 @@ def test_duplicate_relation_ids():
 
 def test_relation_without_endpoint():
     assert_inconsistent(
-        State(
-            relations=[Relation("foo", relation_id=1), Relation("bar", relation_id=1)]
-        ),
-        Event("start"),
+        State(relations={Relation("foo", id=1), Relation("bar", id=1)}),
+        _Event("start"),
         _CharmSpec(MyCharm, meta={"name": "charlemagne"}),
     )
 
     assert_consistent(
-        State(
-            relations=[Relation("foo", relation_id=1), Relation("bar", relation_id=2)]
-        ),
-        Event("start"),
+        State(relations={Relation("foo", id=1), Relation("bar", id=2)}),
+        _Event("start"),
         _CharmSpec(
             MyCharm,
             meta={
@@ -460,20 +526,13 @@ def test_relation_without_endpoint():
 def test_storage_event():
     storage = Storage("foo")
     assert_inconsistent(
-        State(storage=[storage]),
-        Event("foo-storage-attached"),
+        State(storages={storage}),
+        _Event("foo-storage-attached"),
         _CharmSpec(MyCharm, meta={"name": "rupert"}),
     )
     assert_inconsistent(
-        State(storage=[storage]),
-        Event("foo-storage-attached"),
-        _CharmSpec(
-            MyCharm, meta={"name": "rupert", "storage": {"foo": {"type": "filesystem"}}}
-        ),
-    )
-    assert_consistent(
-        State(storage=[storage]),
-        storage.attached_event,
+        State(storages={storage}),
+        _Event("foo-storage-attached"),
         _CharmSpec(
             MyCharm, meta={"name": "rupert", "storage": {"foo": {"type": "filesystem"}}}
         ),
@@ -485,20 +544,20 @@ def test_storage_states():
     storage2 = Storage("foo", index=1)
 
     assert_inconsistent(
-        State(storage=[storage1, storage2]),
-        Event("start"),
+        State(storages={storage1, storage2}),
+        _Event("start"),
         _CharmSpec(MyCharm, meta={"name": "everett"}),
     )
     assert_consistent(
-        State(storage=[storage1, storage2.replace(index=2)]),
-        Event("start"),
+        State(storages={storage1, dataclasses.replace(storage2, index=2)}),
+        _Event("start"),
         _CharmSpec(
             MyCharm, meta={"name": "frank", "storage": {"foo": {"type": "filesystem"}}}
         ),
     )
     assert_consistent(
-        State(storage=[storage1, storage2.replace(name="marx")]),
-        Event("start"),
+        State(storages={storage1, dataclasses.replace(storage2, name="marx")}),
+        _Event("start"),
         _CharmSpec(
             MyCharm,
             meta={
@@ -512,11 +571,31 @@ def test_storage_states():
     )
 
 
+def test_storage_not_in_state():
+    storage = Storage("foo")
+    assert_inconsistent(
+        State(),
+        _Event("foo_storage_attached", storage=storage),
+        _CharmSpec(
+            MyCharm,
+            meta={"name": "sam", "storage": {"foo": {"type": "filesystem"}}},
+        ),
+    )
+    assert_consistent(
+        State(storages=[storage]),
+        _Event("foo_storage_attached", storage=storage),
+        _CharmSpec(
+            MyCharm,
+            meta={"name": "sam", "storage": {"foo": {"type": "filesystem"}}},
+        ),
+    )
+
+
 def test_resource_states():
     # happy path
     assert_consistent(
-        State(resources={"foo": "/foo/bar.yaml"}),
-        Event("start"),
+        State(resources={Resource(name="foo", path="/foo/bar.yaml")}),
+        _Event("start"),
         _CharmSpec(
             MyCharm,
             meta={"name": "yamlman", "resources": {"foo": {"type": "oci-image"}}},
@@ -526,7 +605,7 @@ def test_resource_states():
     # no resources in state but some in meta: OK. Not realistic wrt juju but fine for testing
     assert_consistent(
         State(),
-        Event("start"),
+        _Event("start"),
         _CharmSpec(
             MyCharm,
             meta={"name": "yamlman", "resources": {"foo": {"type": "oci-image"}}},
@@ -535,8 +614,8 @@ def test_resource_states():
 
     # resource not defined in meta
     assert_inconsistent(
-        State(resources={"bar": "/foo/bar.yaml"}),
-        Event("start"),
+        State(resources={Resource(name="bar", path="/foo/bar.yaml")}),
+        _Event("start"),
         _CharmSpec(
             MyCharm,
             meta={"name": "yamlman", "resources": {"foo": {"type": "oci-image"}}},
@@ -544,8 +623,8 @@ def test_resource_states():
     )
 
     assert_inconsistent(
-        State(resources={"bar": "/foo/bar.yaml"}),
-        Event("start"),
+        State(resources={Resource(name="bar", path="/foo/bar.yaml")}),
+        _Event("start"),
         _CharmSpec(
             MyCharm,
             meta={"name": "yamlman"},
@@ -555,8 +634,8 @@ def test_resource_states():
 
 def test_networks_consistency():
     assert_inconsistent(
-        State(networks={"foo": Network.default()}),
-        Event("start"),
+        State(networks={Network("foo")}),
+        _Event("start"),
         _CharmSpec(
             MyCharm,
             meta={"name": "wonky"},
@@ -564,8 +643,8 @@ def test_networks_consistency():
     )
 
     assert_inconsistent(
-        State(networks={"foo": Network.default()}),
-        Event("start"),
+        State(networks={Network("foo")}),
+        _Event("start"),
         _CharmSpec(
             MyCharm,
             meta={
@@ -577,8 +656,8 @@ def test_networks_consistency():
     )
 
     assert_consistent(
-        State(networks={"foo": Network.default()}),
-        Event("start"),
+        State(networks={Network("foo")}),
+        _Event("start"),
         _CharmSpec(
             MyCharm,
             meta={
@@ -607,7 +686,7 @@ def test_cloudspec_consistency():
 
     assert_consistent(
         State(model=Model(name="lxd-model", type="lxd", cloud_spec=cloud_spec)),
-        Event("start"),
+        _Event("start"),
         _CharmSpec(
             MyCharm,
             meta={"name": "MyVMCharm"},
@@ -616,9 +695,41 @@ def test_cloudspec_consistency():
 
     assert_inconsistent(
         State(model=Model(name="k8s-model", type="kubernetes", cloud_spec=cloud_spec)),
-        Event("start"),
+        _Event("start"),
         _CharmSpec(
             MyCharm,
             meta={"name": "MyK8sCharm"},
         ),
     )
+
+
+def test_storedstate_consistency():
+    assert_consistent(
+        State(
+            stored_states={
+                StoredState(content={"foo": "bar"}),
+                StoredState(name="my_stored_state", content={"foo": 1}),
+                StoredState(owner_path="MyCharmLib", content={"foo": None}),
+                StoredState(owner_path="OtherCharmLib", content={"foo": (1, 2, 3)}),
+            }
+        ),
+        _Event("start"),
+        _CharmSpec(
+            MyCharm,
+            meta={
+                "name": "foo",
+            },
+        ),
+    )
+    assert_inconsistent(
+        State(
+            stored_states={StoredState(owner_path=None, content={"secret": Secret({})})}
+        ),
+        _Event("start"),
+        _CharmSpec(
+            MyCharm,
+            meta={
+                "name": "foo",
+            },
+        ),
+    )
diff --git a/tests/test_context.py b/tests/test_context.py
index ff9ef690..0d55ca9e 100644
--- a/tests/test_context.py
+++ b/tests/test_context.py
@@ -3,8 +3,8 @@
 import pytest
 from ops import CharmBase
 
-from scenario import Action, Context, Event, State
-from scenario.state import next_action_id
+from scenario import Context, State
+from scenario.state import _Event, _next_action_id
 
 
 class MyCharm(CharmBase):
@@ -17,14 +17,14 @@ def test_run():
 
     with patch.object(ctx, "_run") as p:
         ctx._output_state = "foo"  # would normally be set within the _run call scope
-        output = ctx.run("start", state)
+        output = ctx.run(ctx.on.start(), state)
         assert output == "foo"
 
     assert p.called
     e = p.call_args.kwargs["event"]
     s = p.call_args.kwargs["state"]
 
-    assert isinstance(e, Event)
+    assert isinstance(e, _Event)
     assert e.name == "start"
     assert s is state
 
@@ -32,41 +32,39 @@ def test_run():
 def test_run_action():
     ctx = Context(MyCharm, meta={"name": "foo"})
     state = State()
-    expected_id = next_action_id(update=False)
+    expected_id = _next_action_id(update=False)
 
-    with patch.object(ctx, "_run_action") as p:
-        ctx._output_state = (
-            "foo"  # would normally be set within the _run_action call scope
-        )
-        output = ctx.run_action("do-foo", state)
-        assert output.state == "foo"
+    with patch.object(ctx, "_run") as p:
+        ctx._output_state = "foo"  # would normally be set within the _run call scope
+        output = ctx.run(ctx.on.action("do-foo"), state)
+        assert output == "foo"
 
     assert p.called
-    a = p.call_args.kwargs["action"]
+    e = p.call_args.kwargs["event"]
     s = p.call_args.kwargs["state"]
 
-    assert isinstance(a, Action)
-    assert a.event.name == "do_foo_action"
+    assert isinstance(e, _Event)
+    assert e.name == "do_foo_action"
     assert s is state
-    assert a.id == expected_id
-
-
-def test_clear():
-    ctx = Context(MyCharm, meta={"name": "foo"})
-    state = State()
-
-    ctx.run("start", state)
-    assert ctx.emitted_events
-
-    ctx.clear()
-    assert not ctx.emitted_events  # and others...
+    assert e.action.id == expected_id
 
 
 @pytest.mark.parametrize("app_name", ("foo", "bar", "george"))
 @pytest.mark.parametrize("unit_id", (1, 2, 42))
 def test_app_name(app_name, unit_id):
-    with Context(
-        MyCharm, meta={"name": "foo"}, app_name=app_name, unit_id=unit_id
-    ).manager("start", State()) as mgr:
+    ctx = Context(MyCharm, meta={"name": "foo"}, app_name=app_name, unit_id=unit_id)
+    with ctx(ctx.on.start(), State()) as mgr:
         assert mgr.charm.app.name == app_name
         assert mgr.charm.unit.name == f"{app_name}/{unit_id}"
+
+
+def test_context_manager():
+    ctx = Context(MyCharm, meta={"name": "foo"}, actions={"act": {}})
+    state = State()
+    with ctx(ctx.on.start(), state) as mgr:
+        mgr.run()
+        assert mgr.charm.meta.name == "foo"
+
+    with ctx(ctx.on.action("act"), state) as mgr:
+        mgr.run()
+        assert mgr.charm.meta.name == "foo"
diff --git a/tests/test_context_on.py b/tests/test_context_on.py
new file mode 100644
index 00000000..32759fd4
--- /dev/null
+++ b/tests/test_context_on.py
@@ -0,0 +1,337 @@
+import copy
+
+import ops
+import pytest
+
+import scenario
+
+META = {
+    "name": "context-charm",
+    "containers": {
+        "bar": {},
+    },
+    "requires": {
+        "baz": {
+            "interface": "charmlink",
+        }
+    },
+    "storage": {
+        "foo": {
+            "type": "filesystem",
+        }
+    },
+}
+ACTIONS = {
+    "act": {
+        "params": {
+            "param": {
+                "description": "some parameter",
+                "type": "string",
+                "default": "",
+            }
+        }
+    },
+}
+
+
+class ContextCharm(ops.CharmBase):
+    def __init__(self, framework):
+        super().__init__(framework)
+        self.observed = []
+        for event in self.on.events().values():
+            framework.observe(event, self._on_event)
+
+    def _on_event(self, event):
+        self.observed.append(event)
+
+
+@pytest.mark.parametrize(
+    "event_name, event_kind",
+    [
+        ("install", ops.InstallEvent),
+        ("start", ops.StartEvent),
+        ("stop", ops.StopEvent),
+        ("remove", ops.RemoveEvent),
+        ("update_status", ops.UpdateStatusEvent),
+        ("config_changed", ops.ConfigChangedEvent),
+        ("upgrade_charm", ops.UpgradeCharmEvent),
+        ("pre_series_upgrade", ops.PreSeriesUpgradeEvent),
+        ("post_series_upgrade", ops.PostSeriesUpgradeEvent),
+        ("leader_elected", ops.LeaderElectedEvent),
+    ],
+)
+def test_simple_events(event_name, event_kind):
+    ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS)
+    # These look like:
+    #   ctx.run(ctx.on.install(), state)
+    with ctx(getattr(ctx.on, event_name)(), scenario.State()) as mgr:
+        mgr.run()
+        assert len(mgr.charm.observed) == 2
+        assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent)
+        assert isinstance(mgr.charm.observed[0], event_kind)
+
+
+@pytest.mark.parametrize("as_kwarg", [True, False])
+@pytest.mark.parametrize(
+    "event_name,event_kind,owner",
+    [
+        ("secret_changed", ops.SecretChangedEvent, None),
+        ("secret_rotate", ops.SecretRotateEvent, "app"),
+    ],
+)
+def test_simple_secret_events(as_kwarg, event_name, event_kind, owner):
+    ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS)
+    secret = scenario.Secret({"password": "xxxx"}, owner=owner)
+    state_in = scenario.State(secrets={secret})
+    # These look like:
+    #   ctx.run(ctx.on.secret_changed(secret=secret), state)
+    # The secret must always be passed because the same event name is used for
+    # all secrets.
+    if as_kwarg:
+        args = ()
+        kwargs = {"secret": secret}
+    else:
+        args = (secret,)
+        kwargs = {}
+    with ctx(getattr(ctx.on, event_name)(*args, **kwargs), state_in) as mgr:
+        mgr.run()
+        assert len(mgr.charm.observed) == 2
+        assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent)
+        mgr = mgr.charm.observed[0]
+        assert isinstance(mgr, event_kind)
+        assert mgr.secret.id == secret.id
+
+
+@pytest.mark.parametrize(
+    "event_name, event_kind",
+    [
+        ("secret_expired", ops.SecretExpiredEvent),
+        ("secret_remove", ops.SecretRemoveEvent),
+    ],
+)
+def test_revision_secret_events(event_name, event_kind):
+    ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS)
+    secret = scenario.Secret(
+        tracked_content={"password": "yyyy"},
+        latest_content={"password": "xxxx"},
+        owner="app",
+    )
+    state_in = scenario.State(secrets={secret})
+    # These look like:
+    #   ctx.run(ctx.on.secret_expired(secret=secret, revision=revision), state)
+    # The secret and revision must always be passed because the same event name
+    # is used for all secrets.
+    with ctx(getattr(ctx.on, event_name)(secret, revision=42), state_in) as mgr:
+        mgr.run()
+        assert len(mgr.charm.observed) == 2
+        assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent)
+        mgr = mgr.charm.observed[0]
+        assert isinstance(mgr, event_kind)
+        assert mgr.secret.id == secret.id
+        assert mgr.revision == 42
+
+
+@pytest.mark.parametrize("event_name", ["secret_expired", "secret_remove"])
+def test_revision_secret_events_as_positional_arg(event_name):
+    ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS)
+    secret = scenario.Secret(
+        tracked_content={"password": "yyyy"},
+        latest_content={"password": "xxxx"},
+        owner=None,
+    )
+    state_in = scenario.State(secrets={secret})
+    with pytest.raises(TypeError):
+        ctx.run(getattr(ctx.on, event_name)(secret, 42), state_in)
+
+
+@pytest.mark.parametrize(
+    "event_name, event_kind",
+    [
+        ("storage_attached", ops.StorageAttachedEvent),
+        ("storage_detaching", ops.StorageDetachingEvent),
+    ],
+)
+def test_storage_events(event_name, event_kind):
+    ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS)
+    storage = scenario.Storage("foo")
+    state_in = scenario.State(storages=[storage])
+    # These look like:
+    #   ctx.run(ctx.on.storage_attached(storage), state)
+    with ctx(getattr(ctx.on, event_name)(storage), state_in) as mgr:
+        mgr.run()
+        assert len(mgr.charm.observed) == 2
+        assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent)
+        mgr = mgr.charm.observed[0]
+        assert isinstance(mgr, event_kind)
+        assert mgr.storage.name == storage.name
+        assert mgr.storage.index == storage.index
+
+
+def test_action_event_no_params():
+    ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS)
+    # These look like:
+    #   ctx.run(ctx.on.action(action_name), state)
+    with ctx(ctx.on.action("act"), scenario.State()) as mgr:
+        mgr.run()
+        assert len(mgr.charm.observed) == 2
+        assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent)
+        mgr = mgr.charm.observed[0]
+        assert isinstance(mgr, ops.ActionEvent)
+
+
+def test_action_event_with_params():
+    ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS)
+    # These look like:
+    #   ctx.run(ctx.on.action(action=action), state)
+    # So that any parameters can be included and the ID can be customised.
+    call_event = ctx.on.action("act", params={"param": "hello"})
+    with ctx(call_event, scenario.State()) as mgr:
+        mgr.run()
+        assert len(mgr.charm.observed) == 2
+        assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent)
+        mgr = mgr.charm.observed[0]
+        assert isinstance(mgr, ops.ActionEvent)
+        assert mgr.id == call_event.action.id
+        assert mgr.params["param"] == call_event.action.params["param"]
+
+
+def test_pebble_ready_event():
+    ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS)
+    container = scenario.Container("bar", can_connect=True)
+    state_in = scenario.State(containers=[container])
+    # These look like:
+    #   ctx.run(ctx.on.pebble_ready(container), state)
+    with ctx(ctx.on.pebble_ready(container), state_in) as mgr:
+        mgr.run()
+        assert len(mgr.charm.observed) == 2
+        assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent)
+        mgr = mgr.charm.observed[0]
+        assert isinstance(mgr, ops.PebbleReadyEvent)
+        assert mgr.workload.name == container.name
+
+
+@pytest.mark.parametrize("as_kwarg", [True, False])
+@pytest.mark.parametrize(
+    "event_name, event_kind",
+    [
+        ("relation_created", ops.RelationCreatedEvent),
+        ("relation_broken", ops.RelationBrokenEvent),
+    ],
+)
+def test_relation_app_events(as_kwarg, event_name, event_kind):
+    ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS)
+    relation = scenario.Relation("baz")
+    state_in = scenario.State(relations=[relation])
+    # These look like:
+    #   ctx.run(ctx.on.relation_created(relation), state)
+    if as_kwarg:
+        args = ()
+        kwargs = {"relation": relation}
+    else:
+        args = (relation,)
+        kwargs = {}
+    with ctx(getattr(ctx.on, event_name)(*args, **kwargs), state_in) as mgr:
+        mgr.run()
+        assert len(mgr.charm.observed) == 2
+        assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent)
+        mgr = mgr.charm.observed[0]
+        assert isinstance(mgr, event_kind)
+        assert mgr.relation.id == relation.id
+        assert mgr.app.name == relation.remote_app_name
+        assert mgr.unit is None
+
+
+def test_relation_complex_name():
+    meta = copy.deepcopy(META)
+    meta["requires"]["foo-bar-baz"] = {"interface": "another-one"}
+    ctx = scenario.Context(ContextCharm, meta=meta, actions=ACTIONS)
+    relation = scenario.Relation("foo-bar-baz")
+    state_in = scenario.State(relations=[relation])
+    with ctx(ctx.on.relation_created(relation), state_in) as mgr:
+        mgr.run()
+        assert len(mgr.charm.observed) == 2
+        mgr = mgr.charm.observed[0]
+        assert isinstance(mgr, ops.RelationCreatedEvent)
+        assert mgr.relation.id == relation.id
+        assert mgr.app.name == relation.remote_app_name
+        assert mgr.unit is None
+
+
+@pytest.mark.parametrize("event_name", ["relation_created", "relation_broken"])
+def test_relation_events_as_positional_arg(event_name):
+    ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS)
+    relation = scenario.Relation("baz")
+    state_in = scenario.State(relations=[relation])
+    with pytest.raises(TypeError):
+        ctx.run(getattr(ctx.on, event_name)(relation, 0), state_in)
+
+
+@pytest.mark.parametrize(
+    "event_name, event_kind",
+    [
+        ("relation_joined", ops.RelationJoinedEvent),
+        ("relation_changed", ops.RelationChangedEvent),
+    ],
+)
+def test_relation_unit_events_default_unit(event_name, event_kind):
+    ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS)
+    relation = scenario.Relation("baz", remote_units_data={1: {"x": "y"}})
+    state_in = scenario.State(relations=[relation])
+    # These look like:
+    #   ctx.run(ctx.on.baz_relation_changed, state)
+    # The unit is chosen automatically.
+    with ctx(getattr(ctx.on, event_name)(relation), state_in) as mgr:
+        mgr.run()
+        assert len(mgr.charm.observed) == 2
+        assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent)
+        mgr = mgr.charm.observed[0]
+        assert isinstance(mgr, event_kind)
+        assert mgr.relation.id == relation.id
+        assert mgr.app.name == relation.remote_app_name
+        assert mgr.unit.name == "remote/1"
+
+
+@pytest.mark.parametrize(
+    "event_name, event_kind",
+    [
+        ("relation_joined", ops.RelationJoinedEvent),
+        ("relation_changed", ops.RelationChangedEvent),
+    ],
+)
+def test_relation_unit_events(event_name, event_kind):
+    ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS)
+    relation = scenario.Relation(
+        "baz", remote_units_data={1: {"x": "y"}, 2: {"x": "z"}}
+    )
+    state_in = scenario.State(relations=[relation])
+    # These look like:
+    #   ctx.run(ctx.on.baz_relation_changed(unit=unit_ordinal), state)
+    with ctx(getattr(ctx.on, event_name)(relation, remote_unit=2), state_in) as mgr:
+        mgr.run()
+        assert len(mgr.charm.observed) == 2
+        assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent)
+        mgr = mgr.charm.observed[0]
+        assert isinstance(mgr, event_kind)
+        assert mgr.relation.id == relation.id
+        assert mgr.app.name == relation.remote_app_name
+        assert mgr.unit.name == "remote/2"
+
+
+def test_relation_departed_event():
+    ctx = scenario.Context(ContextCharm, meta=META, actions=ACTIONS)
+    relation = scenario.Relation("baz")
+    state_in = scenario.State(relations=[relation])
+    # These look like:
+    #   ctx.run(ctx.on.baz_relation_departed(unit=unit_ordinal, departing_unit=unit_ordinal), state)
+    with ctx(
+        ctx.on.relation_departed(relation, remote_unit=2, departing_unit=1), state_in
+    ) as mgr:
+        mgr.run()
+        assert len(mgr.charm.observed) == 2
+        assert isinstance(mgr.charm.observed[1], ops.CollectStatusEvent)
+        mgr = mgr.charm.observed[0]
+        assert isinstance(mgr, ops.RelationDepartedEvent)
+        assert mgr.relation.id == relation.id
+        assert mgr.app.name == relation.remote_app_name
+        assert mgr.unit.name == "remote/2"
+        assert mgr.departing_unit.name == "remote/1"
diff --git a/tests/test_dcbase.py b/tests/test_dcbase.py
deleted file mode 100644
index fd5ff872..00000000
--- a/tests/test_dcbase.py
+++ /dev/null
@@ -1,41 +0,0 @@
-import dataclasses
-from typing import Dict, List
-
-from scenario.state import _DCBase
-
-
-@dataclasses.dataclass(frozen=True)
-class Foo(_DCBase):
-    a: int
-    b: List[int]
-    c: Dict[int, List[int]]
-
-
-def test_base_case():
-    l = [1, 2]
-    l1 = [1, 2, 3]
-    d = {1: l1}
-    f = Foo(1, l, d)
-    g = f.replace(a=2)
-
-    assert g.a == 2
-    assert g.b == l
-    assert g.c == d
-    assert g.c[1] == l1
-
-
-def test_dedup_on_replace():
-    l = [1, 2]
-    l1 = [1, 2, 3]
-    d = {1: l1}
-    f = Foo(1, l, d)
-    g = f.replace(a=2)
-
-    l.append(3)
-    l1.append(4)
-    d[2] = "foobar"
-
-    assert g.a == 2
-    assert g.b == [1, 2]
-    assert g.c == {1: [1, 2, 3]}
-    assert g.c[1] == [1, 2, 3]
diff --git a/tests/test_e2e/test_actions.py b/tests/test_e2e/test_actions.py
index 5282c195..0ab845b9 100644
--- a/tests/test_e2e/test_actions.py
+++ b/tests/test_e2e/test_actions.py
@@ -3,10 +3,8 @@
 from ops.charm import ActionEvent, CharmBase
 from ops.framework import Framework
 
-from scenario import Context
-from scenario.context import InvalidEventError
-from scenario.state import Action, Event, State
-from tests.helpers import trigger
+from scenario import ActionFailed, Context
+from scenario.state import State, _Action, _next_action_id
 
 
 @pytest.fixture(scope="function")
@@ -35,30 +33,27 @@ def test_action_event(mycharm, baz_value):
             "foo": {"params": {"bar": {"type": "number"}, "baz": {"type": "boolean"}}}
         },
     )
-    action = Action("foo", params={"baz": baz_value, "bar": 10})
-    ctx.run_action(action, State())
+    state = ctx.run(ctx.on.action("foo", params={"baz": baz_value, "bar": 10}), State())
 
+    assert isinstance(state, State)
     evt = ctx.emitted_events[0]
-
     assert evt.params["bar"] == 10
     assert evt.params["baz"] is baz_value
 
 
-def test_action_pre_post(mycharm):
-    ctx = Context(
-        mycharm,
-        meta={"name": "foo"},
-        actions={
-            "foo": {"params": {"bar": {"type": "number"}, "baz": {"type": "boolean"}}}
-        },
-    )
-    action = Action("foo", params={"baz": True, "bar": 10})
-    ctx.run_action(
-        action,
-        State(),
-        pre_event=lambda charm: None,
-        post_event=lambda charm: None,
-    )
+def test_action_no_results():
+    class MyCharm(CharmBase):
+        def __init__(self, framework):
+            super().__init__(framework)
+            framework.observe(self.on.act_action, self._on_act_action)
+
+        def _on_act_action(self, _):
+            pass
+
+    ctx = Context(MyCharm, meta={"name": "foo"}, actions={"act": {}})
+    ctx.run(ctx.on.action("act"), State())
+    assert ctx.action_results is None
+    assert ctx.action_logs == []
 
 
 @pytest.mark.parametrize("res_value", ("one", 1, [2], ["bar"], (1,), {1, 2}))
@@ -69,36 +64,13 @@ def handle_evt(charm: CharmBase, evt: ActionEvent):
 
     mycharm._evt_handler = handle_evt
 
-    action = Action("foo")
-    ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}})
-    ctx.run_action(action, State())
-
-
-def test_cannot_run_action(mycharm):
-    ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}})
-    action = Action("foo")
-
-    with pytest.raises(InvalidEventError):
-        ctx.run(action, state=State())
-
-
-def test_cannot_run_action_name(mycharm):
     ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}})
-    action = Action("foo")
-    with pytest.raises(InvalidEventError):
-        ctx.run(action.event.name, state=State())
-
-
-def test_cannot_run_action_event(mycharm):
-    ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}})
-    action = Action("foo")
-    with pytest.raises(InvalidEventError):
-        ctx.run(action.event, state=State())
+    ctx.run(ctx.on.action("foo"), State())
 
 
 @pytest.mark.parametrize("res_value", ({"a": {"b": {"c"}}}, {"d": "e"}))
 def test_action_event_results_valid(mycharm, res_value):
-    def handle_evt(charm: CharmBase, evt):
+    def handle_evt(_: CharmBase, evt):
         if not isinstance(evt, ActionEvent):
             return
         evt.set_results(res_value)
@@ -107,18 +79,16 @@ def handle_evt(charm: CharmBase, evt):
 
     mycharm._evt_handler = handle_evt
 
-    action = Action("foo")
     ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}})
 
-    out = ctx.run_action(action, State())
+    ctx.run(ctx.on.action("foo"), State())
 
-    assert out.results == res_value
-    assert out.success is True
+    assert ctx.action_results == res_value
 
 
 @pytest.mark.parametrize("res_value", ({"a": {"b": {"c"}}}, {"d": "e"}))
 def test_action_event_outputs(mycharm, res_value):
-    def handle_evt(charm: CharmBase, evt: ActionEvent):
+    def handle_evt(_: CharmBase, evt: ActionEvent):
         if not isinstance(evt, ActionEvent):
             return
 
@@ -129,13 +99,32 @@ def handle_evt(charm: CharmBase, evt: ActionEvent):
 
     mycharm._evt_handler = handle_evt
 
-    action = Action("foo")
     ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}})
-    out = ctx.run_action(action, State())
+    with pytest.raises(ActionFailed) as exc_info:
+        ctx.run(ctx.on.action("foo"), State())
+    assert exc_info.value.message == "failed becozz"
+    assert ctx.action_results == {"my-res": res_value}
+    assert ctx.action_logs == ["log1", "log2"]
 
-    assert out.failure == "failed becozz"
-    assert out.logs == ["log1", "log2"]
-    assert out.success is False
+
+def test_action_continues_after_fail():
+    class MyCharm(CharmBase):
+        def __init__(self, framework):
+            super().__init__(framework)
+            framework.observe(self.on.foo_action, self._on_foo_action)
+
+        def _on_foo_action(self, event):
+            event.log("starting")
+            event.set_results({"initial": "result"})
+            event.fail("oh no!")
+            event.set_results({"final": "result"})
+
+    ctx = Context(MyCharm, meta={"name": "foo"}, actions={"foo": {}})
+    with pytest.raises(ActionFailed) as exc_info:
+        ctx.run(ctx.on.action("foo"), State())
+    assert exc_info.value.message == "oh no!"
+    assert ctx.action_logs == ["starting"]
+    assert ctx.action_results == {"initial": "result", "final": "result"}
 
 
 def _ops_less_than(wanted_major, wanted_minor):
@@ -151,16 +140,15 @@ def _ops_less_than(wanted_major, wanted_minor):
     _ops_less_than(2, 11), reason="ops 2.10 and earlier don't have ActionEvent.id"
 )
 def test_action_event_has_id(mycharm):
-    def handle_evt(charm: CharmBase, evt: ActionEvent):
+    def handle_evt(_: CharmBase, evt: ActionEvent):
         if not isinstance(evt, ActionEvent):
             return
         assert isinstance(evt.id, str) and evt.id != ""
 
     mycharm._evt_handler = handle_evt
 
-    action = Action("foo")
     ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}})
-    ctx.run_action(action, State())
+    ctx.run(ctx.on.action("foo"), State())
 
 
 @pytest.mark.skipif(
@@ -176,6 +164,44 @@ def handle_evt(charm: CharmBase, evt: ActionEvent):
 
     mycharm._evt_handler = handle_evt
 
-    action = Action("foo", id=uuid)
     ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}})
-    ctx.run_action(action, State())
+    ctx.run(ctx.on.action("foo", id=uuid), State())
+
+
+def test_two_actions_same_context():
+    class MyCharm(CharmBase):
+        def __init__(self, framework):
+            super().__init__(framework)
+            framework.observe(self.on.foo_action, self._on_foo_action)
+            framework.observe(self.on.bar_action, self._on_bar_action)
+
+        def _on_foo_action(self, event):
+            event.log("foo")
+            event.set_results({"foo": "result"})
+
+        def _on_bar_action(self, event):
+            event.log("bar")
+            event.set_results({"bar": "result"})
+
+    ctx = Context(MyCharm, meta={"name": "foo"}, actions={"foo": {}, "bar": {}})
+    ctx.run(ctx.on.action("foo"), State())
+    assert ctx.action_results == {"foo": "result"}
+    assert ctx.action_logs == ["foo"]
+    # Not recommended, but run another action in the same context.
+    ctx.run(ctx.on.action("bar"), State())
+    assert ctx.action_results == {"bar": "result"}
+    assert ctx.action_logs == ["bar"]
+
+
+def test_positional_arguments():
+    with pytest.raises(TypeError):
+        _Action("foo", {})
+
+
+def test_default_arguments():
+    expected_id = _next_action_id(update=False)
+    name = "foo"
+    action = _Action(name)
+    assert action.name == name
+    assert action.params == {}
+    assert action.id == expected_id
diff --git a/tests/test_e2e/test_builtin_scenes.py b/tests/test_e2e/test_builtin_scenes.py
deleted file mode 100644
index 587e4149..00000000
--- a/tests/test_e2e/test_builtin_scenes.py
+++ /dev/null
@@ -1,56 +0,0 @@
-import pytest
-from ops.charm import CharmBase, CollectStatusEvent
-from ops.framework import Framework
-
-from scenario.sequences import check_builtin_sequences
-from scenario.state import State
-
-CHARM_CALLED = 0
-
-
-@pytest.fixture(scope="function")
-def mycharm():
-    global CHARM_CALLED
-    CHARM_CALLED = 0
-
-    class MyCharm(CharmBase):
-        _call = None
-        require_config = False
-
-        def __init__(self, framework: Framework):
-            super().__init__(framework)
-            self.called = False
-            if self.require_config:
-                assert self.config["foo"] == "bar"
-
-            for evt in self.on.events().values():
-                self.framework.observe(evt, self._on_event)
-
-        def _on_event(self, event):
-            if isinstance(event, CollectStatusEvent):
-                return
-
-            global CHARM_CALLED
-            CHARM_CALLED += 1
-
-            if self._call:
-                self.called = True
-                self._call(event)
-
-    return MyCharm
-
-
-def test_builtin_scenes(mycharm):
-    check_builtin_sequences(mycharm, meta={"name": "foo"})
-    assert CHARM_CALLED == 12
-
-
-def test_builtin_scenes_template(mycharm):
-    mycharm.require_config = True
-    check_builtin_sequences(
-        mycharm,
-        meta={"name": "foo"},
-        config={"options": {"foo": {"type": "string"}}},
-        template_state=State(config={"foo": "bar"}),
-    )
-    assert CHARM_CALLED == 12
diff --git a/tests/test_e2e/test_cloud_spec.py b/tests/test_e2e/test_cloud_spec.py
index 8ce413f8..c3a09248 100644
--- a/tests/test_e2e/test_cloud_spec.py
+++ b/tests/test_e2e/test_cloud_spec.py
@@ -47,14 +47,14 @@ def test_get_cloud_spec():
             name="lxd-model", type="lxd", cloud_spec=scenario_cloud_spec
         ),
     )
-    with ctx.manager("start", state=state) as mgr:
+    with ctx(ctx.on.start(), state=state) as mgr:
         assert mgr.charm.model.get_cloud_spec() == expected_cloud_spec
 
 
 def test_get_cloud_spec_error():
     ctx = scenario.Context(MyCharm, meta={"name": "foo"})
     state = scenario.State(model=scenario.Model(name="lxd-model", type="lxd"))
-    with ctx.manager("start", state) as mgr:
+    with ctx(ctx.on.start(), state) as mgr:
         with pytest.raises(ops.ModelError):
             mgr.charm.model.get_cloud_spec()
 
@@ -65,6 +65,6 @@ def test_get_cloud_spec_untrusted():
     state = scenario.State(
         model=scenario.Model(name="lxd-model", type="lxd", cloud_spec=cloud_spec),
     )
-    with ctx.manager("start", state) as mgr:
+    with ctx(ctx.on.start(), state) as mgr:
         with pytest.raises(ops.ModelError):
             mgr.charm.model.get_cloud_spec()
diff --git a/tests/test_e2e/test_custom_event_triggers.py b/tests/test_e2e/test_custom_event_triggers.py
deleted file mode 100644
index 1c6f07cd..00000000
--- a/tests/test_e2e/test_custom_event_triggers.py
+++ /dev/null
@@ -1,146 +0,0 @@
-import os
-from unittest.mock import MagicMock
-
-import pytest
-from ops.charm import CharmBase, CharmEvents
-from ops.framework import EventBase, EventSource, Object
-
-from scenario import State
-from scenario.ops_main_mock import NoObserverError
-from scenario.runtime import InconsistentScenarioError
-from tests.helpers import trigger
-
-
-def test_custom_event_emitted():
-    class FooEvent(EventBase):
-        pass
-
-    class MyCharmEvents(CharmEvents):
-        foo = EventSource(FooEvent)
-
-    class MyCharm(CharmBase):
-        META = {"name": "mycharm"}
-        on = MyCharmEvents()
-        _foo_called = 0
-
-        def __init__(self, *args, **kwargs):
-            super().__init__(*args, **kwargs)
-            self.framework.observe(self.on.foo, self._on_foo)
-            self.framework.observe(self.on.start, self._on_start)
-
-        def _on_foo(self, e):
-            MyCharm._foo_called += 1
-
-        def _on_start(self, e):
-            self.on.foo.emit()
-
-    trigger(State(), "foo", MyCharm, meta=MyCharm.META)
-    assert MyCharm._foo_called == 1
-
-    trigger(State(), "start", MyCharm, meta=MyCharm.META)
-    assert MyCharm._foo_called == 2
-
-
-def test_funky_named_event_emitted():
-    class FooRelationChangedEvent(EventBase):
-        pass
-
-    class MyCharmEvents(CharmEvents):
-        foo_relation_changed = EventSource(FooRelationChangedEvent)
-
-    class MyCharm(CharmBase):
-        META = {"name": "mycharm"}
-        on = MyCharmEvents()
-        _foo_called = False
-
-        def __init__(self, *args, **kwargs):
-            super().__init__(*args, **kwargs)
-            self.framework.observe(self.on.foo_relation_changed, self._on_foo)
-
-        def _on_foo(self, e):
-            MyCharm._foo_called = True
-
-    # we called our custom event like a builtin one. Trouble!
-    with pytest.raises(InconsistentScenarioError):
-        trigger(State(), "foo-relation-changed", MyCharm, meta=MyCharm.META)
-
-    assert not MyCharm._foo_called
-
-    os.environ["SCENARIO_SKIP_CONSISTENCY_CHECKS"] = "1"
-    trigger(State(), "foo-relation-changed", MyCharm, meta=MyCharm.META)
-    assert MyCharm._foo_called
-    os.unsetenv("SCENARIO_SKIP_CONSISTENCY_CHECKS")
-
-
-def test_child_object_event_emitted_no_path_raises():
-    class FooEvent(EventBase):
-        pass
-
-    class MyObjEvents(CharmEvents):
-        foo = EventSource(FooEvent)
-
-    class MyObject(Object):
-        my_on = MyObjEvents()
-
-    class MyCharm(CharmBase):
-        META = {"name": "mycharm"}
-        _foo_called = False
-
-        def __init__(self, *args, **kwargs):
-            super().__init__(*args, **kwargs)
-            self.obj = MyObject(self, "child")
-            self.framework.observe(self.obj.my_on.foo, self._on_foo)
-
-        def _on_foo(self, e):
-            MyCharm._foo_called = True
-
-    with pytest.raises(NoObserverError):
-        # this will fail because "foo" isn't registered on MyCharm but on MyCharm.foo
-        trigger(State(), "foo", MyCharm, meta=MyCharm.META)
-        assert MyCharm._foo_called
-
-    # workaround: we can use pre_event to have Scenario set up the simulation for us and run our
-    # test code before it eventually fails. pre_event gets called with the set-up charm instance.
-    def pre_event(charm: MyCharm):
-        event_mock = MagicMock()
-        charm._on_foo(event_mock)
-        assert charm.unit.name == "mycharm/0"
-
-    # make sure you only filter out NoObserverError, else if pre_event raises,
-    # they will also be caught while you want them to bubble up.
-    with pytest.raises(NoObserverError):
-        trigger(
-            State(),
-            "rubbish",  # you can literally put anything here
-            MyCharm,
-            pre_event=pre_event,
-            meta=MyCharm.META,
-        )
-    assert MyCharm._foo_called
-
-
-def test_child_object_event():
-    class FooEvent(EventBase):
-        pass
-
-    class MyObjEvents(CharmEvents):
-        foo = EventSource(FooEvent)
-
-    class MyObject(Object):
-        my_on = MyObjEvents()
-
-    class MyCharm(CharmBase):
-        META = {"name": "mycharm"}
-        _foo_called = False
-
-        def __init__(self, *args, **kwargs):
-            super().__init__(*args, **kwargs)
-            self.obj = MyObject(self, "child")
-            self.framework.observe(self.obj.my_on.foo, self._on_foo)
-
-        def _on_foo(self, e):
-            MyCharm._foo_called = True
-
-    trigger(State(), "obj.my_on.foo", MyCharm, meta=MyCharm.META)
-
-    assert MyCharm._foo_called
diff --git a/tests/test_e2e/test_deferred.py b/tests/test_e2e/test_deferred.py
index a96100bc..44e21fce 100644
--- a/tests/test_e2e/test_deferred.py
+++ b/tests/test_e2e/test_deferred.py
@@ -12,7 +12,7 @@
 from ops.framework import Framework
 
 from scenario import Context
-from scenario.state import Container, DeferredEvent, Notice, Relation, State, deferred
+from scenario.state import Container, Notice, Relation, State, _Event
 from tests.helpers import trigger
 
 CHARM_CALLED = 0
@@ -54,7 +54,7 @@ def test_deferred_evt_emitted(mycharm):
     mycharm.defer_next = 2
 
     out = trigger(
-        State(deferred=[deferred(event="update_status", handler=mycharm._on_event)]),
+        State(deferred=[_Event("update_status").deferred(handler=mycharm._on_event)]),
         "start",
         mycharm,
         meta=mycharm.META,
@@ -72,45 +72,6 @@ def test_deferred_evt_emitted(mycharm):
     assert isinstance(start, StartEvent)
 
 
-def test_deferred_relation_event_without_relation_raises(mycharm):
-    with pytest.raises(AttributeError):
-        deferred(event="foo_relation_changed", handler=mycharm._on_event)
-
-
-def test_deferred_relation_evt(mycharm):
-    rel = Relation(endpoint="foo", remote_app_name="remote")
-    evt1 = rel.changed_event.deferred(handler=mycharm._on_event)
-    evt2 = deferred(
-        event="foo_relation_changed",
-        handler=mycharm._on_event,
-        relation=rel,
-    )
-
-    assert asdict(evt2) == asdict(evt1)
-
-
-def test_deferred_workload_evt(mycharm):
-    ctr = Container("foo")
-    evt1 = ctr.pebble_ready_event.deferred(handler=mycharm._on_event)
-    evt2 = deferred(event="foo_pebble_ready", handler=mycharm._on_event, container=ctr)
-
-    assert asdict(evt2) == asdict(evt1)
-
-
-def test_deferred_notice_evt(mycharm):
-    notice = Notice(key="example.com/bar")
-    ctr = Container("foo", notices=[notice])
-    evt1 = ctr.get_notice("example.com/bar").event.deferred(handler=mycharm._on_event)
-    evt2 = deferred(
-        event="foo_pebble_custom_notice",
-        handler=mycharm._on_event,
-        container=ctr,
-        notice=notice,
-    )
-
-    assert asdict(evt2) == asdict(evt1)
-
-
 def test_deferred_relation_event(mycharm):
     mycharm.defer_next = 2
 
@@ -118,12 +79,10 @@ def test_deferred_relation_event(mycharm):
 
     out = trigger(
         State(
-            relations=[rel],
+            relations={rel},
             deferred=[
-                deferred(
-                    event="foo_relation_changed",
+                _Event("foo_relation_changed", relation=rel).deferred(
                     handler=mycharm._on_event,
-                    relation=rel,
                 )
             ],
         ),
@@ -145,13 +104,16 @@ def test_deferred_relation_event(mycharm):
 
 
 def test_deferred_relation_event_from_relation(mycharm):
+    ctx = Context(mycharm, meta=mycharm.META)
     mycharm.defer_next = 2
     rel = Relation(endpoint="foo", remote_app_name="remote")
     out = trigger(
         State(
-            relations=[rel],
+            relations={rel},
             deferred=[
-                rel.changed_event(remote_unit_id=1).deferred(handler=mycharm._on_event)
+                ctx.on.relation_changed(rel, remote_unit=1).deferred(
+                    handler=mycharm._on_event
+                )
             ],
         ),
         "start",
@@ -164,7 +126,7 @@ def test_deferred_relation_event_from_relation(mycharm):
     assert out.deferred[0].name == "foo_relation_changed"
     assert out.deferred[0].snapshot_data == {
         "relation_name": rel.endpoint,
-        "relation_id": rel.relation_id,
+        "relation_id": rel.id,
         "app_name": "remote",
         "unit_name": "remote/1",
     }
@@ -185,8 +147,12 @@ def test_deferred_workload_event(mycharm):
 
     out = trigger(
         State(
-            containers=[ctr],
-            deferred=[ctr.pebble_ready_event.deferred(handler=mycharm._on_event)],
+            containers={ctr},
+            deferred=[
+                _Event("foo_pebble_ready", container=ctr).deferred(
+                    handler=mycharm._on_event
+                )
+            ],
         ),
         "start",
         mycharm,
@@ -210,10 +176,10 @@ def test_defer_reemit_lifecycle_event(mycharm):
     ctx = Context(mycharm, meta=mycharm.META, capture_deferred_events=True)
 
     mycharm.defer_next = 1
-    state_1 = ctx.run("update-status", State())
+    state_1 = ctx.run(ctx.on.update_status(), State())
 
     mycharm.defer_next = 0
-    state_2 = ctx.run("start", state_1)
+    state_2 = ctx.run(ctx.on.start(), state_1)
 
     assert [type(e).__name__ for e in ctx.emitted_events] == [
         "UpdateStatusEvent",
@@ -229,10 +195,10 @@ def test_defer_reemit_relation_event(mycharm):
 
     rel = Relation("foo")
     mycharm.defer_next = 1
-    state_1 = ctx.run(rel.created_event, State(relations=[rel]))
+    state_1 = ctx.run(ctx.on.relation_created(rel), State(relations={rel}))
 
     mycharm.defer_next = 0
-    state_2 = ctx.run("start", state_1)
+    state_2 = ctx.run(ctx.on.start(), state_1)
 
     assert [type(e).__name__ for e in ctx.emitted_events] == [
         "RelationCreatedEvent",
diff --git a/tests/test_e2e/test_event.py b/tests/test_e2e/test_event.py
index f30fc65a..20997421 100644
--- a/tests/test_e2e/test_event.py
+++ b/tests/test_e2e/test_event.py
@@ -3,7 +3,7 @@
 from ops import CharmBase, StartEvent, UpdateStatusEvent
 
 from scenario import Context
-from scenario.state import Event, State, _CharmSpec, _EventType
+from scenario.state import State, _CharmSpec, _Event, _EventType
 
 
 @pytest.mark.parametrize(
@@ -29,7 +29,7 @@
     ),
 )
 def test_event_type(evt, expected_type):
-    event = Event(evt)
+    event = _Event(evt)
     assert event._path.type is expected_type
 
     assert event._is_relation_event is (expected_type is _EventType.relation)
@@ -63,7 +63,7 @@ class MyCharm(CharmBase):
         META = {"name": "joop"}
 
     ctx = Context(MyCharm, meta=MyCharm.META, capture_framework_events=True)
-    ctx.run("update-status", State())
+    ctx.run(ctx.on.update_status(), State())
     assert len(ctx.emitted_events) == 4
     assert list(map(type, ctx.emitted_events)) == [
         ops.UpdateStatusEvent,
@@ -86,7 +86,9 @@ def _foo(self, e):
         capture_deferred_events=True,
         capture_framework_events=True,
     )
-    ctx.run("start", State(deferred=[Event("update-status").deferred(MyCharm._foo)]))
+    ctx.run(
+        ctx.on.start(), State(deferred=[_Event("update-status").deferred(MyCharm._foo)])
+    )
 
     assert len(ctx.emitted_events) == 5
     assert [e.handle.kind for e in ctx.emitted_events] == [
diff --git a/tests/test_e2e/test_event_bind.py b/tests/test_e2e/test_event_bind.py
deleted file mode 100644
index 4878e6ac..00000000
--- a/tests/test_e2e/test_event_bind.py
+++ /dev/null
@@ -1,62 +0,0 @@
-import pytest
-
-from scenario import Container, Event, Relation, Secret, State
-from scenario.state import BindFailedError
-
-
-def test_bind_relation():
-    event = Event("foo-relation-changed")
-    foo_relation = Relation("foo")
-    state = State(relations=[foo_relation])
-    assert event.bind(state).relation is foo_relation
-
-
-def test_bind_relation_complex_name():
-    event = Event("foo-bar-baz-relation-changed")
-    foo_relation = Relation("foo_bar_baz")
-    state = State(relations=[foo_relation])
-    assert event.bind(state).relation is foo_relation
-
-
-def test_bind_relation_notfound():
-    event = Event("foo-relation-changed")
-    state = State(relations=[])
-    with pytest.raises(BindFailedError):
-        event.bind(state)
-
-
-def test_bind_relation_toomany(caplog):
-    event = Event("foo-relation-changed")
-    foo_relation = Relation("foo")
-    foo_relation1 = Relation("foo")
-    state = State(relations=[foo_relation, foo_relation1])
-    event.bind(state)
-    assert "too many relations" in caplog.text
-
-
-def test_bind_secret():
-    event = Event("secret-changed")
-    secret = Secret("foo", {"a": "b"})
-    state = State(secrets=[secret])
-    assert event.bind(state).secret is secret
-
-
-def test_bind_secret_notfound():
-    event = Event("secret-changed")
-    state = State(secrets=[])
-    with pytest.raises(BindFailedError):
-        event.bind(state)
-
-
-def test_bind_container():
-    event = Event("foo-pebble-ready")
-    container = Container("foo")
-    state = State(containers=[container])
-    assert event.bind(state).container is container
-
-
-def test_bind_container_notfound():
-    event = Event("foo-pebble-ready")
-    state = State(containers=[])
-    with pytest.raises(BindFailedError):
-        event.bind(state)
diff --git a/tests/test_e2e/test_juju_log.py b/tests/test_e2e/test_juju_log.py
index 5f58a973..1efd1e70 100644
--- a/tests/test_e2e/test_juju_log.py
+++ b/tests/test_e2e/test_juju_log.py
@@ -5,7 +5,6 @@
 
 from scenario import Context
 from scenario.state import JujuLogLine, State
-from tests.helpers import trigger
 
 logger = logging.getLogger("testing logger")
 
@@ -31,7 +30,7 @@ def _on_event(self, event):
 
 def test_juju_log(mycharm):
     ctx = Context(mycharm, meta=mycharm.META)
-    ctx.run("start", State())
+    ctx.run(ctx.on.start(), State())
     assert ctx.juju_log[-2] == JujuLogLine(
         level="DEBUG", message="Emitting Juju event start."
     )
diff --git a/tests/test_e2e/test_manager.py b/tests/test_e2e/test_manager.py
index e7fefb7a..1856c7ae 100644
--- a/tests/test_e2e/test_manager.py
+++ b/tests/test_e2e/test_manager.py
@@ -4,8 +4,8 @@
 from ops import ActiveStatus
 from ops.charm import CharmBase, CollectStatusEvent
 
-from scenario import Action, Context, State
-from scenario.context import ActionOutput, AlreadyEmittedError, _EventManager
+from scenario import Context, State
+from scenario.context import AlreadyEmittedError, Manager
 
 
 @pytest.fixture(scope="function")
@@ -23,7 +23,6 @@ def _on_event(self, e):
             if isinstance(e, CollectStatusEvent):
                 return
 
-            print("event!")
             self.unit.status = ActiveStatus(e.handle.kind)
 
     return MyCharm
@@ -31,54 +30,34 @@ def _on_event(self, e):
 
 def test_manager(mycharm):
     ctx = Context(mycharm, meta=mycharm.META)
-    with _EventManager(ctx, "start", State()) as manager:
+    with Manager(ctx, ctx.on.start(), State()) as manager:
         assert isinstance(manager.charm, mycharm)
-        assert not manager.output
         state_out = manager.run()
-        assert manager.output is state_out
 
     assert isinstance(state_out, State)
-    assert manager.output  # still there!
-
-
-def test_manager_legacy_pre_post_hooks(mycharm):
-    ctx = Context(mycharm, meta=mycharm.META)
-    post_event = MagicMock()
-    pre_event = MagicMock()
-
-    ctx.run("start", State(), pre_event=pre_event, post_event=post_event)
-
-    assert post_event.called
-    assert isinstance(post_event.call_args.args[0], mycharm)
-    assert pre_event.called
-    assert isinstance(pre_event.call_args.args[0], mycharm)
 
 
 def test_manager_implicit(mycharm):
     ctx = Context(mycharm, meta=mycharm.META)
-    with _EventManager(ctx, "start", State()) as manager:
+    with Manager(ctx, ctx.on.start(), State()) as manager:
         assert isinstance(manager.charm, mycharm)
         # do not call .run()
 
     # run is called automatically
     assert manager._emitted
-    assert manager.output
-    assert manager.output.unit_status == ActiveStatus("start")
 
 
 def test_manager_reemit_fails(mycharm):
     ctx = Context(mycharm, meta=mycharm.META)
-    with _EventManager(ctx, "start", State()) as manager:
+    with Manager(ctx, ctx.on.start(), State()) as manager:
         manager.run()
         with pytest.raises(AlreadyEmittedError):
             manager.run()
 
-    assert manager.output
-
 
 def test_context_manager(mycharm):
     ctx = Context(mycharm, meta=mycharm.META)
-    with ctx.manager("start", State()) as manager:
+    with ctx(ctx.on.start(), State()) as manager:
         state_out = manager.run()
         assert isinstance(state_out, State)
     assert ctx.emitted_events[0].handle.kind == "start"
@@ -86,7 +65,7 @@ def test_context_manager(mycharm):
 
 def test_context_action_manager(mycharm):
     ctx = Context(mycharm, meta=mycharm.META, actions=mycharm.ACTIONS)
-    with ctx.action_manager(Action("do-x"), State()) as manager:
-        ao = manager.run()
-        assert isinstance(ao, ActionOutput)
+    with ctx(ctx.on.action("do-x"), State()) as manager:
+        state_out = manager.run()
+        assert isinstance(state_out, State)
     assert ctx.emitted_events[0].handle.kind == "do_x_action"
diff --git a/tests/test_e2e/test_network.py b/tests/test_e2e/test_network.py
index 28a4133c..7fe78667 100644
--- a/tests/test_e2e/test_network.py
+++ b/tests/test_e2e/test_network.py
@@ -4,7 +4,14 @@
 from ops.framework import Framework
 
 from scenario import Context
-from scenario.state import Network, Relation, State, SubordinateRelation
+from scenario.state import (
+    Address,
+    BindAddress,
+    Network,
+    Relation,
+    State,
+    SubordinateRelation,
+)
 
 
 @pytest.fixture(scope="function")
@@ -40,18 +47,18 @@ def test_ip_get(mycharm):
         },
     )
 
-    with ctx.manager(
-        "update_status",
+    with ctx(
+        ctx.on.update_status(),
         State(
             relations=[
                 Relation(
                     interface="foo",
                     remote_app_name="remote",
                     endpoint="metrics-endpoint",
-                    relation_id=1,
+                    id=1,
                 ),
             ],
-            networks={"foo": Network.default(private_address="4.4.4.4")},
+            networks={Network("foo", [BindAddress([Address("4.4.4.4")])])},
         ),
     ) as mgr:
         # we have a network for the relation
@@ -77,8 +84,8 @@ def test_no_sub_binding(mycharm):
         },
     )
 
-    with ctx.manager(
-        "update_status",
+    with ctx(
+        ctx.on.update_status(),
         State(
             relations=[
                 SubordinateRelation("bar"),
@@ -102,18 +109,18 @@ def test_no_relation_error(mycharm):
         },
     )
 
-    with ctx.manager(
-        "update_status",
+    with ctx(
+        ctx.on.update_status(),
         State(
             relations=[
                 Relation(
                     interface="foo",
                     remote_app_name="remote",
                     endpoint="metrics-endpoint",
-                    relation_id=1,
+                    id=1,
                 ),
             ],
-            networks={"bar": Network.default()},
+            networks={Network("bar")},
         ),
     ) as mgr:
         with pytest.raises(RelationNotFoundError):
@@ -126,8 +133,8 @@ def test_juju_info_network_default(mycharm):
         meta={"name": "foo"},
     )
 
-    with ctx.manager(
-        "update_status",
+    with ctx(
+        ctx.on.update_status(),
         State(),
     ) as mgr:
         # we have a network for the relation
@@ -137,25 +144,6 @@ def test_juju_info_network_default(mycharm):
         )
 
 
-def test_juju_info_network_override(mycharm):
-    ctx = Context(
-        mycharm,
-        meta={"name": "foo"},
-    )
-
-    with ctx.manager(
-        "update_status",
-        State(
-            networks={"juju-info": Network.default(private_address="4.4.4.4")},
-        ),
-    ) as mgr:
-        # we have a network for the relation
-        assert (
-            str(mgr.charm.model.get_binding("juju-info").network.bind_address)
-            == "4.4.4.4"
-        )
-
-
 def test_explicit_juju_info_network_override(mycharm):
     ctx = Context(
         mycharm,
@@ -166,8 +154,8 @@ def test_explicit_juju_info_network_override(mycharm):
         },
     )
 
-    with ctx.manager(
-        "update_status",
+    with ctx(
+        ctx.on.update_status(),
         State(),
     ) as mgr:
         assert mgr.charm.model.get_binding("juju-info").network.bind_address
diff --git a/tests/test_e2e/test_pebble.py b/tests/test_e2e/test_pebble.py
index e5c16bf7..621e4054 100644
--- a/tests/test_e2e/test_pebble.py
+++ b/tests/test_e2e/test_pebble.py
@@ -1,4 +1,6 @@
+import dataclasses
 import datetime
+import io
 import tempfile
 from pathlib import Path
 
@@ -9,8 +11,8 @@
 from ops.pebble import ExecError, ServiceStartup, ServiceStatus
 
 from scenario import Context
-from scenario.state import Container, ExecOutput, Mount, Notice, Port, State
-from tests.helpers import trigger
+from scenario.state import CheckInfo, Container, Exec, Mount, Notice, State
+from tests.helpers import jsonpatch_delta, trigger
 
 
 @pytest.fixture(scope="function")
@@ -60,7 +62,7 @@ def callback(self: CharmBase):
         assert can_connect == self.unit.get_container("foo").can_connect()
 
     trigger(
-        State(containers=[Container(name="foo", can_connect=can_connect)]),
+        State(containers={Container(name="foo", can_connect=can_connect)}),
         charm_type=charm_cls,
         meta={"name": "foo", "containers": {"foo": {}}},
         event="start",
@@ -81,13 +83,13 @@ def callback(self: CharmBase):
 
     trigger(
         State(
-            containers=[
+            containers={
                 Container(
                     name="foo",
                     can_connect=True,
-                    mounts={"bar": Mount("/bar/baz.txt", pth)},
+                    mounts={"bar": Mount(location="/bar/baz.txt", source=pth)},
                 )
-            ]
+            }
         ),
         charm_type=charm_cls,
         meta={"name": "foo", "containers": {"foo": {}}},
@@ -96,10 +98,6 @@ def callback(self: CharmBase):
     )
 
 
-def test_port_equality():
-    assert Port("tcp", 42) == Port("tcp", 42)
-
-
 @pytest.mark.parametrize("make_dirs", (True, False))
 def test_fs_pull(charm_cls, make_dirs):
     text = "lorem ipsum/n alles amat gloriae foo"
@@ -121,29 +119,30 @@ def callback(self: CharmBase):
 
     td = tempfile.TemporaryDirectory()
     container = Container(
-        name="foo", can_connect=True, mounts={"foo": Mount("/foo", td.name)}
+        name="foo",
+        can_connect=True,
+        mounts={"foo": Mount(location="/foo", source=td.name)},
     )
-    state = State(containers=[container])
+    state = State(containers={container})
 
     ctx = Context(
         charm_type=charm_cls,
         meta={"name": "foo", "containers": {"foo": {}}},
     )
-    out = ctx.run(
-        event="start",
-        state=state,
-        post_event=callback,
-    )
+    with ctx(ctx.on.start(), state=state) as mgr:
+        out = mgr.run()
+        callback(mgr.charm)
 
     if make_dirs:
-        # file = (out.get_container("foo").mounts["foo"].src + "bar/baz.txt").open("/foo/bar/baz.txt")
+        # file = (out.get_container("foo").mounts["foo"].source + "bar/baz.txt").open("/foo/bar/baz.txt")
 
         # this is one way to retrieve the file
         file = Path(td.name + "/bar/baz.txt")
 
         # another is:
         assert (
-            file == Path(out.get_container("foo").mounts["foo"].src) / "bar" / "baz.txt"
+            file
+            == Path(out.get_container("foo").mounts["foo"].source) / "bar" / "baz.txt"
         )
 
         # but that is actually a symlink to the context's root tmp folder:
@@ -158,8 +157,8 @@ def callback(self: CharmBase):
 
     else:
         # nothing has changed
-        out_purged = out.replace(stored_state=state.stored_state)
-        assert not out_purged.jsonpatch_delta(state)
+        out_purged = dataclasses.replace(out, stored_states=state.stored_states)
+        assert not jsonpatch_delta(out_purged, state)
 
 
 LS = """
@@ -195,17 +194,17 @@ def callback(self: CharmBase):
         container = self.unit.get_container("foo")
         proc = container.exec([cmd])
         proc.wait()
-        assert proc.stdout.read() == "hello pebble"
+        assert proc.stdout.read() == out
 
     trigger(
         State(
-            containers=[
+            containers={
                 Container(
                     name="foo",
                     can_connect=True,
-                    exec_mock={(cmd,): ExecOutput(stdout="hello pebble")},
+                    execs={Exec([cmd], stdout=out)},
                 )
-            ]
+            }
         ),
         charm_type=charm_cls,
         meta={"name": "foo", "containers": {"foo": {}}},
@@ -214,6 +213,32 @@ def callback(self: CharmBase):
     )
 
 
+@pytest.mark.parametrize(
+    "stdin,write",
+    (
+        [None, "hello world!"],
+        ["hello world!", None],
+        [io.StringIO("hello world!"), None],
+    ),
+)
+def test_exec_history_stdin(stdin, write):
+    class MyCharm(CharmBase):
+        def __init__(self, framework: Framework):
+            super().__init__(framework)
+            self.framework.observe(self.on.foo_pebble_ready, self._on_ready)
+
+        def _on_ready(self, _):
+            proc = self.unit.get_container("foo").exec(["ls"], stdin=stdin)
+            if write:
+                proc.stdin.write(write)
+            proc.wait()
+
+    ctx = Context(MyCharm, meta={"name": "foo", "containers": {"foo": {}}})
+    container = Container(name="foo", can_connect=True, execs={Exec([])})
+    ctx.run(ctx.on.pebble_ready(container=container), State(containers={container}))
+    assert ctx.exec_history[container.name][0].stdin == "hello world!"
+
+
 def test_pebble_ready(charm_cls):
     def callback(self: CharmBase):
         foo = self.unit.get_container("foo")
@@ -222,47 +247,52 @@ def callback(self: CharmBase):
     container = Container(name="foo", can_connect=True)
 
     trigger(
-        State(containers=[container]),
+        State(containers={container}),
         charm_type=charm_cls,
         meta={"name": "foo", "containers": {"foo": {}}},
-        event=container.pebble_ready_event,
+        event="pebble_ready",
         post_event=callback,
     )
 
 
 @pytest.mark.parametrize("starting_service_status", pebble.ServiceStatus)
 def test_pebble_plan(charm_cls, starting_service_status):
-    def callback(self: CharmBase):
-        foo = self.unit.get_container("foo")
+    class PlanCharm(charm_cls):
+        def __init__(self, framework):
+            super().__init__(framework)
+            framework.observe(self.on.foo_pebble_ready, self._on_ready)
 
-        assert foo.get_plan().to_dict() == {
-            "services": {"fooserv": {"startup": "enabled"}}
-        }
-        fooserv = foo.get_services("fooserv")["fooserv"]
-        assert fooserv.startup == ServiceStartup.ENABLED
-        assert fooserv.current == ServiceStatus.ACTIVE
-
-        foo.add_layer(
-            "bar",
-            {
-                "summary": "bla",
-                "description": "deadbeef",
-                "services": {"barserv": {"startup": "disabled"}},
-            },
-        )
+        def _on_ready(self, event):
+            foo = event.workload
 
-        foo.replan()
-        assert foo.get_plan().to_dict() == {
-            "services": {
-                "barserv": {"startup": "disabled"},
-                "fooserv": {"startup": "enabled"},
+            assert foo.get_plan().to_dict() == {
+                "services": {"fooserv": {"startup": "enabled"}}
             }
-        }
+            fooserv = foo.get_services("fooserv")["fooserv"]
+            assert fooserv.startup == ServiceStartup.ENABLED
+            assert fooserv.current == ServiceStatus.ACTIVE
 
-        assert foo.get_service("barserv").current == starting_service_status
-        foo.start("barserv")
-        # whatever the original state, starting a service sets it to active
-        assert foo.get_service("barserv").current == ServiceStatus.ACTIVE
+            foo.add_layer(
+                "bar",
+                {
+                    "summary": "bla",
+                    "description": "deadbeef",
+                    "services": {"barserv": {"startup": "disabled"}},
+                },
+            )
+
+            foo.replan()
+            assert foo.get_plan().to_dict() == {
+                "services": {
+                    "barserv": {"startup": "disabled"},
+                    "fooserv": {"startup": "enabled"},
+                }
+            }
+
+            assert foo.get_service("barserv").current == starting_service_status
+            foo.start("barserv")
+            # whatever the original state, starting a service sets it to active
+            assert foo.get_service("barserv").current == ServiceStatus.ACTIVE
 
     container = Container(
         name="foo",
@@ -276,7 +306,7 @@ def callback(self: CharmBase):
                 }
             )
         },
-        service_status={
+        service_statuses={
             "fooserv": pebble.ServiceStatus.ACTIVE,
             # todo: should we disallow setting status for services that aren't known YET?
             "barserv": starting_service_status,
@@ -284,15 +314,14 @@ def callback(self: CharmBase):
     )
 
     out = trigger(
-        State(containers=[container]),
-        charm_type=charm_cls,
+        State(containers={container}),
+        charm_type=PlanCharm,
         meta={"name": "foo", "containers": {"foo": {}}},
-        event=container.pebble_ready_event,
-        post_event=callback,
+        event="pebble_ready",
     )
 
     serv = lambda name, obj: pebble.Service(name, raw=obj)
-    container = out.containers[0]
+    container = out.get_container(container.name)
     assert container.plan.services == {
         "barserv": serv("barserv", {"startup": "disabled"}),
         "fooserv": serv("fooserv", {"startup": "enabled"}),
@@ -306,62 +335,59 @@ def callback(self: CharmBase):
 
 def test_exec_wait_error(charm_cls):
     state = State(
-        containers=[
+        containers={
             Container(
                 name="foo",
                 can_connect=True,
-                exec_mock={("foo",): ExecOutput(stdout="hello pebble", return_code=1)},
+                execs={Exec(["foo"], stdout="hello pebble", return_code=1)},
             )
-        ]
+        }
     )
 
-    with Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}).manager(
-        "start", state
-    ) as mgr:
+    ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}})
+    with ctx(ctx.on.start(), state) as mgr:
         container = mgr.charm.unit.get_container("foo")
         proc = container.exec(["foo"])
-        with pytest.raises(ExecError):
-            proc.wait()
-        assert proc.stdout.read() == "hello pebble"
+        with pytest.raises(ExecError) as exc_info:
+            proc.wait_output()
+        assert exc_info.value.stdout == "hello pebble"
 
 
-def test_exec_wait_output(charm_cls):
+@pytest.mark.parametrize("command", (["foo"], ["foo", "bar"], ["foo", "bar", "baz"]))
+def test_exec_wait_output(charm_cls, command):
     state = State(
-        containers=[
+        containers={
             Container(
                 name="foo",
                 can_connect=True,
-                exec_mock={
-                    ("foo",): ExecOutput(stdout="hello pebble", stderr="oepsie")
-                },
+                execs={Exec(["foo"], stdout="hello pebble", stderr="oepsie")},
             )
-        ]
+        }
     )
 
-    with Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}).manager(
-        "start", state
-    ) as mgr:
+    ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}})
+    with ctx(ctx.on.start(), state) as mgr:
         container = mgr.charm.unit.get_container("foo")
-        proc = container.exec(["foo"])
+        proc = container.exec(command)
         out, err = proc.wait_output()
         assert out == "hello pebble"
         assert err == "oepsie"
+        assert ctx.exec_history[container.name][0].command == command
 
 
 def test_exec_wait_output_error(charm_cls):
     state = State(
-        containers=[
+        containers={
             Container(
                 name="foo",
                 can_connect=True,
-                exec_mock={("foo",): ExecOutput(stdout="hello pebble", return_code=1)},
+                execs={Exec(["foo"], stdout="hello pebble", return_code=1)},
             )
-        ]
+        }
     )
 
-    with Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}}).manager(
-        "start", state
-    ) as mgr:
+    ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}})
+    with ctx(ctx.on.start(), state) as mgr:
         container = mgr.charm.unit.get_container("foo")
         proc = container.exec(["foo"])
         with pytest.raises(ExecError):
@@ -382,7 +408,9 @@ def test_pebble_custom_notice(charm_cls):
 
     state = State(containers=[container])
     ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}})
-    with ctx.manager(container.get_notice("example.com/baz").event, state) as mgr:
+    with ctx(
+        ctx.on.pebble_custom_notice(container=container, notice=notices[-1]), state
+    ) as mgr:
         container = mgr.charm.unit.get_container("foo")
         assert container.get_notices() == [n._to_ops() for n in notices]
 
@@ -438,4 +466,83 @@ def _on_custom_notice(self, event: PebbleCustomNoticeEvent):
     )
     state = State(containers=[container])
     ctx = Context(MyCharm, meta={"name": "foo", "containers": {"foo": {}}})
-    ctx.run(container.get_notice(key).event, state)
+    ctx.run(ctx.on.pebble_custom_notice(container=container, notice=notices[-1]), state)
+
+
+def test_pebble_check_failed():
+    infos = []
+
+    class MyCharm(CharmBase):
+        def __init__(self, framework):
+            super().__init__(framework)
+            framework.observe(self.on.foo_pebble_check_failed, self._on_check_failed)
+
+        def _on_check_failed(self, event):
+            infos.append(event.info)
+
+    ctx = Context(MyCharm, meta={"name": "foo", "containers": {"foo": {}}})
+    check = CheckInfo("http-check", failures=7, status=pebble.CheckStatus.DOWN)
+    container = Container("foo", check_infos={check})
+    state = State(containers={container})
+    ctx.run(ctx.on.pebble_check_failed(container, check), state=state)
+    assert len(infos) == 1
+    assert infos[0].name == "http-check"
+    assert infos[0].status == pebble.CheckStatus.DOWN
+    assert infos[0].failures == 7
+
+
+def test_pebble_check_recovered():
+    infos = []
+
+    class MyCharm(CharmBase):
+        def __init__(self, framework):
+            super().__init__(framework)
+            framework.observe(
+                self.on.foo_pebble_check_recovered, self._on_check_recovered
+            )
+
+        def _on_check_recovered(self, event):
+            infos.append(event.info)
+
+    ctx = Context(MyCharm, meta={"name": "foo", "containers": {"foo": {}}})
+    check = CheckInfo("http-check")
+    container = Container("foo", check_infos={check})
+    state = State(containers={container})
+    ctx.run(ctx.on.pebble_check_recovered(container, check), state=state)
+    assert len(infos) == 1
+    assert infos[0].name == "http-check"
+    assert infos[0].status == pebble.CheckStatus.UP
+    assert infos[0].failures == 0
+
+
+def test_pebble_check_failed_two_containers():
+    foo_infos = []
+    bar_infos = []
+
+    class MyCharm(CharmBase):
+        def __init__(self, framework):
+            super().__init__(framework)
+            framework.observe(
+                self.on.foo_pebble_check_failed, self._on_foo_check_failed
+            )
+            framework.observe(
+                self.on.bar_pebble_check_failed, self._on_bar_check_failed
+            )
+
+        def _on_foo_check_failed(self, event):
+            foo_infos.append(event.info)
+
+        def _on_bar_check_failed(self, event):
+            bar_infos.append(event.info)
+
+    ctx = Context(MyCharm, meta={"name": "foo", "containers": {"foo": {}, "bar": {}}})
+    check = CheckInfo("http-check", failures=7, status=pebble.CheckStatus.DOWN)
+    foo_container = Container("foo", check_infos={check})
+    bar_container = Container("bar", check_infos={check})
+    state = State(containers={foo_container, bar_container})
+    ctx.run(ctx.on.pebble_check_failed(foo_container, check), state=state)
+    assert len(foo_infos) == 1
+    assert foo_infos[0].name == "http-check"
+    assert foo_infos[0].status == pebble.CheckStatus.DOWN
+    assert foo_infos[0].failures == 7
+    assert len(bar_infos) == 0
diff --git a/tests/test_e2e/test_play_assertions.py b/tests/test_e2e/test_play_assertions.py
index b8b92d5a..103940af 100644
--- a/tests/test_e2e/test_play_assertions.py
+++ b/tests/test_e2e/test_play_assertions.py
@@ -1,10 +1,12 @@
+import dataclasses
+
 import pytest
 from ops.charm import CharmBase
 from ops.framework import Framework
 from ops.model import ActiveStatus, BlockedStatus
 
 from scenario.state import Relation, State
-from tests.helpers import trigger
+from tests.helpers import jsonpatch_delta, trigger
 
 
 @pytest.fixture(scope="function")
@@ -60,8 +62,8 @@ def post_event(charm):
 
     assert out.unit_status == ActiveStatus("yabadoodle")
 
-    out_purged = out.replace(stored_state=initial_state.stored_state)
-    assert out_purged.jsonpatch_delta(initial_state) == [
+    out_purged = dataclasses.replace(out, stored_states=initial_state.stored_states)
+    assert jsonpatch_delta(out_purged, initial_state) == [
         {
             "op": "replace",
             "path": "/unit_status/message",
@@ -98,16 +100,16 @@ def check_relation_data(charm):
         assert remote_app_data == {"yaba": "doodle"}
 
     state_in = State(
-        relations=[
+        relations={
             Relation(
                 endpoint="relation_test",
                 interface="azdrubales",
-                relation_id=1,
+                id=1,
                 remote_app_name="karlos",
                 remote_app_data={"yaba": "doodle"},
                 remote_units_data={0: {"foo": "bar"}, 1: {"baz": "qux"}},
             )
-        ]
+        }
     )
     trigger(
         state_in,
diff --git a/tests/test_e2e/test_ports.py b/tests/test_e2e/test_ports.py
index ccff9366..9e46665a 100644
--- a/tests/test_e2e/test_ports.py
+++ b/tests/test_e2e/test_ports.py
@@ -1,13 +1,25 @@
 import pytest
-from ops import CharmBase
+from ops import CharmBase, Framework, StartEvent, StopEvent
 
 from scenario import Context, State
-from scenario.state import Port
+from scenario.state import Port, StateValidationError, TCPPort, UDPPort
 
 
 class MyCharm(CharmBase):
     META = {"name": "edgar"}
 
+    def __init__(self, framework: Framework):
+        super().__init__(framework)
+        framework.observe(self.on.start, self._open_port)
+        framework.observe(self.on.stop, self._close_port)
+
+    def _open_port(self, _: StartEvent):
+        self.unit.open_port("tcp", 12)
+
+    def _close_port(self, _: StopEvent):
+        assert self.unit.opened_ports()
+        self.unit.close_port("tcp", 42)
+
 
 @pytest.fixture
 def ctx():
@@ -15,20 +27,27 @@ def ctx():
 
 
 def test_open_port(ctx):
-    def post_event(charm: CharmBase):
-        charm.unit.open_port("tcp", 12)
-
-    out = ctx.run("start", State(), post_event=post_event)
-    port = out.opened_ports.pop()
+    out = ctx.run(ctx.on.start(), State())
+    assert len(out.opened_ports) == 1
+    port = tuple(out.opened_ports)[0]
 
     assert port.protocol == "tcp"
     assert port.port == 12
 
 
 def test_close_port(ctx):
-    def post_event(charm: CharmBase):
-        assert charm.unit.opened_ports()
-        charm.unit.close_port("tcp", 42)
-
-    out = ctx.run("start", State(opened_ports={Port("tcp", 42)}), post_event=post_event)
+    out = ctx.run(ctx.on.stop(), State(opened_ports={TCPPort(42)}))
     assert not out.opened_ports
+
+
+def test_port_no_arguments():
+    with pytest.raises(RuntimeError):
+        Port()
+
+
+@pytest.mark.parametrize("klass", (TCPPort, UDPPort))
+def test_port_port(klass):
+    with pytest.raises(StateValidationError):
+        klass(port=0)
+    with pytest.raises(StateValidationError):
+        klass(port=65536)
diff --git a/tests/test_e2e/test_relations.py b/tests/test_e2e/test_relations.py
index 212e12a6..b7880425 100644
--- a/tests/test_e2e/test_relations.py
+++ b/tests/test_e2e/test_relations.py
@@ -5,6 +5,8 @@
     CharmBase,
     CharmEvents,
     CollectStatusEvent,
+    RelationBrokenEvent,
+    RelationCreatedEvent,
     RelationDepartedEvent,
     RelationEvent,
 )
@@ -12,13 +14,14 @@
 
 from scenario import Context
 from scenario.state import (
-    DEFAULT_JUJU_DATABAG,
+    _DEFAULT_JUJU_DATABAG,
     PeerRelation,
     Relation,
     RelationBase,
     State,
     StateValidationError,
     SubordinateRelation,
+    _next_relation_id,
 )
 from tests.helpers import trigger
 
@@ -61,10 +64,10 @@ def pre_event(charm: CharmBase):
         State(
             config={"foo": "bar"},
             leader=True,
-            relations=[
+            relations={
                 Relation(endpoint="foo", interface="foo", remote_app_name="remote"),
                 Relation(endpoint="qux", interface="qux", remote_app_name="remote"),
-            ],
+            },
         ),
         "start",
         mycharm,
@@ -80,6 +83,7 @@ def pre_event(charm: CharmBase):
             },
         },
         config={"options": {"foo": {"type": "string"}}},
+        pre_event=pre_event,
     )
 
 
@@ -93,11 +97,11 @@ def test_relation_events(mycharm, evt_name):
 
     trigger(
         State(
-            relations=[
+            relations={
                 relation,
-            ],
+            },
         ),
-        getattr(relation, f"{evt_name}_event"),
+        f"relation_{evt_name}",
         mycharm,
         meta={
             "name": "local",
@@ -137,11 +141,11 @@ def callback(charm: CharmBase, e):
 
     trigger(
         State(
-            relations=[
+            relations={
                 relation,
-            ],
+            },
         ),
-        getattr(relation, f"{evt_name}_event"),
+        f"relation_{evt_name}",
         mycharm,
         meta={
             "name": "local",
@@ -153,8 +157,14 @@ def callback(charm: CharmBase, e):
 
 
 @pytest.mark.parametrize(
-    "evt_name",
-    ("changed", "broken", "departed", "joined", "created"),
+    "evt_name,has_unit",
+    [
+        ("changed", True),
+        ("broken", False),
+        ("departed", True),
+        ("joined", True),
+        ("created", False),
+    ],
 )
 @pytest.mark.parametrize(
     "remote_app_name",
@@ -164,7 +174,9 @@ def callback(charm: CharmBase, e):
     "remote_unit_id",
     (0, 1),
 )
-def test_relation_events_attrs(mycharm, evt_name, remote_app_name, remote_unit_id):
+def test_relation_events_attrs(
+    mycharm, evt_name, has_unit, remote_app_name, remote_unit_id
+):
     relation = Relation(
         endpoint="foo", interface="foo", remote_app_name=remote_app_name
     )
@@ -174,20 +186,15 @@ def callback(charm: CharmBase, event):
             return
 
         assert event.app
-        assert event.unit
+        if not isinstance(event, (RelationCreatedEvent, RelationBrokenEvent)):
+            assert event.unit
         if isinstance(event, RelationDepartedEvent):
             assert event.departing_unit
 
     mycharm._call = callback
 
-    trigger(
-        State(
-            relations=[
-                relation,
-            ],
-        ),
-        getattr(relation, f"{evt_name}_event")(remote_unit_id=remote_unit_id),
-        mycharm,
+    ctx = Context(
+        charm_type=mycharm,
         meta={
             "name": "local",
             "requires": {
@@ -195,6 +202,12 @@ def callback(charm: CharmBase, event):
             },
         },
     )
+    state = State(relations={relation})
+    kwargs = {}
+    if has_unit:
+        kwargs["remote_unit"] = remote_unit_id
+    event = getattr(ctx.on, f"relation_{evt_name}")(relation, **kwargs)
+    ctx.run(event, state=state)
 
 
 @pytest.mark.parametrize(
@@ -218,18 +231,22 @@ def callback(charm: CharmBase, event):
             return
 
         assert event.app  # that's always present
-        assert event.unit
+        # .unit is always None for created and broken.
+        if isinstance(event, (RelationCreatedEvent, RelationBrokenEvent)):
+            assert event.unit is None
+        else:
+            assert event.unit
         assert (evt_name == "departed") is bool(getattr(event, "departing_unit", False))
 
     mycharm._call = callback
 
     trigger(
         State(
-            relations=[
+            relations={
                 relation,
-            ],
+            },
         ),
-        getattr(relation, f"{evt_name}_event"),
+        f"relation_{evt_name}",
         mycharm,
         meta={
             "name": "local",
@@ -239,26 +256,28 @@ def callback(charm: CharmBase, event):
         },
     )
 
-    assert (
-        "remote unit ID unset, and multiple remote unit IDs are present" in caplog.text
-    )
+    if evt_name not in ("created", "broken"):
+        assert (
+            "remote unit ID unset, and multiple remote unit IDs are present"
+            in caplog.text
+        )
 
 
 def test_relation_default_unit_data_regular():
     relation = Relation("baz")
-    assert relation.local_unit_data == DEFAULT_JUJU_DATABAG
-    assert relation.remote_units_data == {0: DEFAULT_JUJU_DATABAG}
+    assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG
+    assert relation.remote_units_data == {0: _DEFAULT_JUJU_DATABAG}
 
 
 def test_relation_default_unit_data_sub():
     relation = SubordinateRelation("baz")
-    assert relation.local_unit_data == DEFAULT_JUJU_DATABAG
-    assert relation.remote_unit_data == DEFAULT_JUJU_DATABAG
+    assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG
+    assert relation.remote_unit_data == _DEFAULT_JUJU_DATABAG
 
 
 def test_relation_default_unit_data_peer():
     relation = PeerRelation("baz")
-    assert relation.local_unit_data == DEFAULT_JUJU_DATABAG
+    assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG
 
 
 @pytest.mark.parametrize(
@@ -283,11 +302,11 @@ def callback(charm: CharmBase, event):
 
     trigger(
         State(
-            relations=[
+            relations={
                 relation,
-            ],
+            },
         ),
-        getattr(relation, f"{evt_name}_event"),
+        f"relation_{evt_name}",
         mycharm,
         meta={
             "name": "local",
@@ -297,7 +316,8 @@ def callback(charm: CharmBase, event):
         },
     )
 
-    assert "remote unit ID unset; no remote unit data present" in caplog.text
+    if evt_name not in ("created", "broken"):
+        assert "remote unit ID unset; no remote unit data present" in caplog.text
 
 
 @pytest.mark.parametrize("data", (set(), {}, [], (), 1, 1.0, None, b""))
@@ -336,8 +356,8 @@ def test_relation_event_trigger(relation, evt_name, mycharm):
         "peers": {"b": {"interface": "i2"}},
     }
     state = trigger(
-        State(relations=[relation]),
-        getattr(relation, evt_name + "_event"),
+        State(relations={relation}),
+        f"relation_{evt_name}",
         mycharm,
         meta=meta,
     )
@@ -369,8 +389,8 @@ def post_event(charm: CharmBase):
             assert len(relation.units) == 1
 
     trigger(
-        State(relations=[sub1, sub2]),
-        "update-status",
+        State(relations={sub1, sub2}),
+        "update_status",
         mycharm,
         meta=meta,
         post_event=post_event,
@@ -388,16 +408,68 @@ def test_relation_ids():
     initial_id = _next_relation_id_counter
     for i in range(10):
         rel = Relation("foo")
-        assert rel.relation_id == initial_id + i
+        assert rel.id == initial_id + i
 
 
 def test_broken_relation_not_in_model_relations(mycharm):
     rel = Relation("foo")
 
-    with Context(
+    ctx = Context(
         mycharm, meta={"name": "local", "requires": {"foo": {"interface": "foo"}}}
-    ).manager(rel.broken_event, state=State(relations=[rel])) as mgr:
+    )
+    with ctx(ctx.on.relation_broken(rel), state=State(relations={rel})) as mgr:
         charm = mgr.charm
 
         assert charm.model.get_relation("foo") is None
         assert charm.model.relations["foo"] == []
+
+
+@pytest.mark.parametrize("klass", (Relation, PeerRelation, SubordinateRelation))
+def test_relation_positional_arguments(klass):
+    with pytest.raises(TypeError):
+        klass("foo", "bar", None)
+
+
+def test_relation_default_values():
+    expected_id = _next_relation_id(update=False)
+    endpoint = "database"
+    interface = "postgresql"
+    relation = Relation(endpoint, interface)
+    assert relation.id == expected_id
+    assert relation.endpoint == endpoint
+    assert relation.interface == interface
+    assert relation.local_app_data == {}
+    assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG
+    assert relation.remote_app_name == "remote"
+    assert relation.limit == 1
+    assert relation.remote_app_data == {}
+    assert relation.remote_units_data == {0: _DEFAULT_JUJU_DATABAG}
+
+
+def test_subordinate_relation_default_values():
+    expected_id = _next_relation_id(update=False)
+    endpoint = "database"
+    interface = "postgresql"
+    relation = SubordinateRelation(endpoint, interface)
+    assert relation.id == expected_id
+    assert relation.endpoint == endpoint
+    assert relation.interface == interface
+    assert relation.local_app_data == {}
+    assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG
+    assert relation.remote_app_name == "remote"
+    assert relation.remote_unit_id == 0
+    assert relation.remote_app_data == {}
+    assert relation.remote_unit_data == _DEFAULT_JUJU_DATABAG
+
+
+def test_peer_relation_default_values():
+    expected_id = _next_relation_id(update=False)
+    endpoint = "peers"
+    interface = "shared"
+    relation = PeerRelation(endpoint, interface)
+    assert relation.id == expected_id
+    assert relation.endpoint == endpoint
+    assert relation.interface == interface
+    assert relation.local_app_data == {}
+    assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG
+    assert relation.peers_data == {0: _DEFAULT_JUJU_DATABAG}
diff --git a/tests/test_e2e/test_resource.py b/tests/test_e2e/test_resource.py
new file mode 100644
index 00000000..aebe8a0d
--- /dev/null
+++ b/tests/test_e2e/test_resource.py
@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+# Copyright 2024 Canonical Ltd.
+# See LICENSE file for licensing details.
+
+import pathlib
+
+import ops
+import pytest
+
+from scenario import Context, Resource, State
+
+
+class ResourceCharm(ops.CharmBase):
+    def __init__(self, framework):
+        super().__init__(framework)
+
+
+def test_get_resource():
+    ctx = Context(
+        ResourceCharm,
+        meta={
+            "name": "resource-charm",
+            "resources": {"foo": {"type": "file"}, "bar": {"type": "file"}},
+        },
+    )
+    resource1 = Resource(name="foo", path=pathlib.Path("/tmp/foo"))
+    resource2 = Resource(name="bar", path=pathlib.Path("~/bar"))
+    with ctx(
+        ctx.on.update_status(), state=State(resources={resource1, resource2})
+    ) as mgr:
+        assert mgr.charm.model.resources.fetch("foo") == resource1.path
+        assert mgr.charm.model.resources.fetch("bar") == resource2.path
+        with pytest.raises(NameError):
+            mgr.charm.model.resources.fetch("baz")
diff --git a/tests/test_e2e/test_rubbish_events.py b/tests/test_e2e/test_rubbish_events.py
index 10582d82..0656b80c 100644
--- a/tests/test_e2e/test_rubbish_events.py
+++ b/tests/test_e2e/test_rubbish_events.py
@@ -5,7 +5,7 @@
 from ops.framework import EventBase, EventSource, Framework, Object
 
 from scenario.ops_main_mock import NoObserverError
-from scenario.state import Container, Event, State, _CharmSpec
+from scenario.state import Container, State, _CharmSpec, _Event
 from tests.helpers import trigger
 
 
@@ -46,7 +46,7 @@ def _on_event(self, e):
 
 @pytest.mark.parametrize("evt_name", ("rubbish", "foo", "bar", "kazoo_pebble_ready"))
 def test_rubbish_event_raises(mycharm, evt_name):
-    with pytest.raises(NoObserverError):
+    with pytest.raises(AttributeError):
         if evt_name.startswith("kazoo"):
             os.environ["SCENARIO_SKIP_CONSISTENCY_CHECKS"] = "true"
             # else it will whine about the container not being in state and meta;
@@ -59,14 +59,15 @@ def test_rubbish_event_raises(mycharm, evt_name):
 
 
 @pytest.mark.parametrize("evt_name", ("qux",))
-def test_custom_events_pass(mycharm, evt_name):
-    trigger(State(), evt_name, mycharm, meta={"name": "foo"})
+def test_custom_events_fail(mycharm, evt_name):
+    with pytest.raises(AttributeError):
+        trigger(State(), evt_name, mycharm, meta={"name": "foo"})
 
 
 # cfr: https://github.com/PietroPasotti/ops-scenario/pull/11#discussion_r1101694961
 @pytest.mark.parametrize("evt_name", ("sub",))
 def test_custom_events_sub_raise(mycharm, evt_name):
-    with pytest.raises(RuntimeError):
+    with pytest.raises(AttributeError):
         trigger(State(), evt_name, mycharm, meta={"name": "foo"})
 
 
@@ -86,4 +87,4 @@ def test_is_custom_event(mycharm, evt_name, expected):
     spec = _CharmSpec(
         charm_type=mycharm, meta={"name": "mycharm", "requires": {"foo": {}}}
     )
-    assert Event(evt_name)._is_builtin_event(spec) is expected
+    assert _Event(evt_name)._is_builtin_event(spec) is expected
diff --git a/tests/test_e2e/test_secrets.py b/tests/test_e2e/test_secrets.py
index 26988245..0d5730df 100644
--- a/tests/test_e2e/test_secrets.py
+++ b/tests/test_e2e/test_secrets.py
@@ -1,5 +1,4 @@
 import datetime
-import warnings
 
 import pytest
 from ops import (
@@ -34,137 +33,131 @@ def _on_event(self, event):
 
 
 def test_get_secret_no_secret(mycharm):
-    with Context(mycharm, meta={"name": "local"}).manager(
-        "update_status", State()
-    ) as mgr:
+    ctx = Context(mycharm, meta={"name": "local"})
+    with ctx(ctx.on.update_status(), State()) as mgr:
         with pytest.raises(SecretNotFoundError):
             assert mgr.charm.model.get_secret(id="foo")
         with pytest.raises(SecretNotFoundError):
             assert mgr.charm.model.get_secret(label="foo")
 
 
-def test_get_secret(mycharm):
-    with Context(mycharm, meta={"name": "local"}).manager(
-        state=State(secrets=[Secret(id="foo", contents={0: {"a": "b"}}, granted=True)]),
-        event="update_status",
-    ) as mgr:
-        assert mgr.charm.model.get_secret(id="foo").get_content()["a"] == "b"
-
-
-def test_get_secret_not_granted(mycharm):
-    with Context(mycharm, meta={"name": "local"}).manager(
-        state=State(secrets=[]),
-        event="update_status",
+@pytest.mark.parametrize("owner", ("app", "unit"))
+def test_get_secret(mycharm, owner):
+    ctx = Context(mycharm, meta={"name": "local"})
+    secret = Secret({"a": "b"}, owner=owner)
+    with ctx(
+        state=State(secrets={secret}),
+        event=ctx.on.update_status(),
     ) as mgr:
-        with pytest.raises(SecretNotFoundError) as e:
-            assert mgr.charm.model.get_secret(id="foo").get_content()["a"] == "b"
+        assert mgr.charm.model.get_secret(id=secret.id).get_content()["a"] == "b"
 
 
-@pytest.mark.parametrize("owner", ("app", "unit", "application"))
-# "application" is deprecated but still supported
+@pytest.mark.parametrize("owner", ("app", "unit"))
 def test_get_secret_get_refresh(mycharm, owner):
-    with Context(mycharm, meta={"name": "local"}).manager(
-        "update_status",
-        State(
-            secrets=[
-                Secret(
-                    id="foo",
-                    contents={
-                        0: {"a": "b"},
-                        1: {"a": "c"},
-                    },
-                    owner=owner,
-                )
-            ]
-        ),
+    ctx = Context(mycharm, meta={"name": "local"})
+    secret = Secret(
+        tracked_content={"a": "b"},
+        latest_content={"a": "c"},
+        owner=owner,
+    )
+    with ctx(
+        ctx.on.update_status(),
+        State(secrets={secret}),
     ) as mgr:
         charm = mgr.charm
-        assert charm.model.get_secret(id="foo").get_content(refresh=True)["a"] == "c"
+        assert (
+            charm.model.get_secret(id=secret.id).get_content(refresh=True)["a"] == "c"
+        )
 
 
 @pytest.mark.parametrize("app", (True, False))
 def test_get_secret_nonowner_peek_update(mycharm, app):
-    with Context(mycharm, meta={"name": "local"}).manager(
-        "update_status",
+    ctx = Context(mycharm, meta={"name": "local"})
+    secret = Secret(
+        tracked_content={"a": "b"},
+        latest_content={"a": "c"},
+    )
+    with ctx(
+        ctx.on.update_status(),
         State(
             leader=app,
-            secrets=[
-                Secret(
-                    id="foo",
-                    contents={
-                        0: {"a": "b"},
-                        1: {"a": "c"},
-                    },
-                ),
-            ],
+            secrets={secret},
         ),
     ) as mgr:
         charm = mgr.charm
-        assert charm.model.get_secret(id="foo").get_content()["a"] == "b"
-        assert charm.model.get_secret(id="foo").peek_content()["a"] == "c"
-        assert charm.model.get_secret(id="foo").get_content()["a"] == "b"
+        assert charm.model.get_secret(id=secret.id).get_content()["a"] == "b"
+        assert charm.model.get_secret(id=secret.id).peek_content()["a"] == "c"
+        # Verify that the peek has not refreshed:
+        assert charm.model.get_secret(id=secret.id).get_content()["a"] == "b"
 
-        assert charm.model.get_secret(id="foo").get_content(refresh=True)["a"] == "c"
-        assert charm.model.get_secret(id="foo").get_content()["a"] == "c"
+        assert (
+            charm.model.get_secret(id=secret.id).get_content(refresh=True)["a"] == "c"
+        )
+        assert charm.model.get_secret(id=secret.id).get_content()["a"] == "c"
 
 
-@pytest.mark.parametrize("owner", ("app", "unit", "application"))
-# "application" is deprecated but still supported
+@pytest.mark.parametrize("owner", ("app", "unit"))
 def test_get_secret_owner_peek_update(mycharm, owner):
-    with Context(mycharm, meta={"name": "local"}).manager(
-        "update_status",
+    ctx = Context(mycharm, meta={"name": "local"})
+    secret = Secret(
+        tracked_content={"a": "b"},
+        latest_content={"a": "c"},
+        owner=owner,
+    )
+    with ctx(
+        ctx.on.update_status(),
         State(
-            secrets=[
-                Secret(
-                    id="foo",
-                    contents={
-                        0: {"a": "b"},
-                        1: {"a": "c"},
-                    },
-                    owner=owner,
-                )
-            ]
+            secrets={secret},
         ),
     ) as mgr:
         charm = mgr.charm
-        assert charm.model.get_secret(id="foo").get_content()["a"] == "b"
-        assert charm.model.get_secret(id="foo").peek_content()["a"] == "c"
-        assert charm.model.get_secret(id="foo").get_content(refresh=True)["a"] == "c"
+        assert charm.model.get_secret(id=secret.id).get_content()["a"] == "b"
+        assert charm.model.get_secret(id=secret.id).peek_content()["a"] == "c"
+        # Verify that the peek has not refreshed:
+        assert charm.model.get_secret(id=secret.id).get_content()["a"] == "b"
+        assert (
+            charm.model.get_secret(id=secret.id).get_content(refresh=True)["a"] == "c"
+        )
 
 
-@pytest.mark.parametrize("owner", ("app", "unit", "application"))
-# "application" is deprecated but still supported
+@pytest.mark.parametrize("owner", ("app", "unit"))
 def test_secret_changed_owner_evt_fails(mycharm, owner):
+    ctx = Context(mycharm, meta={"name": "local"})
+    secret = Secret(
+        tracked_content={"a": "b"},
+        latest_content={"a": "c"},
+        owner=owner,
+    )
     with pytest.raises(ValueError):
-        _ = Secret(
-            id="foo",
-            contents={
-                0: {"a": "b"},
-                1: {"a": "c"},
-            },
-            owner=owner,
-        ).changed_event
-
-
-@pytest.mark.parametrize("evt_prefix", ("rotate", "expired", "remove"))
-def test_consumer_events_failures(mycharm, evt_prefix):
+        _ = ctx.on.secret_changed(secret)
+
+
+@pytest.mark.parametrize(
+    "evt_suffix,revision",
+    [
+        ("rotate", None),
+        ("expired", 1),
+        ("remove", 1),
+    ],
+)
+def test_consumer_events_failures(mycharm, evt_suffix, revision):
+    ctx = Context(mycharm, meta={"name": "local"})
+    secret = Secret(
+        tracked_content={"a": "b"},
+        latest_content={"a": "c"},
+    )
+    kwargs = {"secret": secret}
+    if revision is not None:
+        kwargs["revision"] = revision
     with pytest.raises(ValueError):
-        _ = getattr(
-            Secret(
-                id="foo",
-                contents={
-                    0: {"a": "b"},
-                    1: {"a": "c"},
-                },
-            ),
-            evt_prefix + "_event",
-        )
+        _ = getattr(ctx.on, f"secret_{evt_suffix}")(**kwargs)
 
 
 @pytest.mark.parametrize("app", (True, False))
 def test_add(mycharm, app):
-    with Context(mycharm, meta={"name": "local"}).manager(
-        "update_status",
+    ctx = Context(mycharm, meta={"name": "local"})
+    with ctx(
+        ctx.on.update_status(),
         State(leader=app),
     ) as mgr:
         charm = mgr.charm
@@ -172,19 +165,21 @@ def test_add(mycharm, app):
             charm.app.add_secret({"foo": "bar"}, label="mylabel")
         else:
             charm.unit.add_secret({"foo": "bar"}, label="mylabel")
+        output = mgr.run()
 
-    assert mgr.output.secrets
-    secret = mgr.output.secrets[0]
-    assert secret.contents[0] == {"foo": "bar"}
+    assert output.secrets
+    secret = output.get_secret(label="mylabel")
+    assert secret.latest_content == secret.tracked_content == {"foo": "bar"}
     assert secret.label == "mylabel"
 
 
 def test_set_legacy_behaviour(mycharm):
     # in juju < 3.1.7, secret owners always used to track the latest revision.
     # ref: https://bugs.launchpad.net/juju/+bug/2037120
-    rev1, rev2, rev3 = {"foo": "bar"}, {"foo": "baz"}, {"foo": "baz", "qux": "roz"}
-    with Context(mycharm, meta={"name": "local"}, juju_version="3.1.6").manager(
-        "update_status",
+    ctx = Context(mycharm, meta={"name": "local"}, juju_version="3.1.6")
+    rev1, rev2 = {"foo": "bar"}, {"foo": "baz", "qux": "roz"}
+    with ctx(
+        ctx.on.update_status(),
         State(),
     ) as mgr:
         charm = mgr.charm
@@ -197,7 +192,9 @@ def test_set_legacy_behaviour(mycharm):
         )
 
         secret.set_content(rev2)
-        secret = charm.model.get_secret(label="mylabel")
+        # We need to get the secret again, because ops caches the content in
+        # the object.
+        secret: ops_Secret = charm.model.get_secret(label="mylabel")
         assert (
             secret.get_content()
             == secret.peek_content()
@@ -205,27 +202,20 @@ def test_set_legacy_behaviour(mycharm):
             == rev2
         )
 
-        secret.set_content(rev3)
         state_out = mgr.run()
-        secret = charm.model.get_secret(label="mylabel")
-        assert (
-            secret.get_content()
-            == secret.peek_content()
-            == secret.get_content(refresh=True)
-            == rev3
-        )
 
-    assert state_out.secrets[0].contents == {
-        0: rev1,
-        1: rev2,
-        2: rev3,
-    }
+    assert (
+        state_out.get_secret(label="mylabel").tracked_content
+        == state_out.get_secret(label="mylabel").latest_content
+        == rev2
+    )
 
 
 def test_set(mycharm):
-    rev1, rev2, rev3 = {"foo": "bar"}, {"foo": "baz"}, {"foo": "baz", "qux": "roz"}
-    with Context(mycharm, meta={"name": "local"}).manager(
-        "update_status",
+    ctx = Context(mycharm, meta={"name": "local"})
+    rev1, rev2 = {"foo": "bar"}, {"foo": "baz", "qux": "roz"}
+    with ctx(
+        ctx.on.update_status(),
         State(),
     ) as mgr:
         charm = mgr.charm
@@ -237,26 +227,27 @@ def test_set(mycharm):
             == rev1
         )
 
+        # TODO: if this is done in the same event hook, it's more complicated
+        # than this. Figure out what we should do here.
+        # Also the next test, for Juju 3.3
         secret.set_content(rev2)
         assert secret.get_content() == rev1
         assert secret.peek_content() == secret.get_content(refresh=True) == rev2
 
-        secret.set_content(rev3)
         state_out = mgr.run()
-        assert secret.get_content() == rev2
-        assert secret.peek_content() == secret.get_content(refresh=True) == rev3
 
-    assert state_out.secrets[0].contents == {
-        0: rev1,
-        1: rev2,
-        2: rev3,
-    }
+    assert (
+        state_out.get_secret(label="mylabel").tracked_content
+        == state_out.get_secret(label="mylabel").latest_content
+        == rev2
+    )
 
 
 def test_set_juju33(mycharm):
-    rev1, rev2, rev3 = {"foo": "bar"}, {"foo": "baz"}, {"foo": "baz", "qux": "roz"}
-    with Context(mycharm, meta={"name": "local"}, juju_version="3.3.1").manager(
-        "update_status",
+    ctx = Context(mycharm, meta={"name": "local"}, juju_version="3.3.1")
+    rev1, rev2 = {"foo": "bar"}, {"foo": "baz", "qux": "roz"}
+    with ctx(
+        ctx.on.update_status(),
         State(),
     ) as mgr:
         charm = mgr.charm
@@ -268,43 +259,36 @@ def test_set_juju33(mycharm):
         assert secret.peek_content() == rev2
         assert secret.get_content(refresh=True) == rev2
 
-        secret.set_content(rev3)
         state_out = mgr.run()
-        assert secret.get_content() == rev2
-        assert secret.peek_content() == rev3
-        assert secret.get_content(refresh=True) == rev3
 
-    assert state_out.secrets[0].contents == {
-        0: rev1,
-        1: rev2,
-        2: rev3,
-    }
+    assert (
+        state_out.get_secret(label="mylabel").tracked_content
+        == state_out.get_secret(label="mylabel").latest_content
+        == rev2
+    )
 
 
 @pytest.mark.parametrize("app", (True, False))
 def test_meta(mycharm, app):
-    with Context(mycharm, meta={"name": "local"}).manager(
-        "update_status",
+    ctx = Context(mycharm, meta={"name": "local"})
+    secret = Secret(
+        {"a": "b"},
+        owner="app" if app else "unit",
+        label="mylabel",
+        description="foobarbaz",
+        rotate=SecretRotate.HOURLY,
+    )
+    with ctx(
+        ctx.on.update_status(),
         State(
             leader=True,
-            secrets=[
-                Secret(
-                    owner="app" if app else "unit",
-                    id="foo",
-                    label="mylabel",
-                    description="foobarbaz",
-                    rotate=SecretRotate.HOURLY,
-                    contents={
-                        0: {"a": "b"},
-                    },
-                )
-            ],
+            secrets={secret},
         ),
     ) as mgr:
         charm = mgr.charm
         assert charm.model.get_secret(label="mylabel")
 
-        secret = charm.model.get_secret(id="foo")
+        secret = charm.model.get_secret(id=secret.id)
         info = secret.get_info()
 
         assert secret.label is None
@@ -312,32 +296,6 @@ def test_meta(mycharm, app):
         assert info.rotation == SecretRotate.HOURLY
 
 
-def test_secret_deprecation_application(mycharm):
-    with warnings.catch_warnings(record=True) as captured:
-        s = Secret("123", {}, owner="application")
-        assert s.owner == "app"
-    msg = captured[0].message
-    assert isinstance(msg, DeprecationWarning)
-    assert msg.args[0] == (
-        "Secret.owner='application' is deprecated in favour of "
-        "'app' and will be removed in Scenario 7+."
-    )
-
-
-@pytest.mark.parametrize("granted", ("app", "unit", False))
-def test_secret_deprecation_granted(mycharm, granted):
-    with warnings.catch_warnings(record=True) as captured:
-        s = Secret("123", {}, granted=granted)
-        assert s.granted == granted
-    msg = captured[0].message
-    assert isinstance(msg, DeprecationWarning)
-    assert msg.args[0] == (
-        "``state.Secret.granted`` is deprecated and will be removed in Scenario 7+. "
-        "If a Secret is not owned by the app/unit you are testing, nor has been granted to "
-        "it by the (remote) owner, then omit it from ``State.secrets`` altogether."
-    )
-
-
 @pytest.mark.parametrize("leader", (True, False))
 @pytest.mark.parametrize("owner", ("app", "unit", None))
 def test_secret_permission_model(mycharm, leader, owner):
@@ -348,32 +306,28 @@ def test_secret_permission_model(mycharm, leader, owner):
         or (owner == "unit")
     )
 
-    with Context(mycharm, meta={"name": "local"}).manager(
-        "update_status",
+    ctx = Context(mycharm, meta={"name": "local"})
+    secret = Secret(
+        {"a": "b"},
+        label="mylabel",
+        owner=owner,
+        description="foobarbaz",
+        rotate=SecretRotate.HOURLY,
+    )
+    secret_id = secret.id
+    with ctx(
+        ctx.on.update_status(),
         State(
             leader=leader,
-            secrets=[
-                Secret(
-                    id="foo",
-                    label="mylabel",
-                    description="foobarbaz",
-                    rotate=SecretRotate.HOURLY,
-                    owner=owner,
-                    contents={
-                        0: {"a": "b"},
-                    },
-                )
-            ],
+            secrets={secret},
         ),
     ) as mgr:
-        secret = mgr.charm.model.get_secret(id="foo")
+        # can always view
+        secret: ops_Secret = mgr.charm.model.get_secret(id=secret_id)
         assert secret.get_content()["a"] == "b"
         assert secret.peek_content()
         assert secret.get_content(refresh=True)
 
-        # can always view
-        secret: ops_Secret = mgr.charm.model.get_secret(id="foo")
-
         if expect_manage:
             assert secret.get_content()
             assert secret.peek_content()
@@ -398,24 +352,21 @@ def test_secret_permission_model(mycharm, leader, owner):
 
 @pytest.mark.parametrize("app", (True, False))
 def test_grant(mycharm, app):
-    with Context(
+    ctx = Context(
         mycharm, meta={"name": "local", "requires": {"foo": {"interface": "bar"}}}
-    ).manager(
-        "update_status",
+    )
+    secret = Secret(
+        {"a": "b"},
+        owner="unit",
+        label="mylabel",
+        description="foobarbaz",
+        rotate=SecretRotate.HOURLY,
+    )
+    with ctx(
+        ctx.on.update_status(),
         State(
             relations=[Relation("foo", "remote")],
-            secrets=[
-                Secret(
-                    owner="unit",
-                    id="foo",
-                    label="mylabel",
-                    description="foobarbaz",
-                    rotate=SecretRotate.HOURLY,
-                    contents={
-                        0: {"a": "b"},
-                    },
-                )
-            ],
+            secrets={secret},
         ),
     ) as mgr:
         charm = mgr.charm
@@ -425,26 +376,24 @@ def test_grant(mycharm, app):
             secret.grant(relation=foo)
         else:
             secret.grant(relation=foo, unit=foo.units.pop())
-    vals = list(mgr.output.secrets[0].remote_grants.values())
+        output = mgr.run()
+    vals = list(output.get_secret(label="mylabel").remote_grants.values())
     assert vals == [{"remote"}] if app else [{"remote/0"}]
 
 
 def test_update_metadata(mycharm):
     exp = datetime.datetime(2050, 12, 12)
 
-    with Context(mycharm, meta={"name": "local"}).manager(
-        "update_status",
+    ctx = Context(mycharm, meta={"name": "local"})
+    secret = Secret(
+        {"a": "b"},
+        owner="unit",
+        label="mylabel",
+    )
+    with ctx(
+        ctx.on.update_status(),
         State(
-            secrets=[
-                Secret(
-                    owner="unit",
-                    id="foo",
-                    label="mylabel",
-                    contents={
-                        0: {"a": "b"},
-                    },
-                )
-            ],
+            secrets={secret},
         ),
     ) as mgr:
         secret = mgr.charm.model.get_secret(label="mylabel")
@@ -454,8 +403,9 @@ def test_update_metadata(mycharm):
             expire=exp,
             rotate=SecretRotate.DAILY,
         )
+        output = mgr.run()
 
-    secret_out = mgr.output.secrets[0]
+    secret_out = output.get_secret(label="babbuccia")
     assert secret_out.label == "babbuccia"
     assert secret_out.rotate == SecretRotate.DAILY
     assert secret_out.description == "blu"
@@ -476,37 +426,34 @@ def _on_start(self, _):
                 secret = self.unit.add_secret({"foo": "bar"})
             secret.grant(self.model.relations["bar"][0])
 
-    state = State(leader=leader, relations=[Relation("bar")])
-    context = Context(
+    state = State(leader=leader, relations={Relation("bar")})
+    ctx = Context(
         GrantingCharm, meta={"name": "foo", "provides": {"bar": {"interface": "bar"}}}
     )
-    context.run("start", state)
+    ctx.run(ctx.on.start(), state)
 
 
 def test_grant_nonowner(mycharm):
-    def post_event(charm: CharmBase):
-        secret = charm.model.get_secret(id="foo")
+    secret = Secret(
+        {"a": "b"},
+        label="mylabel",
+        description="foobarbaz",
+        rotate=SecretRotate.HOURLY,
+    )
+    secret_id = secret.id
 
+    def post_event(charm: CharmBase):
+        secret = charm.model.get_secret(id=secret_id)
         secret = charm.model.get_secret(label="mylabel")
         foo = charm.model.get_relation("foo")
 
         with pytest.raises(ModelError):
             secret.grant(relation=foo)
 
-    out = trigger(
+    trigger(
         State(
-            relations=[Relation("foo", "remote")],
-            secrets=[
-                Secret(
-                    id="foo",
-                    label="mylabel",
-                    description="foobarbaz",
-                    rotate=SecretRotate.HOURLY,
-                    contents={
-                        0: {"a": "b"},
-                    },
-                )
-            ],
+            relations={Relation("foo", "remote")},
+            secrets={secret},
         ),
         "update_status",
         mycharm,
@@ -517,10 +464,9 @@ def post_event(charm: CharmBase):
 
 def test_add_grant_revoke_remove():
     class GrantingCharm(CharmBase):
-        def __init__(self, *args):
-            super().__init__(*args)
+        pass
 
-    context = Context(
+    ctx = Context(
         GrantingCharm, meta={"name": "foo", "provides": {"bar": {"interface": "bar"}}}
     )
     relation_remote_app = "remote_secret_desirerer"
@@ -528,63 +474,136 @@ def __init__(self, *args):
 
     state = State(
         leader=True,
-        relations=[
-            Relation(
-                "bar", remote_app_name=relation_remote_app, relation_id=relation_id
-            )
-        ],
+        relations={
+            Relation("bar", remote_app_name=relation_remote_app, id=relation_id)
+        },
     )
 
-    with context.manager("start", state) as mgr:
+    with ctx(ctx.on.start(), state) as mgr:
         charm = mgr.charm
         secret = charm.app.add_secret({"foo": "bar"}, label="mylabel")
         bar_relation = charm.model.relations["bar"][0]
 
         secret.grant(bar_relation)
+        output = mgr.run()
 
-    assert mgr.output.secrets
-    scenario_secret = mgr.output.secrets[0]
+    assert output.secrets
+    scenario_secret = output.get_secret(label="mylabel")
     assert relation_remote_app in scenario_secret.remote_grants[relation_id]
 
-    with context.manager("start", mgr.output) as mgr:
+    with ctx(ctx.on.start(), output) as mgr:
         charm: GrantingCharm = mgr.charm
         secret = charm.model.get_secret(label="mylabel")
         secret.revoke(bar_relation)
+        output = mgr.run()
 
-    scenario_secret = mgr.output.secrets[0]
+    scenario_secret = output.get_secret(label="mylabel")
     assert scenario_secret.remote_grants == {}
 
-    with context.manager("start", mgr.output) as mgr:
+    with ctx(ctx.on.start(), output) as mgr:
         charm: GrantingCharm = mgr.charm
         secret = charm.model.get_secret(label="mylabel")
         secret.remove_all_revisions()
+        output = mgr.run()
 
-    assert not mgr.output.secrets[0].contents  # secret wiped
+    with pytest.raises(KeyError):
+        output.get_secret(label="mylabel")
 
 
-@pytest.mark.parametrize(
-    "evt,owner,cls",
-    (
-        ("changed", None, SecretChangedEvent),
-        ("rotate", "app", SecretRotateEvent),
-        ("expired", "app", SecretExpiredEvent),
-        ("remove", "app", SecretRemoveEvent),
-    ),
-)
-def test_emit_event(evt, owner, cls):
-    class MyCharm(CharmBase):
+def test_secret_removed_event():
+    class SecretCharm(CharmBase):
         def __init__(self, framework):
             super().__init__(framework)
-            for evt in self.on.events().values():
-                self.framework.observe(evt, self._on_event)
-            self.events = []
+            self.framework.observe(self.on.secret_remove, self._on_secret_remove)
 
-        def _on_event(self, event):
-            self.events.append(event)
-
-    ctx = Context(MyCharm, meta={"name": "local"})
-    secret = Secret(contents={"foo": "bar"}, id="foo", owner=owner)
-    with ctx.manager(getattr(secret, evt + "_event"), State(secrets=[secret])) as mgr:
-        mgr.run()
-        juju_event = mgr.charm.events[0]  # Ignore collect-status etc.
-        assert isinstance(juju_event, cls)
+        def _on_secret_remove(self, event):
+            event.secret.remove_revision(event.revision)
+
+    ctx = Context(SecretCharm, meta={"name": "foo"})
+    secret = Secret({"a": "b"}, owner="app")
+    old_revision = 42
+    state = ctx.run(
+        ctx.on.secret_remove(secret, revision=old_revision),
+        State(leader=True, secrets={secret}),
+    )
+    assert secret in state.secrets
+    assert ctx.removed_secret_revisions == [old_revision]
+
+
+def test_secret_expired_event():
+    class SecretCharm(CharmBase):
+        def __init__(self, framework):
+            super().__init__(framework)
+            self.framework.observe(self.on.secret_expired, self._on_secret_expired)
+
+        def _on_secret_expired(self, event):
+            event.secret.set_content({"password": "newpass"})
+            event.secret.remove_revision(event.revision)
+
+    ctx = Context(SecretCharm, meta={"name": "foo"})
+    secret = Secret({"password": "oldpass"}, owner="app")
+    old_revision = 42
+    state = ctx.run(
+        ctx.on.secret_expired(secret, revision=old_revision),
+        State(leader=True, secrets={secret}),
+    )
+    assert state.get_secret(id=secret.id).latest_content == {"password": "newpass"}
+    assert ctx.removed_secret_revisions == [old_revision]
+
+
+def test_remove_bad_revision():
+    class SecretCharm(CharmBase):
+        def __init__(self, framework):
+            super().__init__(framework)
+            self.framework.observe(self.on.secret_remove, self._on_secret_remove)
+
+        def _on_secret_remove(self, event):
+            with pytest.raises(ValueError):
+                event.secret.remove_revision(event.revision)
+
+    ctx = Context(SecretCharm, meta={"name": "foo"})
+    secret = Secret({"a": "b"}, owner="app")
+    ctx.run(
+        ctx.on.secret_remove(secret, revision=secret._latest_revision),
+        State(leader=True, secrets={secret}),
+    )
+    ctx.run(
+        ctx.on.secret_remove(secret, revision=secret._tracked_revision),
+        State(leader=True, secrets={secret}),
+    )
+
+
+def test_set_label_on_get():
+    class SecretCharm(CharmBase):
+        def __init__(self, framework):
+            super().__init__(framework)
+            self.framework.observe(self.on.start, self._on_start)
+
+        def _on_start(self, _):
+            id = self.unit.add_secret({"foo": "bar"}).id
+            secret = self.model.get_secret(id=id, label="label1")
+            assert secret.label == "label1"
+            secret = self.model.get_secret(id=id, label="label2")
+            assert secret.label == "label2"
+
+    ctx = Context(SecretCharm, meta={"name": "foo"})
+    state = ctx.run(ctx.on.start(), State())
+    assert state.get_secret(label="label2").tracked_content == {"foo": "bar"}
+
+
+def test_no_additional_positional_arguments():
+    with pytest.raises(TypeError):
+        Secret({}, {}, None)
+
+
+def test_default_values():
+    contents = {"foo": "bar"}
+    secret = Secret(contents)
+    assert secret.latest_content == secret.tracked_content == contents
+    assert secret.id.startswith("secret:")
+    assert secret.label is None
+    assert secret.description is None
+    assert secret.owner is None
+    assert secret.rotate is None
+    assert secret.expire is None
+    assert secret.remote_grants == {}
diff --git a/tests/test_e2e/test_state.py b/tests/test_e2e/test_state.py
index 838426ae..9cd1e9c0 100644
--- a/tests/test_e2e/test_state.py
+++ b/tests/test_e2e/test_state.py
@@ -1,4 +1,5 @@
-from dataclasses import asdict
+import copy
+from dataclasses import asdict, replace
 from typing import Type
 
 import pytest
@@ -6,8 +7,18 @@
 from ops.framework import EventBase, Framework
 from ops.model import ActiveStatus, UnknownStatus, WaitingStatus
 
-from scenario.state import DEFAULT_JUJU_DATABAG, Container, Relation, State, sort_patch
-from tests.helpers import trigger
+from scenario.state import (
+    _DEFAULT_JUJU_DATABAG,
+    Address,
+    BindAddress,
+    Container,
+    Model,
+    Network,
+    Relation,
+    Resource,
+    State,
+)
+from tests.helpers import jsonpatch_delta, sort_patch, trigger
 
 CUSTOM_EVT_SUFFIXES = {
     "relation_created",
@@ -57,8 +68,8 @@ def state():
 
 def test_bare_event(state, mycharm):
     out = trigger(state, "start", mycharm, meta={"name": "foo"})
-    out_purged = out.replace(stored_state=state.stored_state)
-    assert state.jsonpatch_delta(out_purged) == []
+    out_purged = replace(out, stored_states=state.stored_states)
+    assert jsonpatch_delta(state, out_purged) == []
 
 
 def test_leader_get(state, mycharm):
@@ -96,8 +107,8 @@ def call(charm: CharmBase, e):
     assert out.workload_version == ""
 
     # ignore stored state in the delta
-    out_purged = out.replace(stored_state=state.stored_state)
-    assert out_purged.jsonpatch_delta(state) == sort_patch(
+    out_purged = replace(out, stored_states=state.stored_states)
+    assert jsonpatch_delta(out_purged, state) == sort_patch(
         [
             {"op": "replace", "path": "/app_status/message", "value": "foo barz"},
             {"op": "replace", "path": "/app_status/name", "value": "waiting"},
@@ -116,7 +127,7 @@ def pre_event(charm: CharmBase):
         assert container.can_connect() is connect
 
     trigger(
-        State(containers=[Container(name="foo", can_connect=connect)]),
+        State(containers={Container(name="foo", can_connect=connect)}),
         "start",
         mycharm,
         meta={
@@ -145,7 +156,7 @@ def pre_event(charm: CharmBase):
                 assert not rel.data[unit]
 
     state = State(
-        relations=[
+        relations={
             Relation(
                 endpoint="foo",
                 interface="bar",
@@ -155,7 +166,7 @@ def pre_event(charm: CharmBase):
                 local_unit_data={"c": "d"},
                 remote_units_data={0: {}, 1: {"e": "f"}, 2: {}},
             )
-        ]
+        }
     )
     trigger(
         state,
@@ -177,7 +188,6 @@ def event_handler(charm: CharmBase, _):
 
         # this will NOT raise an exception because we're not in an event context!
         # we're right before the event context is entered in fact.
-        # todo: how do we warn against the user abusing pre/post_event to mess with an unguarded state?
         with pytest.raises(Exception):
             rel.data[rel.app]["a"] = "b"
         with pytest.raises(Exception):
@@ -191,7 +201,6 @@ def pre_event(charm: CharmBase):
 
         # this would NOT raise an exception because we're not in an event context!
         # we're right before the event context is entered in fact.
-        # todo: how do we warn against the user abusing pre/post_event to mess with an unguarded state?
         # with pytest.raises(Exception):
         #     rel.data[rel.app]["a"] = "b"
         # with pytest.raises(Exception):
@@ -207,7 +216,7 @@ def pre_event(charm: CharmBase):
     state = State(
         leader=True,
         planned_units=4,
-        relations=[relation],
+        relations={relation},
     )
 
     assert not mycharm.called
@@ -223,12 +232,91 @@ def pre_event(charm: CharmBase):
     )
     assert mycharm.called
 
-    assert asdict(out.relations[0]) == asdict(
-        relation.replace(
+    assert asdict(out.get_relation(relation.id)) == asdict(
+        replace(
+            relation,
             local_app_data={"a": "b"},
-            local_unit_data={"c": "d", **DEFAULT_JUJU_DATABAG},
+            local_unit_data={"c": "d", **_DEFAULT_JUJU_DATABAG},
         )
     )
-
-    assert out.relations[0].local_app_data == {"a": "b"}
-    assert out.relations[0].local_unit_data == {"c": "d", **DEFAULT_JUJU_DATABAG}
+    assert out.get_relation(relation.id).local_app_data == {"a": "b"}
+    assert out.get_relation(relation.id).local_unit_data == {
+        "c": "d",
+        **_DEFAULT_JUJU_DATABAG,
+    }
+
+
+@pytest.mark.parametrize(
+    "klass,num_args",
+    [
+        (State, (1,)),
+        (Resource, (1,)),
+        (Address, (0, 2)),
+        (BindAddress, (0, 2)),
+        (Network, (0, 3)),
+    ],
+)
+def test_positional_arguments(klass, num_args):
+    for num in num_args:
+        args = (None,) * num
+        with pytest.raises(TypeError):
+            klass(*args)
+
+
+def test_model_positional_arguments():
+    with pytest.raises(TypeError):
+        Model("", "")
+
+
+def test_container_positional_arguments():
+    with pytest.raises(TypeError):
+        Container("", "")
+
+
+def test_container_default_values():
+    name = "foo"
+    container = Container(name)
+    assert container.name == name
+    assert container.can_connect is False
+    assert container.layers == {}
+    assert container.service_statuses == {}
+    assert container.mounts == {}
+    assert container.execs == frozenset()
+    assert container.layers == {}
+    assert container._base_plan == {}
+
+
+def test_state_default_values():
+    state = State()
+    assert state.config == {}
+    assert state.relations == frozenset()
+    assert state.networks == frozenset()
+    assert state.containers == frozenset()
+    assert state.storages == frozenset()
+    assert state.opened_ports == frozenset()
+    assert state.secrets == frozenset()
+    assert state.resources == frozenset()
+    assert state.deferred == []
+    assert isinstance(state.model, Model)
+    assert state.leader is False
+    assert state.planned_units == 1
+    assert state.app_status == UnknownStatus()
+    assert state.unit_status == UnknownStatus()
+    assert state.workload_version == ""
+
+
+def test_deepcopy_state():
+    containers = [Container("foo"), Container("bar")]
+    state = State(containers=containers)
+    state_copy = copy.deepcopy(state)
+    for container in state.containers:
+        copied_container = state_copy.get_container(container.name)
+        assert container.name == copied_container.name
+
+
+def test_replace_state():
+    containers = [Container("foo"), Container("bar")]
+    state = State(containers=containers, leader=True)
+    state2 = replace(state, leader=False)
+    assert state.leader != state2.leader
+    assert state.containers == state2.containers
diff --git a/tests/test_e2e/test_status.py b/tests/test_e2e/test_status.py
index 0c28f7e6..f88f4b04 100644
--- a/tests/test_e2e/test_status.py
+++ b/tests/test_e2e/test_status.py
@@ -1,17 +1,18 @@
+import ops
 import pytest
 from ops.charm import CharmBase
 from ops.framework import Framework
-from ops.model import (
+
+from scenario import Context
+from scenario.state import (
     ActiveStatus,
     BlockedStatus,
     ErrorStatus,
     MaintenanceStatus,
+    State,
     UnknownStatus,
     WaitingStatus,
 )
-
-from scenario import Context
-from scenario.state import State, _status_to_entitystatus
 from tests.helpers import trigger
 
 
@@ -45,22 +46,23 @@ def post_event(charm: CharmBase):
 
 
 def test_status_history(mycharm):
-    def post_event(charm: CharmBase):
-        for obj in [charm.unit, charm.app]:
-            obj.status = ActiveStatus("1")
-            obj.status = BlockedStatus("2")
-            obj.status = WaitingStatus("3")
+    class StatusCharm(mycharm):
+        def __init__(self, framework):
+            super().__init__(framework)
+            framework.observe(self.on.update_status, self._on_update_status)
+
+        def _on_update_status(self, _):
+            for obj in (self.unit, self.app):
+                obj.status = ops.ActiveStatus("1")
+                obj.status = ops.BlockedStatus("2")
+                obj.status = ops.WaitingStatus("3")
 
     ctx = Context(
-        mycharm,
+        StatusCharm,
         meta={"name": "local"},
     )
 
-    out = ctx.run(
-        "update_status",
-        State(leader=True),
-        post_event=post_event,
-    )
+    out = ctx.run(ctx.on.update_status(), State(leader=True))
 
     assert out.unit_status == WaitingStatus("3")
     assert ctx.unit_status_history == [
@@ -69,7 +71,7 @@ def post_event(charm: CharmBase):
         BlockedStatus("2"),
     ]
 
-    assert out.app_status == WaitingStatus("3")
+    assert out.app_status == ops.WaitingStatus("3")
     assert ctx.app_status_history == [
         UnknownStatus(),
         ActiveStatus("1"),
@@ -78,23 +80,27 @@ def post_event(charm: CharmBase):
 
 
 def test_status_history_preservation(mycharm):
-    def post_event(charm: CharmBase):
-        for obj in [charm.unit, charm.app]:
-            obj.status = WaitingStatus("3")
+    class StatusCharm(mycharm):
+        def __init__(self, framework):
+            super().__init__(framework)
+            framework.observe(self.on.update_status, self._on_update_status)
+
+        def _on_update_status(self, _):
+            for obj in (self.unit, self.app):
+                obj.status = WaitingStatus("3")
 
     ctx = Context(
-        mycharm,
+        StatusCharm,
         meta={"name": "local"},
     )
 
     out = ctx.run(
-        "update_status",
+        ctx.on.update_status(),
         State(
             leader=True,
             unit_status=ActiveStatus("foo"),
             app_status=ActiveStatus("bar"),
         ),
-        post_event=post_event,
     )
 
     assert out.unit_status == WaitingStatus("3")
@@ -105,23 +111,30 @@ def post_event(charm: CharmBase):
 
 
 def test_workload_history(mycharm):
-    def post_event(charm: CharmBase):
-        charm.unit.set_workload_version("1")
-        charm.unit.set_workload_version("1.1")
-        charm.unit.set_workload_version("1.2")
+    class WorkloadCharm(mycharm):
+        def __init__(self, framework):
+            super().__init__(framework)
+            framework.observe(self.on.install, self._on_install)
+            framework.observe(self.on.start, self._on_start)
+            framework.observe(self.on.update_status, self._on_update_status)
+
+        def _on_install(self, _):
+            self.unit.set_workload_version("1")
+
+        def _on_start(self, _):
+            self.unit.set_workload_version("1.1")
+
+        def _on_update_status(self, _):
+            self.unit.set_workload_version("1.2")
 
     ctx = Context(
-        mycharm,
+        WorkloadCharm,
         meta={"name": "local"},
     )
 
-    out = ctx.run(
-        "update_status",
-        State(
-            leader=True,
-        ),
-        post_event=post_event,
-    )
+    out = ctx.run(ctx.on.install(), State(leader=True))
+    out = ctx.run(ctx.on.start(), out)
+    out = ctx.run(ctx.on.update_status(), out)
 
     assert ctx.workload_version_history == ["1", "1.1"]
     assert out.workload_version == "1.2"
@@ -139,7 +152,20 @@ def post_event(charm: CharmBase):
     ),
 )
 def test_status_comparison(status):
-    entitystatus = _status_to_entitystatus(status)
-    assert entitystatus == entitystatus == status
-    assert isinstance(entitystatus, type(status))
-    assert repr(entitystatus) == repr(status)
+    if isinstance(status, UnknownStatus):
+        ops_status = ops.UnknownStatus()
+    else:
+        ops_status = getattr(ops, status.__class__.__name__)(status.message)
+    # A status can be compared to itself.
+    assert status == status
+    # A status can be compared to another instance of the scenario class.
+    if isinstance(status, UnknownStatus):
+        assert status == status.__class__()
+    else:
+        assert status == status.__class__(status.message)
+    # A status can be compared to an instance of the ops class.
+    assert status == ops_status
+    # isinstance also works for comparing to the ops classes.
+    assert isinstance(status, type(ops_status))
+    # The repr of the scenario and ops classes should be identical.
+    assert repr(status) == repr(ops_status)
diff --git a/tests/test_e2e/test_storage.py b/tests/test_e2e/test_storage.py
index 87aa9370..34785aa1 100644
--- a/tests/test_e2e/test_storage.py
+++ b/tests/test_e2e/test_storage.py
@@ -23,13 +23,13 @@ def no_storage_ctx():
 
 
 def test_storage_get_null(no_storage_ctx):
-    with no_storage_ctx.manager("update-status", State()) as mgr:
+    with no_storage_ctx(no_storage_ctx.on.update_status(), State()) as mgr:
         storages = mgr.charm.model.storages
         assert not len(storages)
 
 
 def test_storage_get_unknown_name(storage_ctx):
-    with storage_ctx.manager("update-status", State()) as mgr:
+    with storage_ctx(storage_ctx.on.update_status(), State()) as mgr:
         storages = mgr.charm.model.storages
         # not in metadata
         with pytest.raises(KeyError):
@@ -37,7 +37,7 @@ def test_storage_get_unknown_name(storage_ctx):
 
 
 def test_storage_request_unknown_name(storage_ctx):
-    with storage_ctx.manager("update-status", State()) as mgr:
+    with storage_ctx(storage_ctx.on.update_status(), State()) as mgr:
         storages = mgr.charm.model.storages
         # not in metadata
         with pytest.raises(ModelError):
@@ -45,7 +45,7 @@ def test_storage_request_unknown_name(storage_ctx):
 
 
 def test_storage_get_some(storage_ctx):
-    with storage_ctx.manager("update-status", State()) as mgr:
+    with storage_ctx(storage_ctx.on.update_status(), State()) as mgr:
         storages = mgr.charm.model.storages
         # known but none attached
         assert storages["foo"] == []
@@ -53,7 +53,7 @@ def test_storage_get_some(storage_ctx):
 
 @pytest.mark.parametrize("n", (1, 3, 5))
 def test_storage_add(storage_ctx, n):
-    with storage_ctx.manager("update-status", State()) as mgr:
+    with storage_ctx(storage_ctx.on.update_status(), State()) as mgr:
         storages = mgr.charm.model.storages
         storages.request("foo", n)
 
@@ -65,7 +65,7 @@ def test_storage_usage(storage_ctx):
     # setup storage with some content
     (storage.get_filesystem(storage_ctx) / "myfile.txt").write_text("helloworld")
 
-    with storage_ctx.manager("update-status", State(storage=[storage])) as mgr:
+    with storage_ctx(storage_ctx.on.update_status(), State(storages={storage})) as mgr:
         foo = mgr.charm.model.storages["foo"][0]
         loc = foo.location
         path = loc / "myfile.txt"
@@ -83,9 +83,11 @@ def test_storage_usage(storage_ctx):
 
 def test_storage_attached_event(storage_ctx):
     storage = Storage("foo")
-    storage_ctx.run(storage.attached_event, State(storage=[storage]))
+    storage_ctx.run(storage_ctx.on.storage_attached(storage), State(storages={storage}))
 
 
 def test_storage_detaching_event(storage_ctx):
     storage = Storage("foo")
-    storage_ctx.run(storage.detaching_event, State(storage=[storage]))
+    storage_ctx.run(
+        storage_ctx.on.storage_detaching(storage), State(storages={storage})
+    )
diff --git a/tests/test_e2e/test_stored_state.py b/tests/test_e2e/test_stored_state.py
index 22a6235e..94b9c301 100644
--- a/tests/test_e2e/test_stored_state.py
+++ b/tests/test_e2e/test_stored_state.py
@@ -32,20 +32,47 @@ def _on_event(self, event):
 
 def test_stored_state_default(mycharm):
     out = trigger(State(), "start", mycharm, meta=mycharm.META)
-    assert out.stored_state[0].content == {"foo": "bar", "baz": {12: 142}}
+    assert out.get_stored_state("_stored", owner_path="MyCharm").content == {
+        "foo": "bar",
+        "baz": {12: 142},
+    }
+    assert out.get_stored_state("_stored2", owner_path="MyCharm").content == {
+        "foo": "bar",
+        "baz": {12: 142},
+    }
 
 
 def test_stored_state_initialized(mycharm):
     out = trigger(
         State(
-            stored_state=[
-                StoredState("MyCharm", name="_stored", content={"foo": "FOOX"}),
-            ]
+            stored_states={
+                StoredState(
+                    owner_path="MyCharm", name="_stored", content={"foo": "FOOX"}
+                ),
+            }
         ),
         "start",
         mycharm,
         meta=mycharm.META,
     )
-    # todo: ordering is messy?
-    assert out.stored_state[1].content == {"foo": "FOOX", "baz": {12: 142}}
-    assert out.stored_state[0].content == {"foo": "bar", "baz": {12: 142}}
+    assert out.get_stored_state("_stored", owner_path="MyCharm").content == {
+        "foo": "FOOX",
+        "baz": {12: 142},
+    }
+    assert out.get_stored_state("_stored2", owner_path="MyCharm").content == {
+        "foo": "bar",
+        "baz": {12: 142},
+    }
+
+
+def test_positional_arguments():
+    with pytest.raises(TypeError):
+        StoredState("_stored", "")
+
+
+def test_default_arguments():
+    s = StoredState()
+    assert s.name == "_stored"
+    assert s.owner_path == None
+    assert s.content == {}
+    assert s._data_type_name == "StoredStateData"
diff --git a/tests/test_e2e/test_vroot.py b/tests/test_e2e/test_vroot.py
index c6d59be5..fa8a3d41 100644
--- a/tests/test_e2e/test_vroot.py
+++ b/tests/test_e2e/test_vroot.py
@@ -47,7 +47,7 @@ def test_charm_virtual_root(charm_virtual_root):
         meta=MyCharm.META,
         charm_root=charm_virtual_root,
     )
-    assert out.unit_status == ("active", "hello world")
+    assert out.unit_status == ActiveStatus("hello world")
 
 
 def test_charm_virtual_root_cleanup_if_exists(charm_virtual_root):
@@ -55,8 +55,9 @@ def test_charm_virtual_root_cleanup_if_exists(charm_virtual_root):
     raw_ori_meta = yaml.safe_dump({"name": "karl"})
     meta_file.write_text(raw_ori_meta)
 
-    with Context(MyCharm, meta=MyCharm.META, charm_root=charm_virtual_root).manager(
-        "start",
+    ctx = Context(MyCharm, meta=MyCharm.META, charm_root=charm_virtual_root)
+    with ctx(
+        ctx.on.start(),
         State(),
     ) as mgr:
         assert meta_file.exists()
@@ -77,8 +78,9 @@ def test_charm_virtual_root_cleanup_if_not_exists(charm_virtual_root):
 
     assert not meta_file.exists()
 
-    with Context(MyCharm, meta=MyCharm.META, charm_root=charm_virtual_root).manager(
-        "start",
+    ctx = Context(MyCharm, meta=MyCharm.META, charm_root=charm_virtual_root)
+    with ctx(
+        ctx.on.start(),
         State(),
     ) as mgr:
         assert meta_file.exists()
diff --git a/tests/test_emitted_events_util.py b/tests/test_emitted_events_util.py
index 7fc0eb00..8a324dbc 100644
--- a/tests/test_emitted_events_util.py
+++ b/tests/test_emitted_events_util.py
@@ -1,9 +1,9 @@
-import pytest
 from ops.charm import CharmBase, CharmEvents, CollectStatusEvent, StartEvent
 from ops.framework import CommitEvent, EventBase, EventSource, PreCommitEvent
 
-from scenario import Event, State
-from scenario.capture_events import capture_events
+from scenario import State
+from scenario.runtime import _capture_events
+from scenario.state import _Event
 from tests.helpers import trigger
 
 
@@ -31,35 +31,20 @@ def _on_foo(self, e):
         pass
 
 
-def test_capture_custom_evt():
-    with capture_events(Foo) as emitted:
-        trigger(State(), "foo", MyCharm, meta=MyCharm.META)
-
-    assert len(emitted) == 1
-    assert isinstance(emitted[0], Foo)
-
-
-def test_capture_custom_evt_nonspecific_capture():
-    with capture_events() as emitted:
-        trigger(State(), "foo", MyCharm, meta=MyCharm.META)
-
-    assert len(emitted) == 1
-    assert isinstance(emitted[0], Foo)
-
-
 def test_capture_custom_evt_nonspecific_capture_include_fw_evts():
-    with capture_events(include_framework=True) as emitted:
-        trigger(State(), "foo", MyCharm, meta=MyCharm.META)
+    with _capture_events(include_framework=True) as emitted:
+        trigger(State(), "start", MyCharm, meta=MyCharm.META)
 
-    assert len(emitted) == 4
-    assert isinstance(emitted[0], Foo)
-    assert isinstance(emitted[1], CollectStatusEvent)
-    assert isinstance(emitted[2], PreCommitEvent)
-    assert isinstance(emitted[3], CommitEvent)
+    assert len(emitted) == 5
+    assert isinstance(emitted[0], StartEvent)
+    assert isinstance(emitted[1], Foo)
+    assert isinstance(emitted[2], CollectStatusEvent)
+    assert isinstance(emitted[3], PreCommitEvent)
+    assert isinstance(emitted[4], CommitEvent)
 
 
 def test_capture_juju_evt():
-    with capture_events() as emitted:
+    with _capture_events() as emitted:
         trigger(State(), "start", MyCharm, meta=MyCharm.META)
 
     assert len(emitted) == 2
@@ -69,9 +54,9 @@ def test_capture_juju_evt():
 
 def test_capture_deferred_evt():
     # todo: this test should pass with ops < 2.1 as well
-    with capture_events() as emitted:
+    with _capture_events() as emitted:
         trigger(
-            State(deferred=[Event("foo").deferred(handler=MyCharm._on_foo)]),
+            State(deferred=[_Event("foo").deferred(handler=MyCharm._on_foo)]),
             "start",
             MyCharm,
             meta=MyCharm.META,
@@ -85,9 +70,9 @@ def test_capture_deferred_evt():
 
 def test_capture_no_deferred_evt():
     # todo: this test should pass with ops < 2.1 as well
-    with capture_events(include_deferred=False) as emitted:
+    with _capture_events(include_deferred=False) as emitted:
         trigger(
-            State(deferred=[Event("foo").deferred(handler=MyCharm._on_foo)]),
+            State(deferred=[_Event("foo").deferred(handler=MyCharm._on_foo)]),
             "start",
             MyCharm,
             meta=MyCharm.META,
diff --git a/tests/test_hypothesis.py b/tests/test_hypothesis.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/test_plugin.py b/tests/test_plugin.py
index 06873f17..b802b9eb 100644
--- a/tests/test_plugin.py
+++ b/tests/test_plugin.py
@@ -12,16 +12,16 @@ def test_plugin_ctx_run(pytester):
         from scenario import State
         from scenario import Context
         import ops
-        
+
         class MyCharm(ops.CharmBase):
             pass
-        
+
         @pytest.fixture
         def context():
             return Context(charm_type=MyCharm, meta={"name": "foo"})
-        
+
         def test_sth(context):
-            context.run('start', State())
+            context.run(context.on.start(), State())
     """
     )
 
diff --git a/tests/test_runtime.py b/tests/test_runtime.py
index 1fa0e884..eaaf99ef 100644
--- a/tests/test_runtime.py
+++ b/tests/test_runtime.py
@@ -10,7 +10,7 @@
 
 from scenario import Context
 from scenario.runtime import Runtime, UncaughtCharmError
-from scenario.state import Event, Relation, State, _CharmSpec
+from scenario.state import Relation, State, _CharmSpec, _Event
 
 
 def charm_type():
@@ -56,7 +56,9 @@ class MyEvt(EventBase):
         )
 
         with runtime.exec(
-            state=State(), event=Event("bar"), context=Context(my_charm_type, meta=meta)
+            state=State(),
+            event=_Event("bar"),
+            context=Context(my_charm_type, meta=meta),
         ) as ops:
             pass
 
@@ -84,7 +86,7 @@ def test_unit_name(app_name, unit_id):
 
     with runtime.exec(
         state=State(),
-        event=Event("start"),
+        event=_Event("start"),
         context=Context(my_charm_type, meta=meta),
     ) as ops:
         assert ops.charm.unit.name == f"{app_name}/{unit_id}"
@@ -105,7 +107,7 @@ def test_env_cleanup_on_charm_error():
     with pytest.raises(UncaughtCharmError):
         with runtime.exec(
             state=State(),
-            event=Event("box_relation_changed", relation=Relation("box")),
+            event=_Event("box_relation_changed", relation=Relation("box")),
             context=Context(my_charm_type, meta=meta),
         ):
             assert os.getenv("JUJU_REMOTE_APP")