Repository: canonical/operator (language: python)

Issue:
Remote app is not added to RelationMapping for relations in some cases
- Two apps, one primary, one subordinate;
- Both have two units;
- A leader subordinate writes app relation data to a container-scoped relation for all primaries to read;
- The first primary observes the leader subordinate (relation-joined) which then writes some data to the app relation data bag;
- The second primary comes up but gets a relation-changed event for the app relation data before it observes (relation-joined) its own subordinate - `relation-list` returns no units.

The reason why it fails is that we currently retrieve a remote app from a remote unit and special-case the peer relation but do not do anything for subordinates we do not yet observe.
https://github.com/canonical/operator/blob/8ef8bd7276a5bacf33d6559db7a381b24a7c8544/ops/model.py#L335-L342

It might be that Juju needs to make a sure subordinate `-joined` event fires first for a primary and only then `-changed` events are fired (created https://bugs.launchpad.net/juju/+bug/1866828).

Additional info:
```
juju status
Model    Controller           Cloud/Region         Version  SLA          Timestamp
default  localhost-localhost  localhost/localhost  2.7.4.1  unsupported  14:09:00+03:00

App           Version  Status   Scale  Charm         Store  Rev  OS      Notes
apache-httpd           error        2  apache-httpd  local    0  ubuntu  
dummy-vhost            waiting    1/2  dummy-vhost   local    0  ubuntu  

Unit              Workload  Agent       Machine  Public address  Ports  Message
apache-httpd/0*   active    idle        0        10.209.240.137         
  dummy-vhost/0*  active    idle                 10.209.240.137         
apache-httpd/1    error     idle        1        10.209.240.253         hook failed: "vhost-config-relation-changed"
  dummy-vhost/1   waiting   allocating           10.209.240.253         agent initializing
```

The unit for which everything is OK:

```
juju show-status-log apache-httpd/0
Time                        Type       Status       Message
10 Mar 2020 14:07:41+03:00  juju-unit  allocating   
10 Mar 2020 14:07:41+03:00  workload   waiting      waiting for machine
10 Mar 2020 14:08:19+03:00  workload   waiting      installing agent
10 Mar 2020 14:08:21+03:00  workload   waiting      agent initializing
10 Mar 2020 14:08:22+03:00  workload   maintenance  installing charm software
10 Mar 2020 14:08:22+03:00  juju-unit  executing    running install hook
10 Mar 2020 14:08:32+03:00  juju-unit  executing    running leader-elected hook
10 Mar 2020 14:08:33+03:00  juju-unit  executing    running config-changed hook
10 Mar 2020 14:08:33+03:00  workload   active       
10 Mar 2020 14:08:33+03:00  juju-unit  executing    running start hook
10 Mar 2020 14:08:34+03:00  juju-unit  idle         
10 Mar 2020 14:08:40+03:00  juju-unit  executing    running vhost-config-relation-joined hook
10 Mar 2020 14:08:41+03:00  juju-unit  executing    running vhost-config-relation-changed hook
10 Mar 2020 14:08:42+03:00  juju-unit  idle         
10 Mar 2020 14:08:57+03:00  juju-unit  executing    running httpd-peer-relation-joined hook
10 Mar 2020 14:08:58+03:00  juju-unit  executing    running httpd-peer-relation-changed hook
10 Mar 2020 14:08:58+03:00  juju-unit  idle         
```

The failing unit:

```juju show-status-log apache-httpd/1
Time                        Type       Status       Message
10 Mar 2020 14:08:44+03:00  workload   waiting      agent initializing
10 Mar 2020 14:08:44+03:00  workload   maintenance  installing charm software
10 Mar 2020 14:08:44+03:00  juju-unit  executing    running install hook
10 Mar 2020 14:08:56+03:00  juju-unit  executing    running leader-settings-changed hook
10 Mar 2020 14:08:56+03:00  juju-unit  executing    running config-changed hook
10 Mar 2020 14:08:56+03:00  workload   active       
10 Mar 2020 14:08:56+03:00  juju-unit  executing    running start hook
10 Mar 2020 14:08:57+03:00  juju-unit  executing    running httpd-peer-relation-joined hook
10 Mar 2020 14:08:58+03:00  juju-unit  executing    running httpd-peer-relation-changed hook
10 Mar 2020 14:08:59+03:00  juju-unit  executing    running vhost-config-relation-changed hook
10 Mar 2020 14:08:59+03:00  juju-unit  error        hook failed: "vhost-config-relation-changed"
```

```
./hooks/vhost-config-relation-changed 
> /var/lib/juju/agents/unit-apache-httpd-1/charm/hooks/vhost-config-relation-changed(212)on_vhost_config_relation_changed()
-> vhosts_serialized = event.relation.data[event.app].get('vhosts')
(Pdb) c
Traceback (most recent call last):
  File "./hooks/vhost-config-relation-changed", line 272, in <module>
    main(Charm)
  File "lib/ops/main.py", line 195, in main
    _emit_charm_event(charm, juju_event_name)
  File "lib/ops/main.py", line 120, in _emit_charm_event
    event_to_emit.emit(*args, **kwargs)
  File "lib/ops/framework.py", line 199, in emit
    framework._emit(event)
  File "lib/ops/framework.py", line 633, in _emit
    self._reemit(event_path)
  File "lib/ops/framework.py", line 668, in _reemit
    custom_handler(event)
  File "./hooks/vhost-config-relation-changed", line 212, in on_vhost_config_relation_changed
    vhosts_serialized = event.relation.data[event.app].get('vhosts')
  File "lib/ops/model.py", line 372, in __getitem__
    return self._data[key]
KeyError: <ops.model.Application
```


```
(Pdb) dict(event.relation.data)
{<ops.model.Unit apache-httpd/1>: <ops.model.RelationDataContent object at 0x7f5d38f4ab00>, <ops.model.Application apache-httpd>: <ops.model.RelationDataContent object at 0x7f5d38f4aa58>}

```

```
[1]+  Stopped                 ./hooks/vhost-config-relation-changed
root@juju-df5eba-1:/var/lib/juju/agents/unit-apache-httpd-1/charm# relation-ids
vhost-config:1
root@juju-df5eba-1:/var/lib/juju/agents/unit-apache-httpd-1/charm# relation-list
root@juju-df5eba-1:/var/lib/juju/agents/unit-apache-httpd-1/charm# relation-list ; echo $?
0
root@juju-df5eba-1:/var/lib/juju/agents/unit-apache-httpd-1/charm# env | grep JUJU_REMOTE
JUJU_REMOTE_UNIT=
JUJU_REMOTE_APP=dummy-vhost
root@juju-df5eba-1:/var/lib/juju/agents/unit-apache-httpd-1/charm# relation-get --app
vhosts: '- {port: "80", template: PFZpcnR1YWxIb3N0ICo6ODA+CglTZXJ2ZXJBZG1pbiB3ZWJtYXN0ZXJAbG9jYWxob3N0CglEb2N1bWVudFJvb3QgL3Zhci93d3cvZHVtbXktdmhvc3QKCUVycm9yTG9nICR7QVBBQ0hFX0xPR19ESVJ9L2Vycm9yLmxvZwoJQ3VzdG9tTG9nICR7QVBBQ0hFX0xPR19ESVJ9L2FjY2Vzcy5sb2cgY29tYmluZWQKPC9WaXJ0dWFsSG9zdD4K}'
```

```
cat metadata.yaml 
name: apache-httpd
# ... 
requires:
    vhost-config:
        interface: apache-vhost-config
        scope: container
```


Discussion / hints:
A simple workaround is as follows:

```
    def on_vhost_config_relation_changed(self, event):
        # No subordinate unit observed yet, let's wait until it appears.
        if not event.unit:
            return
        some_value = event.relation.data[event.app].get('some-key')
```

Turns out it doesn't affect just subordinates but also regular units:

```
juju show-status-log haproxy/1 --days 1
Time                        Type       Status       Message
30 Mar 2020 21:21:15+03:00  juju-unit  allocating   
30 Mar 2020 21:21:15+03:00  workload   waiting      waiting for machine
30 Mar 2020 21:22:03+03:00  workload   waiting      installing agent
30 Mar 2020 21:22:05+03:00  workload   waiting      agent initializing
30 Mar 2020 21:22:06+03:00  workload   maintenance  installing charm software
30 Mar 2020 21:22:07+03:00  juju-unit  executing    running install hook
30 Mar 2020 21:22:14+03:00  juju-unit  executing    running leader-elected hook
30 Mar 2020 21:22:14+03:00  juju-unit  executing    running config-changed hook
30 Mar 2020 21:22:15+03:00  juju-unit  executing    running start hook
30 Mar 2020 21:22:15+03:00  workload   active       
30 Mar 2020 21:22:17+03:00  juju-unit  executing    running proxy-listen-tcp-relation-changed hook
30 Mar 2020 21:22:17+03:00  juju-unit  error        hook failed: "proxy-listen-tcp-relation-changed"

# debug-log
unit-haproxy-1: 21:34:09 DEBUG unit.haproxy/1.proxy-listen-tcp-relation-changed   File "lib/interface_proxy_listen_tcp.py", line 42, in listen_proxies
unit-haproxy-1: 21:34:09 DEBUG unit.haproxy/1.proxy-listen-tcp-relation-changed     app_data = relation.data[relation.app]
unit-haproxy-1: 21:34:09 DEBUG unit.haproxy/1.proxy-listen-tcp-relation-changed   File "lib/ops/model.py", line 422, in __getitem__
unit-haproxy-1: 21:34:09 DEBUG unit.haproxy/1.proxy-listen-tcp-relation-changed     return self._data[key]
unit-haproxy-1: 21:34:09 DEBUG unit.haproxy/1.proxy-listen-tcp-relation-changed KeyError: None
```
So it would be possible for juju to add something like 'relation-list --app'. However, for the event relation-changed, Juju is already setting JUJU_REMOTE_APP rather than relying on relation-list to see the remote units.

I see the problem a bit better. the issue is that the event *does* have a proper app, but that the way the framework builds Relation.data it doesn't know who the app is because it uses relation-list and then assume the app by parsing the units.


Fix patch:
diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
index ed150484c..95de3066c 100644
--- a/.git-blame-ignore-revs
+++ b/.git-blame-ignore-revs
@@ -3,3 +3,9 @@
 
 # Code reformatting with Ruff formatter
 d8148ab41869f6119683cf42b7e6e574f1a99bf1
+
+# Move ops-scenario/* to ops-scenario/testing
+645daed566516b807027588b238f700042d89272
+
+# Merge ops-scenario into operator
+45353978b19903813105f0da6dbe0fd626edb99e
diff --git a/.github/workflows/build_scenario_wheels.yaml b/.github/workflows/build_scenario_wheels.yaml
new file mode 100644
index 000000000..e1666676b
--- /dev/null
+++ b/.github/workflows/build_scenario_wheels.yaml
@@ -0,0 +1,57 @@
+name: Build ops-scenario wheels
+
+on:
+  push:
+    branches:
+      - main
+
+jobs:
+  build_wheel:
+    name: Build wheel on ubuntu (where else???)
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: actions/setup-python@v5
+
+      - name: Install build
+        run: pip install build
+
+      - name: Build wheel
+        run: python -m build
+
+      - uses: actions/upload-artifact@v3
+        with:
+          path: ./dist/*.whl
+
+      - name: Get the version
+        id: get_version
+        run: echo "VERSION=$(sed -n 's/^ *version.*=.*"\([^"]*\)".*/\1/p' pyproject.toml)" >> $GITHUB_OUTPUT
+
+      - name: release
+        uses: actions/create-release@v1
+        id: create_release
+        with:
+          draft: false
+          prerelease: false
+          tag_name: ${{ steps.get_version.outputs.VERSION }}
+          release_name: ${{ steps.get_version.outputs.VERSION }}
+
+        env:
+          GITHUB_TOKEN: ${{ github.token }}
+
+      - name: upload wheel
+        uses: actions/upload-release-asset@v1
+        env:
+          GITHUB_TOKEN: ${{ github.token }}
+        with:
+          upload_url: ${{ steps.create_release.outputs.upload_url }}
+          asset_path: ./dist/ops_scenario-${{ steps.get_version.outputs.VERSION }}-py3-none-any.whl
+          asset_name: ops_scenario-${{ steps.get_version.outputs.VERSION }}-py3-none-any.whl
+          asset_content_type: application/wheel
+
+      - name: Publish to PyPI
+        uses: pypa/gh-action-pypi-publish@release/v1
+        with:
+          password: ${{ secrets.PYPI_API_TOKEN }}
diff --git a/.gitignore b/.gitignore
index c8b24c7d0..97e7a9657 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,5 @@
 __pycache__
 /sandbox
-/ops.egg-info
 .idea
 *~
 .venv
@@ -18,9 +17,13 @@ coverage.xml
 .secrets
 
 # Build artifacts
+/ops.egg-info
 /dist
 /build
 /docs/_build
+/testing/ops_scenario.egg-info/
+/testing/build/
+/testing/dist/
 
 # Smoke test artifacts
 *.tar.gz
diff --git a/docs/resources/state-transition-model.png b/docs/resources/state-transition-model.png
new file mode 100644
index 000000000..dca511e05
Binary files /dev/null and b/docs/resources/state-transition-model.png differ
diff --git a/pyproject.toml b/pyproject.toml
index d4763bae1..024bb943e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -58,6 +58,9 @@ requires = [
 ]
 build-backend = "setuptools.build_meta"
 
+[tool.setuptools.packages.find]
+include = ["ops"]
+
 [tool.setuptools.dynamic]
 version = {attr = "ops.version.version"}
 
@@ -218,7 +221,7 @@ convention = "google"
 builtins-ignorelist = ["id", "min", "map", "range", "type", "TimeoutError", "ConnectionError", "Warning", "input", "format"]
 
 [tool.pyright]
-include = ["ops/*.py", "ops/_private/*.py", "test/*.py", "test/charms/*/src/*.py"]
+include = ["ops/*.py", "ops/_private/*.py", "test/*.py", "test/charms/*/src/*.py", "testing/src/*.py"]
 pythonVersion = "3.8" # check no python > 3.8 features are used
 pythonPlatform = "All"
 typeCheckingMode = "strict"
diff --git a/tox.ini b/tox.ini
index 66b2cc548..4b9ffbb63 100644
--- a/tox.ini
+++ b/tox.ini
@@ -76,13 +76,19 @@ commands =
 
 [testenv:static]
 description = Run static type checker
+allowlist_externals = cp
 deps =
     PyYAML==6.*
     websocket-client==1.*
     pyright==1.1.377
     pytest~=7.2
     typing_extensions~=4.2
-    ops-scenario>=7.0.5,<8.0
+    -e .
+    # TODO: If the code in testing changes, it isn't reinstalled.
+    # -e doesn't install it with the right name. We should probably
+    # build a wheel and then use that across the environments,
+    # similar to what was done in the ops-scenario repository.
+    ./testing
 commands =
     pyright {posargs}
 
@@ -97,7 +103,13 @@ deps =
     pytest~=7.2
     pytest-xdist~=3.6
     typing_extensions~=4.2
-    ops-scenario>=7.0.5,<8.0
+    jsonpatch~=1.33
+    -e .
+    # TODO: If the code in testing changes, it isn't reinstalled.
+    # -e doesn't install it with the right name. We should probably
+    # build a wheel and then use that across the environments,
+    # similar to what was done in the ops-scenario repository.
+    ./testing
 commands =
     pytest -n auto --ignore={[vars]tst_path}smoke -v --tb native {posargs}
 
@@ -112,6 +124,13 @@ deps =
     coverage[toml]~=7.0
     pytest~=7.2
     typing_extensions~=4.2
+    jsonpatch~=1.33
+    -e .
+    # TODO: If the code in testing changes, it isn't reinstalled.
+    # -e doesn't install it with the right name. We should probably
+    # build a wheel and then use that across the environments,
+    # similar to what was done in the ops-scenario repository.
+    ./testing
 commands =
     coverage run --source={[vars]src_path} \
              -m pytest --ignore={[vars]tst_path}smoke -v --tb native {posargs}
@@ -157,3 +176,24 @@ commands =
 
     # Run our smoke tests (this will build the charm, then run the tests).
     pytest -v --tb native --log-cli-level=INFO -s {posargs} {toxinidir}/test/smoke/
+
+[testenv:test-readme]
+description = Test code snippets in the testing README.
+skip_install = true
+allowlist_externals =
+    mkdir
+    cp
+deps =
+    -e .
+    # TODO: If the code in testing changes, it isn't reinstalled.
+    # -e doesn't install it with the right name. We should probably
+    # build a wheel and then use that across the environments,
+    # similar to what was done in the ops-scenario repository.
+    ./testing
+    pytest
+    pytest-markdown-docs
+commands =
+    mkdir -p {envtmpdir}/test-readme
+    cp {toxinidir}/testing/README.md {envtmpdir}/test-readme/README.md
+    cp {toxinidir}/testing/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/testing/CONTRIBUTING.md b/testing/CONTRIBUTING.md
new file mode 100644
index 000000000..29ecabeb7
--- /dev/null
+++ b/testing/CONTRIBUTING.md
@@ -0,0 +1,51 @@
+# Contributing
+
+![GitHub License](https://img.shields.io/github/license/canonical/ops-scenario)
+![GitHub Commit Activity](https://img.shields.io/github/commit-activity/y/canonical/ops-scenario)
+![GitHub Lines of Code](https://img.shields.io/tokei/lines/github/canonical/ops-scenario)
+![GitHub Issues](https://img.shields.io/github/issues/canonical/ops-scenario)
+![GitHub PRs](https://img.shields.io/github/issues-pr/canonical/ops-scenario)
+![GitHub Contributors](https://img.shields.io/github/contributors/canonical/ops-scenario)
+![GitHub Watchers](https://img.shields.io/github/watchers/canonical/ops-scenario?style=social)
+
+This documents explains the processes and practices recommended for contributing enhancements to this project.
+
+- Generally, before developing enhancements to this project, you should consider [opening an issue](https://github.com/canonical/ops-scenario/issues) explaining your use case.
+- If you would like to chat with us about your use-cases or proposed implementation, you can reach us at [Canonical Mattermost public channel](https://chat.charmhub.io/charmhub/channels/charm-dev) or [Discourse](https://discourse.charmhub.io/).
+- Familiarising yourself with the [Charmed Operator Framework](https://juju.is/docs/sdk) library will help you a lot when working on new features or bug fixes.
+- All enhancements require review before being merged. Code review typically examines:
+  - code quality
+  - test coverage
+  - user experience
+- When evaluating design decisions, we optimize for the following personas, in descending order of priority:
+  - charm authors and maintainers
+  - the contributors to this codebase
+  - juju developers
+- Please help us out in ensuring easy to review branches by rebasing your pull request branch onto the `main` branch. This also avoids merge commits and creates a linear Git commit history.
+
+## Notable design decisions
+
+- The `State` object is immutable from the perspective of the test writer.
+At the moment there is some hackery here and there (`object.__setattr__`...) to bypass the read-only dataclass for when the charm code mutates the state; at some point it would be nice to refactor the code to make that unnecessary.
+
+- At the moment the mocking operates at the level of `ops.ModelBackend`-mediated hook tool calls. `ModelBackend` would `Popen` hook tool calls, but `Scenario` patches the methods that would call `Popen`, which is therefore never called. Instead, values are returned according to the `State`. We could consider allowing to operate in increasing levels of stricter confinement:
+  - Actually generate hook tool scripts that read/write from/to `State`, making patching `ModelBackend` unnecessary.
+  - On top of that, run the whole simulation in a container.
+
+## Developing
+
+To set up the dependencies you can run:
+`pip install . ; pip uninstall ops-scenario`
+
+We recommend using the provided `pre-commit` config. For how to set up git pre-commit: [see here](https://pre-commit.com/).
+If you dislike that, you can always manually remember to `tox -e lint` before you push.
+ 
+### Testing
+```shell
+tox -e fmt           # auto-fix your code as much as possible, including formatting and linting
+tox -e lint          # code style
+tox -e unit          # unit tests
+tox -e lint-tests    # lint testing code
+tox                  # runs 'lint', 'lint-tests' and 'unit' environments
+```
+
diff --git a/testing/README.md b/testing/README.md
new file mode 100644
index 000000000..9b1f5e307
--- /dev/null
+++ b/testing/README.md
@@ -0,0 +1,1186 @@
+# Scenario
+
+[![Build](https://github.com/canonical/ops-scenario/actions/workflows/build_wheels.yaml/badge.svg)](https://github.com/canonical/ops-scenario/actions/workflows/build_wheels.yaml)
+[![QC](https://github.com/canonical/ops-scenario/actions/workflows/quality_checks.yaml/badge.svg?event=pull_request)](https://github.com/canonical/ops-scenario/actions/workflows/quality_checks.yaml?event=pull_request)
+[![Discourse Status](https://img.shields.io/discourse/status?server=https%3A%2F%2Fdiscourse.charmhub.io&style=flat&label=CharmHub%20Discourse)](https://discourse.charmhub.io)
+[![foo](https://img.shields.io/badge/everything-charming-blueviolet)](https://github.com/PietroPasotti/jhack)
+[![Awesome](https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg)](https://discourse.charmhub.io/t/rethinking-charm-testing-with-ops-scenario/8649)
+[![Python >= 3.8](https://img.shields.io/badge/python-3.8-blue.svg)](https://www.python.org/downloads/release/python-380/)
+
+Scenario is a state-transition testing SDK for Operator Framework charms.
+
+Where the Harness enables you to procedurally mock pieces of the state the charm needs to function, Scenario tests allow
+you to declaratively define the state all at once, and use it as a sort of context against which you can fire a single
+event on the charm and execute its logic.
+
+This puts scenario tests somewhere in between unit and integration tests: some say 'functional', some say 'contract', I prefer 'state-transition'.
+
+Scenario tests nudge you into thinking of a charm as an 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 relation data? what is my
+config?...) and the charm's execution `Context` (what relations 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 initial state back.
+
+![state transition model depiction](https://raw.githubusercontent.com/canonical/ops-scenario/main/resources/state-transition-model.png)
+
+For example: a charm currently in `unknown` status is executed with a `start` event, and based on whether it has leadership or not (according to its input state), it will decide to set `active` or `blocked` status (which will be reflected in the output state).
+
+Scenario-testing a charm, then, means verifying that:
+
+- the charm does not raise uncaught exceptions while handling the event
+- the output state (or the diff with the input state) is as expected.
+
+## Core concepts as a metaphor
+
+I like metaphors, so here we go:
+
+- There is a theatre stage.
+- You pick an actor (a Charm) to put on the stage. Not just any actor: an improv one.
+- You arrange the stage with content that the actor will have to interact with. This consists of selecting:
+    - An initial situation (`State`) in which the actor is, e.g. is the actor the main role or an NPC (`is_leader`), or what
+      other actors are there around it, what is written in those pebble-shaped books on the table?
+    - Something that has just happened (an `Event`) and to which the actor has to react (e.g. one of the NPCs leaves the
+      stage (`relation-departed`), or the content of one of the books changes).
+- How the actor will react to the event will have an impact on the context: e.g. the actor might knock over a table (a
+  container), or write something down into one of the books.
+
+## Core concepts not as a metaphor
+
+Scenario tests are about running assertions on atomic state transitions treating the charm being tested like a black
+box. An initial state goes in, an event occurs (say, `'start'`) and a new state comes out. Scenario tests are about
+validating the transition, that is, consistency-checking the delta between the two states, and verifying the charm
+author's expectations.
+
+Comparing scenario tests with `Harness` tests:
+
+- Harness exposes an imperative API: the user is expected to call methods on the Harness driving it to the desired
+  state, then verify its validity by calling charm methods or inspecting the raw data. In contrast, Scenario is declarative. You fully specify an initial state, an execution context and an event, then you run the charm and inspect the results.
+- Harness instantiates the charm once, then allows you to fire multiple events on the charm, which is breeding ground
+  for subtle bugs. Scenario tests are centered around testing single state transitions, that is, one event at a time.
+  This ensures that the execution environment is as clean as possible (for a unit test).
+- Harness maintains a model of the Juju Model, which is a maintenance burden and adds complexity. Scenario mocks at the
+  level of hook tools and stores all mocking data in a monolithic data structure (the State), which makes it more
+  lightweight and portable.
+
+# Writing scenario tests
+
+A scenario test consists of three broad steps:
+
+- **Arrange**:
+    - declare the context 
+    - declare the input state
+    - select an event to fire
+- **Act**:
+    - run the context (i.e. obtain the output state, given the input state and the event)
+- **Assert**:
+    - verify that the output state (or the delta 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 internal APIs and the internal state of the charm and operator framework.
+
+The most basic scenario is one in which all is defaulted and barely any data is
+available. The charm has no config, no relations, no leadership, and its status is `unknown`.
+
+With that, we can write the simplest possible scenario test:
+
+```python
+def test_scenario_base():
+    ctx = scenario.Context(MyCharm, meta={"name": "foo"})
+    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 pytest
+
+
+class MyCharm(ops.CharmBase):
+    def __init__(self, framework):
+        super().__init__(framework)
+        framework.observe(self.on.start, self._on_start)
+
+    def _on_start(self, _):
+        if self.unit.is_leader():
+            self.unit.status = ops.ActiveStatus('I rule')
+        else:
+            self.unit.status = ops.ActiveStatus('I am ruled')
+
+
+@pytest.mark.parametrize('leader', (True, False))
+def test_status_leader(leader):
+    ctx = scenario.Context(MyCharm, meta={"name": "foo"})
+    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
+ask the Juju model: am I leader? What are my relations? What is the remote unit I'm talking to? etc...
+
+## Statuses
+
+One of the simplest types of black-box testing available to charmers is to execute the charm and verify that the charm
+sets the expected unit/application status. We have seen a simple example above including leadership. But what if the
+charm transitions through a sequence of statuses?
+
+```python
+# charm code:
+def _on_event(self, _event):
+    self.unit.status = ops.MaintenanceStatus('determining who the ruler is...')
+    try:
+        if self._call_that_takes_a_few_seconds_and_only_passes_on_leadership():
+            self.unit.status = ops.ActiveStatus('I rule')
+        else:
+            self.unit.status = ops.WaitingStatus('checking this is right...')
+            self._check_that_takes_some_more_time()
+            self.unit.status = ops.ActiveStatus('I am ruled')
+    except:
+        self.unit.status = ops.BlockedStatus('something went wrong')
+```
+
+More broadly, often we want to test 'side effects' of executing a charm, such as what events have been emitted, what
+statuses it went through, etc... Before we get there, we have to explain what the `Context` represents, and its
+relationship with the `State`.
+
+## Context and State
+
+Consider the following tests. Suppose we want to verify that while handling a given top-level Juju event:
+
+- a specific chain of (custom) events was emitted on the charm
+- the charm `juju-log`ged these specific strings
+- the charm went through this sequence of app/unit statuses (e.g. `maintenance`, then `waiting`, then `active`)
+
+These types of test have a place in Scenario, but that is not State: the contents of the Juju log or the status history
+are side effects of executing a charm, but are not persisted in a charm-accessible "state" in any meaningful way.
+In other words: those data streams are, from the charm's perspective, write-only.
+
+As such, they do not belong in `scenario.State` but in `scenario.Context`: the object representing the charm's execution
+context.
+
+## Status history
+
+You can verify that the charm has followed the expected path by checking the unit/app status history like so:
+
+```python
+def test_statuses():
+    ctx = scenario.Context(MyCharm, meta={"name": "foo"})
+    out = ctx.run(ctx.on.start(), scenario.State(leader=False))
+    assert ctx.unit_status_history == [
+        scenario.UnknownStatus(),
+        scenario.MaintenanceStatus('determining who the ruler is...'),
+        scenario.WaitingStatus('checking this is right...'),
+    ]
+    assert out.unit_status == scenario.ActiveStatus("I am ruled")
+    
+    # similarly you can check the app status history:
+    assert ctx.app_status_history == [
+        scenario.UnknownStatus(),
+        ...
+    ]
+```
+
+Note that the *current* status is **not** in the unit status history.
+
+Also note that, unless you initialize the State with a preexisting status, the first status in the history will always
+be `unknown`. That is because, so far as Scenario is concerned, each event is "the first event this charm has ever
+seen".
+
+If you want to simulate a situation in which the charm already has seen some event, and is in a status other than
+Unknown (the default status every charm is born with), you will have to pass the 'initial status' to State.
+
+```python
+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 = scenario.Context(MyCharm, meta={"name": "foo"})
+ctx.run(ctx.on.start(), scenario.State(unit_status=scenario.ActiveStatus('foo')))
+assert ctx.unit_status_history == [
+    scenario.ActiveStatus('foo'),  # now the first status is active: 'foo'!
+    # ...
+]
+```
+
+## Workload version history
+
+Using a similar api to `*_status_history`, you can assert that the charm has set one or more workload versions during a
+hook execution:
+
+```python
+# ...
+ctx = scenario.Context(HistoryCharm, meta={"name": "foo"})
+ctx.run(ctx.on.start(), scenario.State())
+assert ctx.workload_version_history == ['1', '1.2', '1.5']
+# ...
+```
+
+Note that the *current* version is not in the version history, as with the status history.
+
+## Emitted events
+
+If your charm deals with deferred events, custom events, and charm libs that in turn emit their own custom events, it
+can be hard to examine the resulting control flow. In these situations it can be useful to verify that, as a result of a
+given Juju event triggering (say, 'start'), a specific chain of events is emitted on the charm. The
+resulting state, black-box as it is, gives little insight into how exactly it was obtained.
+
+```python
+def test_foo():
+    ctx = scenario.Context(...)
+    ctx.run(ctx.on.start(), ...)
+
+    assert len(ctx.emitted_events) == 1
+    assert isinstance(ctx.emitted_events[0], ops.StartEvent)
+```
+
+You can configure what events will be captured by passing the following arguments to `Context`:
+-  `capture_deferred_events`: If you want to include re-emitted deferred events.
+-  `capture_framework_events`: If you want to include framework events (`pre-commit`, `commit`, and `collect-status`). 
+
+For example:
+```python
+def test_emitted_full():
+    ctx = scenario.Context(
+        MyCharm,
+        capture_deferred_events=True,
+        capture_framework_events=True,
+    )
+    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] == [
+        "update_status",
+        "start",
+        "collect_unit_status",
+        "pre_commit",
+        "commit",
+    ]
+```
+
+## Relations
+
+You can write scenario tests to verify the shape of relation data:
+
+```python
+# This charm copies over remote app data to local unit data
+class MyCharm(ops.CharmBase):
+    ...
+
+    def _on_event(self, event):
+        rel = event.relation
+        assert rel.app.name == 'remote'
+        assert rel.data[self.unit]['abc'] == 'foo'
+        rel.data[self.unit]['abc'] = rel.data[event.app]['cde']
+
+
+def test_relation_data():
+    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(ctx.on.start(), state_in)
+
+    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",
+            remote_app_name="remote",
+            local_unit_data={"abc": "baz!"},
+            remote_app_data={"cde": "baz!"},
+        ),
+    }
+
+# which is very idiomatic and superbly explicit.
+```
+
+The only mandatory argument to `Relation` (and other relation types, see below) is `endpoint`. The `interface` will be
+derived from the charm's metadata. When fully defaulted, a relation is 'empty'. There are no remote units, the
+remote application is called `'remote'` and only has a single unit `remote/0`, and nobody has written any data to the
+databags yet.
+
+That is typically the state of a relation when the first unit joins it.
+
+When you use `Relation`, you are specifying a regular (conventional) relation. But that is not the only type of
+relation. There are also peer relations and subordinate relations. While in the background the data model is the same,
+the data access rules and the consistency constraints on them are very different. For example, it does not make sense
+for a peer relation to have a different 'remote app' than its 'local app', because it's the same application.
+
+### PeerRelation
+
+To declare a peer relation, you should use `scenario.PeerRelation`. The core difference with regular relations is
+that peer relations do not have a "remote app" (it's this app, in fact). So unlike `Relation`, a `PeerRelation` does not
+have `remote_app_name` or `remote_app_data` arguments. Also, it talks in terms of `peers`:
+
+- `Relation.remote_units_data` maps to `PeerRelation.peers_data`
+
+```python
+relation = scenario.PeerRelation(
+    endpoint="peers",
+    peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}},
+)
+```
+
+be mindful when using `PeerRelation` not to include **"this unit"**'s ID in `peers_data` or `peers_ids`, as that would
+be flagged by the Consistency Checker:
+
+```python
+state_in = scenario.State(relations={
+    scenario.PeerRelation(
+        endpoint="peers",
+        peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}},
+    )})
+
+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
+
+To declare a subordinate relation, you should use `scenario.SubordinateRelation`. The core difference with regular
+relations is that subordinate relations always have exactly one remote unit (there is always exactly one remote unit
+that this unit can see). 
+Because of that, `SubordinateRelation`, compared to `Relation`, always talks in terms of `remote`:
+
+- `Relation.remote_units_data` becomes `SubordinateRelation.remote_unit_data` taking a single `Dict[str:str]`. The remote unit ID can be provided as a separate argument. 
+- `Relation.remote_unit_ids` becomes `SubordinateRelation.remote_unit_id` (a single ID instead of a list of IDs)
+- `Relation.remote_units_data` becomes `SubordinateRelation.remote_unit_data` (a single databag instead of a mapping from unit IDs to databags)
+
+```python
+relation = scenario.SubordinateRelation(
+    endpoint="peers",
+    remote_unit_data={"foo": "bar"},
+    remote_app_name="zookeeper",
+    remote_unit_id=42
+)
+relation.remote_unit_name  # "zookeeper/42"
+```
+
+### Triggering Relation Events
+
+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
+ctx = scenario.Context(MyCharm, meta=MyCharm.META)
+
+relation = scenario.Relation(endpoint="foo", interface="bar")
+changed_event = ctx.on.relation_changed(relation=relation)
+joined_event = ctx.on.relation_joined(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.
+
+### 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, 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.
+
+```python
+ctx = scenario.Context(MyCharm, meta=MyCharm.META)
+
+relation = scenario.Relation(endpoint="foo", interface="bar")
+remote_unit_2_is_joining_event = ctx.on.relation_joined(relation, remote_unit=2)
+```
+
+## Networks
+
+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`) 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
+state = scenario.State(networks={
+  scenario.Network("foo", [scenario.BindAddress([scenario.Address('192.0.2.1')])])
+})
+```
+
+Where `foo` can either be the name of an `extra-bindings`-defined binding, or a relation endpoint.
+
+## Containers
+
+When testing a Kubernetes charm, you can mock container interactions. When using the null state (`State()`), there will
+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={...})`
+
+An example of a state including some containers:
+
+```python
+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`.
+
+### Container filesystem setup
+
+You can configure a container to have some files in it:
+
+```python
+import pathlib
+
+local_file = pathlib.Path('/path/to/local/real/file.txt')
+
+container = scenario.Container(
+    name="foo",
+    can_connect=True,
+    mounts={'local': scenario.Mount(location='/local/share/config.yaml', source=local_file)}
+    )
+state = scenario.State(containers={container})
+```
+
+In this case, if the charm were to:
+
+```python
+def _on_start(self, _):
+    foo = self.unit.get_container('foo')
+    content = foo.pull('/local/share/config.yaml').read()
+```
+
+then `content` would be the contents of our locally-supplied `file.txt`. You can use `tempfile` for nicely wrapping
+data and passing it to the charm via the container.
+
+`container.push` works similarly, so you can write a test like:
+
+```python
+import tempfile
+
+
+class MyCharm(ops.CharmBase):
+    def __init__(self, framework):
+        super().__init__(framework)
+        framework.observe(self.on.foo_pebble_ready, self._on_pebble_ready)
+
+    def _on_pebble_ready(self, _):
+        foo = self.unit.get_container('foo')
+        foo.push('/local/share/config.yaml', "TEST", make_dirs=True)
+
+
+def test_pebble_push():
+    with tempfile.NamedTemporaryFile() as local_file:
+        container = scenario.Container(
+            name='foo',
+            can_connect=True,
+            mounts={'local': Mount(location='/local/share/config.yaml', source=local_file.name)}
+        )
+        state_in = State(containers={container})
+        ctx = Context(
+            MyCharm,
+            meta={"name": "foo", "containers": {"foo": {}}}
+        )
+        ctx.run(
+            ctx.on.pebble_ready(container),
+            state_in,
+        )
+        assert local_file.read().decode() == "TEST"
+```
+
+`container.pebble_ready_event` is syntactic sugar for: `Event("foo-pebble-ready", container=container)`. The reason we
+need to associate the container with the event is that the Framework uses an envvar to determine which container the
+pebble-ready event is about (it does not use the event name). Scenario needs that information, similarly, for injecting
+that envvar into the charm's runtime.
+
+### Container filesystem post-mortem
+
+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
+class MyCharm(ops.CharmBase):
+    def __init__(self, framework):
+        super().__init__(framework)
+        framework.observe(self.on.foo_pebble_ready, self._on_pebble_ready)
+
+    def _on_pebble_ready(self, _):
+        foo = self.unit.get_container('foo')
+        foo.push('/local/share/config.yaml', "TEST", make_dirs=True)
+
+
+def test_pebble_push():
+    container = scenario.Container(name='foo', can_connect=True)
+    state_in = scenario.State(containers={container})
+    ctx = scenario.Context(
+        MyCharm,
+        meta={"name": "foo", "containers": {"foo": {}}}
+    )
+    
+    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)
+    cfg_file = container_root_fs / 'local' / 'share' / 'config.yaml'
+    assert cfg_file.read_text() == "TEST"
+```
+
+### `Container.exec` mocks
+
+`container.exec` is a tad more complicated, but if you get to this low a level of simulation, you probably will have far
+worse issues to deal with. You need to specify, for each possible command the charm might run on the container, what the
+result of that would be: its return code, what will be written to stdout/stderr.
+
+```python
+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
+.rw-rw-r--  900 ubuntu ubuntu 18 jan 12:05 -- CONTRIBUTING.md
+drwxrwxr-x    - ubuntu ubuntu 18 jan 12:06 -- lib
+"""
+
+
+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
+
+
+def test_pebble_exec():
+    container = scenario.Container(
+        name='foo',
+        execs={
+            scenario.Exec(
+                command_prefix=['ls'],
+                return_code=0,
+                stdout=LS_LL,
+            ),
+        }
+    )
+    state_in = scenario.State(containers={container})
+    ctx = scenario.Context(
+        MyCharm,
+        meta={"name": "foo", "containers": {"foo": {}}},
+    )
+    state_out = ctx.run(
+        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
+class MyCharm(ops.CharmBase):
+    def __init__(self, framework):
+        super().__init__(framework)
+        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("my-container").get_notices():
+            ...
+
+ctx = scenario.Context(MyCharm, meta={"name": "foo", "containers": {"my-container": {}}})
+notices = [
+    scenario.Notice(key="example.com/a", occurrences=10),
+    scenario.Notice(key="example.com/b", last_data={"bar": "baz"}),
+    scenario.Notice(key="example.com/c"),
+]
+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
+
+If your charm defines `storage` in its metadata, you can use `scenario.Storage` to instruct Scenario to make (mocked) filesystem storage available to the charm at runtime.
+
+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
+# Some charm with a 'foo' filesystem-type storage defined in its metadata:
+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(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()
+    assert path.read_text() == "helloworld"
+
+    myfile = loc / "path.py"
+    myfile.write_text("helloworlds")
+
+# post-mortem: inspect fs contents.
+assert (
+    storage.get_filesystem(ctx) / "path.py"
+).read_text() == "helloworlds"
+```
+
+Note that State only wants to know about **attached** storages. A storage which is not attached to the charm can simply be omitted from State and the charm will be none the wiser.
+
+### Storage-add
+
+If a charm requests adding more storage instances while handling some event, you can inspect that from the `Context.requested_storage` API.
+
+```python notest
+# In MyCharm._on_foo:
+# The charm requests two new "foo" storage instances to be provisioned:
+self.model.storages.request("foo", 2)
+```
+
+From test code, you can inspect that:
+
+```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
+```
+
+Requesting storages has no other consequence in Scenario. In real life, this request will trigger Juju to provision the storage and execute the charm again with `foo-storage-attached`.
+So a natural follow-up Scenario test suite for this case would be:
+
+```python
+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(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(ctx.on.storage_attached(foo_1), scenario.State(storages={foo_0, foo_1}))
+```
+
+## Ports
+
+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
+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
+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(ctx.on.stop(), state1)
+assert state2.opened_ports == {}
+```
+
+## Secrets
+
+Scenario has secrets. Here's how you use them.
+
+```python
+state = scenario.State(
+    secrets={
+        scenario.Secret(
+            tracked_content={'key': 'public'},
+            latest_content={'key': 'public', 'cert': 'private'},
+        )
+    }
+)
+```
+
+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 (this includes user secrets)
+
+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.
+
+[note]
+If this charm does not own the secret, but also it was not granted view rights by the (remote) owner, you model this in Scenario by _not adding it to State.secrets_! The presence of a `Secret` in `State.secrets` means, in other words, that the charm has view rights (otherwise, why would we put it there?). If the charm owns the secret, or is leader, it will _also_ have manage rights on top of view ones.
+[/note]
+
+To specify a secret owned by this unit (or app):
+
+```python
+rel = scenario.Relation("web")
+state = scenario.State(
+    secrets={
+        scenario.Secret(
+            {'key': 'private'},
+            owner='unit',  # or 'app'
+            # 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, or a user secret, and give this unit (or app) access to it:
+
+```python
+state = scenario.State(
+    secrets={
+        scenario.Secret(
+            {'key': 'public'},
+            # owner=None, which is the default
+        )
+    }
+)
+```
+
+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
+class SecretCharm(ops.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):
+        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()
+
+    def __init__(self, framework):
+        super().__init__(framework)
+        assert self.my_stored_state.foo == 'bar'  # this will pass!
+
+
+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
+the output side the same as any other bit of state.
+
+## Resources
+
+If your charm requires access to resources, you can make them available to it through `State.resources`.
+From the perspective of a 'real' deployed charm, if your charm _has_ resources defined in its metadata, they _must_ be made available to the charm. That is a Juju-enforced constraint: you can't deploy a charm without attaching all resources it needs to it.
+However, when testing, this constraint is unnecessarily strict (and it would also mean the great majority of all existing tests would break) since a charm will only notice that a resource is not available when it explicitly asks for it, which not many charms do.
+
+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 pathlib
+
+ctx = scenario.Context(MyCharm, meta={'name': 'juliette', "resources": {"foo": {"type": "oci-image"}}})
+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 = manager.charm.model.resources.fetch('foo')
+    assert path == pathlib.Path('/path/to/resource.tar')
+```
+
+## Model
+
+Charms don't usually need to be aware of the model in which they are deployed,
+but if you need to set the model name or UUID, you can provide a `scenario.Model`
+to the state:
+
+```python
+ctx = scenario.Context(MyCharm, meta={"name": "foo"})
+state_in = scenario.State(model=scenario.Model(name="my-model"))
+out = ctx.run(ctx.on.start(), state_in)
+assert out.model.name == "my-model"
+assert out.model.uuid == state_in.model.uuid
+```
+
+### CloudSpec
+
+You can set CloudSpec information in the model (only `type` and `name` are required).
+
+Example:
+
+```python
+import scenario
+
+cloud_spec=scenario.CloudSpec(
+    type="lxd",
+    endpoint="https://127.0.0.1:8443",
+    credential=scenario.CloudCredential(
+        auth_type="clientcertificate",
+        attributes={
+            "client-cert": "foo",
+            "client-key": "bar",
+            "server-cert": "baz",
+        },
+    ),
+)
+state = scenario.State(
+    model=scenario.Model(name="my-vm-model", type="lxd", cloud_spec=cloud_spec),
+)
+```
+
+Then you can access it by `Model.get_cloud_spec()`:
+
+```python
+# charm.py
+class MyVMCharm(ops.CharmBase):
+    def __init__(self, framework: ops.Framework):
+        super().__init__(framework)
+        framework.observe(self.on.start, self._on_start)
+
+    def _on_start(self, event: ops.StartEvent):
+        self.cloud_spec = self.model.get_cloud_spec()
+```
+
+# Actions
+
+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.
+
+How to test actions with scenario:
+
+## Actions without parameters
+
+```python
+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.
+    state = ctx.run(ctx.on.action("do_backup"), scenario.State())
+
+    # You can assert on action results and logs using the context:
+    assert ctx.action_logs == ['baz', 'qux']
+    assert ctx.action_results == {'foo': 'bar'}
+```
+
+## Failing Actions
+
+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
+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(...)
+
+    # You can still assert action results and logs that occurred 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():
+    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.
+    state = ctx.run(
+        ctx.on.action("do_backup", params={'a': 'b'}),
+        scenario.State()
+    )
+
+    # ...
+```
+
+# Deferred events
+
+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. You generate the deferred data structure using the event's `deferred()` method:
+
+```python
+class MyCharm(ops.CharmBase):
+    ...
+
+    def _on_update_status(self, event):
+        event.defer()
+
+    def _on_start(self, event):
+        event.defer()
+
+
+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=[
+            ctx.on.update_status().deferred(handler=MyCharm._on_update_status)
+        ]
+    )
+    state_out = ctx.run(ctx.on.start(), state_in)
+    assert len(state_out.deferred) == 1
+    assert state_out.deferred[0].name == '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
+class MyCharm(ops.CharmBase):
+    ...
+
+    def _on_start(self, event):
+        event.defer()
+
+
+def test_defer(MyCharm):
+    out = scenario.Context(MyCharm).run(ctx.on.start(), scenario.State())
+    assert len(out.deferred) == 1
+    assert out.deferred[0].name == 'start'
+```
+
+# 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.
+
+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
+
+
+class MyCharm(ops.CharmBase):
+    META = {"name": "mycharm"}
+    _stored = ops.StoredState()
+    
+    def __init__(self, framework):
+        super().__init__(framework)
+        self._stored.set_default(a="a")
+        self.my_charm_lib = CharmLib()
+        framework.observe(self.on.start, self._on_start)
+
+    def _on_start(self, event):
+        self._stored.a = "b"
+
+
+def test_live_charm_introspection(mycharm):
+    ctx = scenario.Context(mycharm, meta=mycharm.META)
+    with ctx(ctx.on.start(), scenario.State()) as manager:
+        # This is your charm instance, after ops has set it up:
+        charm: MyCharm = manager.charm
+        
+        # We can check attributes on nested Objects or the charm itself:
+        assert charm.my_charm_lib.foo == "foo"
+        # such as stored state:
+        assert charm._stored.a == "a"
+
+        # This will tell ops.main to proceed with normal execution and emit the "start" event on the charm:
+        state_out = manager.run()
+    
+        # After that is done, we are handed back control, and we can again do some introspection:
+        assert charm.my_charm_lib.foo == "bar"
+        # and check that the charm's internal state is as we expect:
+        assert charm._stored.a == "b"
+
+    # state_out is, as in regular scenario tests, a State object you can assert on:
+    assert state_out.unit_status == ...
+```
+
+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
+
+Before executing the charm, Scenario copies the charm's `/src`, any libs, the metadata, config, and actions `yaml`s to a temporary directory. The
+charm will see that temporary directory as its 'root'. This allows us to keep things simple when dealing with metadata that can be
+either inferred from the charm type being passed to `Context` or be passed to it as an argument, thereby overriding
+the inferred one. This also allows you to test charms defined on the fly, as in:
+
+```python
+class MyCharmType(ops.CharmBase):
+    pass
+
+
+ctx = scenario.Context(charm_type=MyCharmType, meta={'name': 'my-charm-name'})
+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
+you are passing to `.run()` (because `ops` expects it to be a file...). That is, unless you pass your own:
+
+```python
+import tempfile
+
+
+class MyCharmType(ops.CharmBase):
+    pass
+
+
+td = tempfile.TemporaryDirectory()
+ctx = scenario.Context(
+    charm_type=MyCharmType,
+    meta={'name': 'my-charm-name'},
+    charm_root=td.name
+)
+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
+contents after the charm has run. Do keep in mind that any metadata files you create in it will be overwritten by Scenario, and therefore
+ignored, if you pass any metadata keys to `Context`. Omit `meta` in the call
+above, and Scenario will instead attempt to read metadata from the
+temporary directory.
+
+# Immutability
+
+All of the data structures in `state`, e.g. `State, Relation, Container`, etc... are implemented as frozen dataclasses.
+
+This means that all components of the state that goes into a `context.run()` call are not mutated by the call, and the
+state that you obtain in return is a different instance, and all parts of it have been (deep)copied.
+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 dataclasses `replace` api.
+
+```python
+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 = dataclasses.replace(relation, remote_app_data={"3": "4"})
+```
+
+# Consistency checks
+
+A Scenario, that is, the combination of an event, a state, and a charm, is consistent if it's plausible in JujuLand. 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. If that happens, that's a Juju bug. Scenario however assumes that Juju is bug-free,
+therefore, so far as we're concerned, that can't happen, and therefore we help you verify that the scenarios you create
+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.
+
+## Caveats:
+
+- False positives: not all checks are implemented yet; more will come.
+- False negatives: it is possible that a scenario you know to be consistent is seen as inconsistent. That is probably a
+  bug in the consistency checker itself; please report it.
+- Inherent limitations: if you have a custom event whose name conflicts with a builtin one, the consistency constraints
+  of the builtin one will apply. For example: if you decide to name your custom event `bar-pebble-ready`, but you are
+  working on a machine charm or don't have either way a `bar` container in your `metadata.yaml`, Scenario will flag that
+  as inconsistent.
+
+## Bypassing the checker
+
+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` envvar and skip it altogether. Hopefully you
+don't need that.
+
+# Jhack integrations
+
+Up until `v5.6.0`, Scenario shipped with a cli tool called `snapshot`, used to interact with a live charm's state.
+The functionality [has been moved over to `jhack`](https://github.com/PietroPasotti/jhack/pull/111), 
+to allow us to keep working on it independently, and to streamline 
+the profile of Scenario itself as it becomes more broadly adopted and ready for widespread usage.
diff --git a/testing/UPGRADING.md b/testing/UPGRADING.md
new file mode 100644
index 000000000..16df72fa5
--- /dev/null
+++ b/testing/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/testing/pyproject.toml b/testing/pyproject.toml
new file mode 100644
index 000000000..fd8eb83c6
--- /dev/null
+++ b/testing/pyproject.toml
@@ -0,0 +1,52 @@
+[build-system]
+requires = [
+    "setuptools >= 35.0.2",
+    "setuptools_scm >= 2.0.0, <3"
+]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "ops-scenario"
+
+version = "7.0.5"
+
+authors = [
+    { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" }
+]
+maintainers = [
+    { name = "The Charm Tech team at Canonical Ltd.", email = "charm-tech@lists.launchpad.net" }
+]
+description = "Python library providing a state-transition testing API for Operator Framework charms."
+license.text = "Apache-2.0"
+keywords = ["juju", "test"]
+
+dependencies = [
+    "ops~=2.17",
+    "PyYAML>=6.0.1",
+]
+readme = "README.md"
+requires-python = ">=3.8"
+
+classifiers = [
+    "Development Status :: 5 - Production/Stable",
+    "License :: OSI Approved :: Apache Software License",
+    'Framework :: Pytest',
+    'Intended Audience :: Developers',
+    'Topic :: Software Development :: Quality Assurance',
+    'Topic :: Software Development :: Testing',
+    'Topic :: Utilities',
+]
+
+[project.urls]
+"Homepage" = "https://github.com/canonical/operator"
+"Bug Tracker" = "https://github.com/canonical/operator/issues"
+
+[tool.setuptools.package-dir]
+scenario = "src"
+
+[tool.ruff.format]
+# Like Black, use double quotes for strings.
+quote-style = "double"
+
+[bdist_wheel]
+universal = 1
diff --git a/testing/src/__init__.py b/testing/src/__init__.py
new file mode 100644
index 000000000..557beb3d4
--- /dev/null
+++ b/testing/src/__init__.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+# Copyright 2023 Canonical Ltd.
+# See LICENSE file for licensing details.
+
+"""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 CharmEvents, Context, Manager
+from scenario.errors import StateValidationError  # For backwards compatibility.
+from ops._private.harness import ActionFailed  # For backwards compatibility.
+from scenario.state import (
+    ActiveStatus,
+    Address,
+    AnyJson,
+    BindAddress,
+    BlockedStatus,
+    CharmType,
+    CheckInfo,
+    CloudCredential,
+    CloudSpec,
+    Container,
+    DeferredEvent,
+    ErrorStatus,
+    Exec,
+    ICMPPort,
+    JujuLogLine,
+    MaintenanceStatus,
+    Model,
+    Mount,
+    Network,
+    Notice,
+    PeerRelation,
+    Port,
+    RawDataBagContents,
+    RawSecretRevisionContents,
+    Relation,
+    RelationBase,
+    Resource,
+    Secret,
+    State,
+    Storage,
+    StoredState,
+    SubordinateRelation,
+    TCPPort,
+    UDPPort,
+    UnitID,
+    UnknownStatus,
+    WaitingStatus,
+)
+
+__all__ = [
+    "ActionFailed",
+    "ActiveStatus",
+    "Address",
+    "AnyJson",
+    "BindAddress",
+    "BlockedStatus",
+    "CharmEvents",
+    "CharmType",
+    "CheckInfo",
+    "CloudCredential",
+    "CloudSpec",
+    "Container",
+    "Context",
+    "DeferredEvent",
+    "ErrorStatus",
+    "Exec",
+    "ICMPPort",
+    "JujuLogLine",
+    "MaintenanceStatus",
+    "Manager",
+    "Model",
+    "Mount",
+    "Network",
+    "Notice",
+    "PeerRelation",
+    "Port",
+    "RawDataBagContents",
+    "RawSecretRevisionContents",
+    "Relation",
+    "RelationBase",
+    "Resource",
+    "Secret",
+    "State",
+    "StateValidationError",
+    "Storage",
+    "StoredState",
+    "SubordinateRelation",
+    "TCPPort",
+    "UDPPort",
+    "UnitID",
+    "UnknownStatus",
+    "WaitingStatus",
+]
diff --git a/testing/src/_consistency_checker.py b/testing/src/_consistency_checker.py
new file mode 100644
index 000000000..eab68c416
--- /dev/null
+++ b/testing/src/_consistency_checker.py
@@ -0,0 +1,714 @@
+#!/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, defaultdict
+from collections.abc import Sequence
+from numbers import Number
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    Callable,
+    Dict,
+    Iterable,
+    List,
+    NamedTuple,
+    Optional,
+    Set,
+    Tuple,
+    Union,
+)
+
+from scenario.errors import InconsistentScenarioError
+from scenario.runtime import logger as scenario_logger
+from scenario.state import (
+    CharmType,
+    PeerRelation,
+    SubordinateRelation,
+    _Action,
+    _CharmSpec,
+    _normalise_name,
+)
+
+if TYPE_CHECKING:  # pragma: no cover
+    from scenario.state import State, _Event
+
+logger = scenario_logger.getChild("consistency_checker")
+
+
+class Results(NamedTuple):
+    """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]
+
+
+def check_consistency(
+    state: "State",
+    event: "_Event",
+    charm_spec: "_CharmSpec[Any]",
+    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,
+    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(".")))
+
+    if os.getenv("SCENARIO_SKIP_CONSISTENCY_CHECKS"):
+        logger.info("skipping consistency checks.")
+        return
+
+    errors: List[str] = []
+    warnings: List[str] = []
+
+    checks: Tuple[Callable[..., Results]] = (
+        check_containers_consistency,
+        check_config_consistency,
+        check_resource_consistency,
+        check_event_consistency,
+        check_secrets_consistency,
+        check_storages_consistency,
+        check_relation_consistency,
+        check_network_consistency,
+        check_cloudspec_consistency,
+        check_storedstate_consistency,
+    )  # type: ignore
+    for check in checks:
+        results = check(
+            state=state,
+            event=event,
+            charm_spec=charm_spec,
+            juju_version=juju_version_,
+        )
+        errors.extend(results.errors)
+        warnings.extend(results.warnings)
+
+    if errors:
+        err_fmt = "\n".join(errors)
+        raise InconsistentScenarioError(
+            f"Inconsistent scenario. The following errors were found: {err_fmt}",
+        )
+    if warnings:
+        err_fmt = "\n".join(warnings)
+        logger.warning(
+            f"This scenario is probably inconsistent. Double check, and ignore this "
+            f"warning if you're sure. "
+            f"The following warnings were found: {err_fmt}",
+        )
+
+
+def check_resource_consistency(
+    *,
+    state: "State",
+    charm_spec: "_CharmSpec[CharmType]",
+    **_kwargs: Any,  # noqa: U101
+) -> Results:
+    """Check the internal consistency of the resources from metadata and in :class:`scenario.State`."""
+    errors: List[str] = []
+    warnings: List[str] = []
+
+    resources_from_meta = set(charm_spec.meta.get("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 "
+            f"metadata.yaml. Metadata resources: {resources_from_meta}; "
+            f"State.resources: {resources_from_state}.",
+        )
+    return Results(errors, warnings)
+
+
+def check_event_consistency(
+    *,
+    event: "_Event",
+    charm_spec: "_CharmSpec[CharmType]",
+    state: "State",
+    **_kwargs: Any,  # noqa: U101
+) -> Results:
+    """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.
+    """
+    errors: List[str] = []
+    warnings: List[str] = []
+
+    # custom event: can't make assumptions about its name and its semantics
+    # todo: should we then just skip the other checks?
+    if not event._is_builtin_event(charm_spec):
+        warnings.append(
+            "this is a custom event; if its name makes it look like a builtin one "
+            "(e.g. a relation event, or a workload event), you might get some false-negative "
+            "consistency checks.",
+        )
+
+    if event._is_relation_event:
+        _check_relation_event(charm_spec, event, state, errors, warnings)
+
+    if event._is_workload_event:
+        _check_workload_event(charm_spec, event, state, errors, warnings)
+
+    if event._is_action_event:
+        _check_action_event(charm_spec, event, state, errors, warnings)
+
+    if event._is_storage_event:
+        _check_storage_event(charm_spec, event, state, errors, warnings)
+
+    return Results(errors, warnings)
+
+
+def _check_relation_event(
+    charm_spec: _CharmSpec[CharmType],  # noqa: U100
+    event: "_Event",
+    state: "State",
+    errors: List[str],
+    warnings: List[str],  # noqa: U100
+):
+    if not event.relation:
+        errors.append(
+            "cannot construct a relation event without the relation instance. "
+            "Please pass one.",
+        )
+    else:
+        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.id not in {rel.id for rel 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[CharmType],  # noqa: U100
+    event: "_Event",
+    state: "State",
+    errors: List[str],
+    warnings: List[str],
+):
+    if not event.container:
+        errors.append(
+            "cannot construct a workload event without the container instance. "
+            "Please pass one.",
+        )
+    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[CharmType],
+    event: "_Event",
+    state: "State",  # noqa: U100
+    errors: List[str],
+    warnings: List[str],
+):
+    action = event.action
+    if not action:
+        errors.append(
+            "cannot construct a workload event without the container instance. "
+            "Please pass one.",
+        )
+        return
+
+    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}.",
+        )
+    if action.name not in (charm_spec.actions or ()):
+        errors.append(
+            f"action event {event.name} refers to action {action.name} "
+            f"which is not declared in the charm metadata (actions.yaml).",
+        )
+        return
+
+    _check_action_param_types(charm_spec, action, errors, warnings)
+
+
+def _check_storage_event(
+    charm_spec: _CharmSpec[CharmType],
+    event: "_Event",
+    state: "State",
+    errors: List[str],
+    warnings: List[str],  # noqa: U100
+):
+    storage = event.storage
+    meta = charm_spec.meta
+
+    if not storage:
+        errors.append(
+            "cannot construct a storage event without the Storage instance. "
+            "Please pass one.",
+        )
+    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}.",
+        )
+    elif storage.name not in meta["storage"]:
+        errors.append(
+            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.name, storage.index) not in {
+        (s.name, s.index) for s 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[CharmType],
+    action: _Action,
+    errors: List[str],
+    warnings: List[str],
+):
+    actions = charm_spec.actions
+    if not actions:
+        return
+
+    to_python_type = {
+        "string": str,
+        "boolean": bool,
+        "integer": int,
+        "number": Number,
+        "array": Sequence,
+        "object": dict,
+    }
+    expected_param_type: Dict[str, Any] = {}
+    for par_name, par_spec in actions[action.name].get("params", {}).items():
+        value = par_spec.get("type")
+        if not value:
+            errors.append(
+                f"action parameter {par_name} has no type. "
+                f"Charmcraft will be unhappy about this. ",
+            )
+            continue
+
+        try:
+            expected_param_type[par_name] = to_python_type[value]
+        except KeyError:
+            warnings.append(
+                f"unknown data type declared for parameter {par_name}: type={value}. "
+                f"Cannot consistency-check.",
+            )
+
+    for provided_param_name, provided_param_value in action.params.items():
+        expected_type = expected_param_type.get(provided_param_name)
+        if not expected_type:
+            errors.append(
+                f"param {provided_param_name} is not a valid parameter for {action.name}: "
+                "missing from action specification",
+            )
+            continue
+        if not isinstance(provided_param_value, expected_type):
+            errors.append(
+                f"param {provided_param_name} is of type {type(provided_param_value)}: "
+                f"expecting {expected_type}",
+            )
+
+
+def check_storages_consistency(
+    *,
+    state: "State",
+    charm_spec: "_CharmSpec[CharmType]",
+    **_kwargs: Any,  # noqa: U101
+) -> Results:
+    """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: List[str] = []
+
+    if missing := {s.name for s in state_storage}.difference(
+        set(meta_storage.keys()),
+    ):
+        errors.append(
+            f"some storages passed to State were not defined in metadata.yaml: {missing}",
+        )
+
+    seen: List[Tuple[str, int]] = []
+    for s in state_storage:
+        tag = (s.name, s.index)
+        if tag in seen:
+            errors.append(
+                f"duplicate storage in State: storage {s.name} with index {s.index} "
+                f"occurs multiple times in State.storages.",
+            )
+        seen.append(tag)
+
+    return Results(errors, [])
+
+
+def _is_secret_identifier(value: Union[str, int, float, bool]) -> bool:
+    """Return true iff the value is in the form `secret:{secret id}`."""
+    # cf. https://github.com/juju/juju/blob/13eb9df3df16a84fd471af8a3c95ddbd04389b71/core/secrets/secret.go#L48
+    return bool(re.match(r"secret:[0-9a-z]{20}$", str(value)))
+
+
+def check_config_consistency(
+    *,
+    state: "State",
+    charm_spec: "_CharmSpec[CharmType]",
+    juju_version: Tuple[int, ...],
+    **_kwargs: Any,  # noqa: U101
+) -> Results:
+    """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: List[str] = []
+
+    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 or "
+                f"charmcraft.yaml.",
+            )
+            continue
+
+        converters = {
+            "string": str,
+            "int": int,
+            "float": float,
+            "boolean": bool,
+        }
+        if juju_version >= (3, 4):
+            converters["secret"] = str
+
+        validators = {
+            "secret": _is_secret_identifier,
+        }
+
+        expected_type_name = meta_config[key].get("type", None)
+        if not expected_type_name:
+            errors.append(f"config.yaml invalid; option {key!r} has no 'type'.")
+            continue
+        validator = validators.get(expected_type_name)
+
+        expected_type = converters.get(expected_type_name)
+        if not expected_type:
+            errors.append(
+                f"config invalid for option {key!r}: 'type' {expected_type_name} unknown",
+            )
+
+        elif not isinstance(value, expected_type):
+            errors.append(
+                f"config invalid; option {key!r} should be of type {expected_type} "
+                f"but is of type {type(value)}.",
+            )
+
+        elif validator and not validator(value):
+            errors.append(
+                f"config invalid: option {key!r} value {value!r} is not valid.",
+            )
+
+    return Results(errors, [])
+
+
+def check_secrets_consistency(
+    *,
+    event: "_Event",
+    state: "State",
+    juju_version: Tuple[int, ...],
+    **_kwargs: Any,  # noqa: U101
+) -> Results:
+    """Check the consistency of any :class:`scenario.Secret` in the :class:`scenario.State`."""
+    errors: List[str] = []
+    if not event._is_secret_event:
+        return Results(errors, [])
+
+    assert event.secret is not None
+    if event.secret.id not in {s.id for s in state.secrets}:
+        secret_key = event.secret.id if event.secret.id else event.secret.label
+        errors.append(
+            f"cannot emit {event.name} because secret {secret_key} is not in the state.",
+        )
+    elif juju_version < (3,):
+        errors.append(
+            f"secrets are not supported in the specified juju version {juju_version}. "
+            f"Should be at least 3.0.",
+        )
+
+    return Results(errors, [])
+
+
+def check_network_consistency(
+    *,
+    state: "State",
+    event: "_Event",  # noqa: U100
+    charm_spec: "_CharmSpec[CharmType]",
+    **_kwargs: Any,  # noqa: U101
+) -> Results:
+    """Check the consistency of any :class:`scenario.Network` in the :class:`scenario.State`."""
+    errors: List[str] = []
+
+    meta_bindings = set(charm_spec.meta.get("extra-bindings", ()))
+    # add the implicit juju-info binding so we can override its network without
+    # having to declare a relation for it in metadata
+    implicit_bindings = {"juju-info"}
+    all_relations = charm_spec.get_all_relations()
+    non_sub_relations = {
+        endpoint
+        for endpoint, metadata in all_relations
+        if metadata.get("scope") != "container"  # mark of a sub
+    }
+
+    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 the metadata: {diff}.",
+        )
+
+    endpoints = {endpoint for endpoint, _ in all_relations}
+    if collisions := endpoints.intersection(meta_bindings):
+        errors.append(
+            f"Extra bindings and integration endpoints cannot share the same name: {collisions}.",
+        )
+
+    return Results(errors, [])
+
+
+def check_relation_consistency(
+    *,
+    state: "State",
+    event: "_Event",  # noqa: U100
+    charm_spec: "_CharmSpec[CharmType]",
+    **_kwargs: Any,  # noqa: U101
+) -> Results:
+    """Check the consistency of any relations in the :class:`scenario.State`."""
+    errors: List[str] = []
+
+    peer_relations_meta = charm_spec.meta.get("peers", {}).items()
+    all_relations_meta = charm_spec.get_all_relations()
+
+    def _get_relations(r: str):
+        try:
+            return state.get_relations(r)
+        except ValueError:
+            return ()
+
+    # check relation types
+    for endpoint, _ in peer_relations_meta:
+        for relation in _get_relations(endpoint):
+            if not isinstance(relation, PeerRelation):
+                errors.append(
+                    f"endpoint {endpoint} is a peer relation; "
+                    f"expecting relation to be of type PeerRelation, got {type(relation)}",
+                )
+
+    known_endpoints = [a[0] for a in all_relations_meta]
+    for relation in state.relations:
+        if (ep := relation.endpoint) not in known_endpoints:
+            errors.append(f"relation endpoint {ep} is not declared in metadata.")
+
+    seen_ids: Set[int] = set()
+    for endpoint, relation_meta in all_relations_meta:
+        expected_sub = relation_meta.get("scope", "") == "container"
+        relations = _get_relations(endpoint)
+        for relation in relations:
+            if relation.id in seen_ids:
+                errors.append(
+                    f"duplicate relation ID: {relation.id} is claimed "
+                    f"by multiple Relation instances",
+                )
+
+            seen_ids.add(relation.id)
+            is_sub = isinstance(relation, SubordinateRelation)
+            if is_sub and not expected_sub:
+                errors.append(
+                    f"endpoint {endpoint} is not a subordinate relation; "
+                    f"expecting relation to be of type Relation, "
+                    f"got {type(relation)}",
+                )
+            if expected_sub and not is_sub:
+                errors.append(
+                    f"endpoint {endpoint} is not a subordinate relation; "
+                    f"expecting relation to be of type SubordinateRelation, "
+                    f"got {type(relation)}",
+                )
+
+    # check for duplicate endpoint names
+    seen_endpoints: Set[str] = set()
+    for endpoint, _ in all_relations_meta:
+        if endpoint in seen_endpoints:
+            errors.append("duplicate endpoint name in metadata.")
+            break
+        seen_endpoints.add(endpoint)
+
+    return Results(errors, [])
+
+
+def check_containers_consistency(
+    *,
+    state: "State",
+    event: "_Event",
+    charm_spec: "_CharmSpec[CharmType]",
+    **_kwargs: Any,  # noqa: U101
+) -> Results:
+    """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(_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: List[str] = []
+
+    # it's fine if you have containers in meta that are not in state.containers (yet), but it's
+    # not fine if:
+    # - you're processing a Pebble event and that container is not in state.containers or
+    #   meta.containers
+    if event._is_workload_event:
+        evt_container_name = event.name.split("_pebble_")[0]
+        if evt_container_name not in meta_containers:
+            errors.append(
+                f"the event being processed concerns container {evt_container_name!r}, but a "
+                f"container with that name is not declared in the charm metadata",
+            )
+        if evt_container_name not in state_containers:
+            errors.append(
+                f"the event being processed concerns container {evt_container_name!r}, but a "
+                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))):
+        errors.append(
+            f"some containers declared in the state are not specified in metadata. "
+            f"That's not possible. "
+            f"Missing from metadata: {diff}.",
+        )
+
+    return Results(errors, [])
+
+
+def check_cloudspec_consistency(
+    *,
+    state: "State",
+    event: "_Event",
+    charm_spec: "_CharmSpec[CharmType]",
+    **_kwargs: Any,  # noqa: U101
+) -> Results:
+    """Check that Kubernetes models don't have :attr:`scenario.State.cloud_spec` set."""
+
+    errors: List[str] = []
+    warnings: List[str] = []
+
+    if state.model.type == "kubernetes" and state.model.cloud_spec:
+        errors.append(
+            "CloudSpec is only available for machine charms, not Kubernetes charms. "
+            "Simulate a machine substrate with: `State(..., model=Model(type='lxd'))`.",
+        )
+
+    return Results(errors, warnings)
+
+
+def check_storedstate_consistency(
+    *,
+    state: "State",
+    **_kwargs: Any,  # noqa: U101
+) -> Results:
+    """Check the internal consistency of any :class:`scenario.StoredState` in the :class:`scenario.State`."""
+    errors: List[str] = []
+
+    # Attribute names must be unique on each object.
+    names: defaultdict[Optional[str], List[str]] = 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/testing/src/context.py b/testing/src/context.py
new file mode 100644
index 000000000..5ecf4a57f
--- /dev/null
+++ b/testing/src/context.py
@@ -0,0 +1,696 @@
+#!/usr/bin/env python3
+# Copyright 2023 Canonical Ltd.
+# See LICENSE file for licensing details.
+
+import functools
+import tempfile
+from contextlib import contextmanager
+from pathlib import Path
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    Callable,
+    Dict,
+    List,
+    Mapping,
+    Optional,
+    Type,
+    Union,
+    cast,
+)
+
+import ops
+from ops._private.harness import ActionFailed
+
+from scenario.errors import (
+    AlreadyEmittedError,
+    ContextSetupError,
+    MetadataNotFoundError,
+)
+from scenario.logger import logger as scenario_logger
+from scenario.runtime import Runtime
+from scenario.state import (
+    CharmType,
+    CheckInfo,
+    Container,
+    Notice,
+    Secret,
+    Storage,
+    _Action,
+    _CharmSpec,
+    _Event,
+)
+
+# TODO: Remove the line below after the ops compatibility code is removed.
+# pyright: reportUnnecessaryTypeIgnoreComment=false
+
+if TYPE_CHECKING:  # pragma: no cover
+    try:
+        from ops._private.harness import ExecArgs  # type: ignore
+    except ImportError:
+        from ops.testing import ExecArgs  # type: ignore
+
+    from scenario.ops_main_mock import Ops
+    from scenario.state import (
+        AnyJson,
+        CharmType,
+        JujuLogLine,
+        RelationBase,
+        State,
+        _EntityStatus,
+    )
+
+logger = scenario_logger.getChild("runtime")
+
+_DEFAULT_JUJU_VERSION = "3.5"
+
+
+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::
+
+        ctx = Context(MyCharm)
+        with ctx(ctx.on.start(), State()) as manager:
+            manager.charm.setup()
+            manager.run()
+    """
+
+    def __init__(
+        self,
+        ctx: "Context",
+        arg: _Event,
+        state_in: "State",
+    ):
+        self._ctx = ctx
+        self._arg = arg
+        self._state_in = state_in
+
+        self._emitted: bool = False
+
+        self.ops: Optional["Ops"] = None
+
+    @property
+    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 context manager before accessing this",
+            )
+        return cast(ops.CharmBase, self.ops.charm)
+
+    @property
+    def _runner(self):
+        return self._ctx._run  # noqa
+
+    def __enter__(self):
+        self._wrapped_ctx = wrapped_ctx = self._runner(self._arg, self._state_in)
+        ops = wrapped_ctx.__enter__()
+        self.ops = ops
+        return self
+
+    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 run once.")
+        self._emitted = True
+
+        # wrap up Runtime.exec() so that we can gather the output state
+        self._wrapped_ctx.__exit__(None, None, None)
+
+        assert self._ctx._output_state is not None
+        return self._ctx._output_state
+
+    def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any):  # noqa: U100
+        if not self._emitted:
+            logger.debug(
+                "user didn't emit the event within the context manager scope. Doing so implicitly upon exit...",
+            )
+            self.run()
+
+
+def _copy_doc(original_func: Callable[..., Any]):
+    """Copy the docstring from `original_func` to the wrapped function."""
+
+    def decorator(wrapper_func: Callable[..., Any]):
+        @functools.wraps(wrapper_func)
+        def wrapped(*args: Any, **kwargs: Any):
+            return wrapper_func(*args, **kwargs)
+
+        wrapped.__doc__ = original_func.__doc__
+        return wrapped
+
+    return decorator
+
+
+class CharmEvents:
+    """Events generated by Juju or ops pertaining to the application lifecycle.
+
+    The events listed as attributes of this class should be accessed via the
+    :attr:`Context.on` attribute. For example::
+
+        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.UpgradeCharmEvent)
+    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,
+        )
+
+    @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: Dict[str, Any] = {}
+        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.
+
+    The main entry point to running a test. It contains:
+
+    - 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 :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.
+
+    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`
+    - :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 test will look like::
+
+        from charm import MyCharm, MyCustomEvent  # noqa
+
+        def test_foo():
+            # Arrange: set the context up
+            ctx = Context(MyCharm)
+            # Act: prepare the state and emit an event
+            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 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.
+
+    This will be ``None`` if the charm never calls :meth:`ops.ActionEvent.set_results`
+    """
+    on: CharmEvents
+    """The events that this charm can respond to.
+
+    Use this when calling :meth:`run` to specify the event to emit.
+    """
+
+    def __init__(
+        self,
+        charm_type: Type["CharmType"],
+        meta: Optional[Dict[str, Any]] = None,
+        *,
+        actions: Optional[Dict[str, Any]] = None,
+        config: Optional[Dict[str, Any]] = None,
+        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,
+        unit_id: Optional[int] = 0,
+        app_trusted: bool = False,
+    ):
+        """Represents a simulated charm's execution context.
+
+        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).
+            If none is provided, we will search for a ``actions.yaml`` file in the charm root.
+        :arg 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.
+        :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 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``).
+        :arg charm_root: virtual charm filesystem root the charm will be executed with.
+        """
+
+        if not any((meta, actions, config)):
+            logger.debug("Autoloading charmspec...")
+            try:
+                spec: _CharmSpec[CharmType] = _CharmSpec.autoload(charm_type)
+            except MetadataNotFoundError as e:
+                raise ContextSetupError(
+                    f"Cannot setup scenario with `charm_type`={charm_type}. "
+                    f"Did you forget to pass `meta` to this Context?",
+                ) from e
+
+        else:
+            if not meta:
+                meta = {"name": str(charm_type.__name__)}
+            spec = _CharmSpec(
+                charm_type=charm_type,
+                meta=meta,
+                actions=actions,
+                config=config,
+            )
+
+        self.charm_spec = spec
+        self.charm_root = charm_root
+        self.juju_version = juju_version
+        if juju_version.split(".")[0] == "2":
+            logger.warning(
+                "Juju 2.x is closed and unsupported. You may encounter inconsistencies.",
+            )
+
+        self._app_name = app_name
+        self._unit_id = unit_id
+        self.app_trusted = app_trusted
+        self._tmp = tempfile.TemporaryDirectory()
+
+        # config for what events to be captured in emitted_events.
+        self.capture_deferred_events = capture_deferred_events
+        self.capture_framework_events = capture_framework_events
+
+        # streaming side effects from running an event
+        self.juju_log: List["JujuLogLine"] = []
+        self.app_status_history: List["_EntityStatus"] = []
+        self.unit_status_history: List["_EntityStatus"] = []
+        self.exec_history: Dict[str, List["ExecArgs"]] = {}
+        self.workload_version_history: List[str] = []
+        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
+
+        # 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.on = CharmEvents()
+
+    def _set_output_state(self, output_state: "State"):
+        """Hook for Runtime to set the output state."""
+        self._output_state = 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
+
+    def _get_storage_root(self, name: str, index: int) -> Path:
+        """Get the path to a tempdir where this storage's simulated root will live."""
+        storage_root = Path(self._tmp.name) / "storages" / f"{name}-{index}"
+        # in the case of _get_container_root, _MockPebbleClient will ensure the dir exists.
+        storage_root.mkdir(parents=True, exist_ok=True)
+        return storage_root
+
+    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(state.app_status)
+        else:
+            self.unit_status_history.append(state.unit_status)
+
+    def __call__(self, event: "_Event", state: "State"):
+        """Context manager to introspect live charm object before and after the event is emitted.
+
+        Usage::
+
+            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 event that the charm will respond to.
+            state: the :class:`State` instance to use when handling the event.
+        """
+        return Manager(self, event, state)
+
+    def run_action(self, action: str, state: "State"):
+        """Use `run()` instead.
+
+        :private:
+        """
+        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: "_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 :class:`State`, then emit the event on the charm.
+
+        :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.
+        """
+        # 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",
+            ):  # type: ignore
+                suggested = f"{event}()"
+            elif event in ("secret_changed", "secret_rotate"):  # type: ignore
+                suggested = f"{event}(my_secret)"
+            elif event in ("secret_expired", "secret_remove"):  # type: ignore
+                suggested = f"{event}(my_secret, revision=1)"
+            elif event in (
+                "relation_created",
+                "relation_joined",
+                "relation_changed",
+                "relation_departed",
+                "relation_broken",
+            ):  # type: ignore
+                suggested = f"{event}(my_relation)"
+            elif event in ("storage_attached", "storage_detaching"):  # type: ignore
+                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()
+        # 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,
+                    state=self._output_state,  # type: ignore
+                )
+        return self._output_state
+
+    @contextmanager
+    def _run(self, event: "_Event", state: "State"):
+        runtime = Runtime(
+            charm_spec=self.charm_spec,
+            juju_version=self.juju_version,
+            charm_root=self.charm_root,
+            app_name=self._app_name,
+            unit_id=self._unit_id,
+        )
+        with runtime.exec(
+            state=state,
+            event=event,
+            context=self,  # type: ignore
+        ) as ops:
+            yield ops
diff --git a/testing/src/errors.py b/testing/src/errors.py
new file mode 100644
index 000000000..56a01d126
--- /dev/null
+++ b/testing/src/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/testing/src/logger.py b/testing/src/logger.py
new file mode 100644
index 000000000..daa272b52
--- /dev/null
+++ b/testing/src/logger.py
@@ -0,0 +1,9 @@
+#!/usr/bin/env python3
+# Copyright 2023 Canonical Ltd.
+# See LICENSE file for licensing details.
+
+import logging
+import os
+
+logger = logging.getLogger("ops-scenario")
+logger.setLevel(os.getenv("OPS_SCENARIO_LOGGING", "WARNING"))
diff --git a/testing/src/mocking.py b/testing/src/mocking.py
new file mode 100644
index 000000000..7eb041cd5
--- /dev/null
+++ b/testing/src/mocking.py
@@ -0,0 +1,915 @@
+#!/usr/bin/env python3
+# Copyright 2023 Canonical Ltd.
+# See LICENSE file for licensing details.
+
+import datetime
+import io
+import shutil
+from pathlib import Path
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    Dict,
+    List,
+    Literal,
+    Mapping,
+    NoReturn,
+    Optional,
+    Set,
+    TextIO,
+    Tuple,
+    Union,
+    cast,
+)
+
+from ops import JujuVersion, pebble
+
+# TODO: Remove the line below after the ops compatibility code is removed.
+# pyright: reportUnnecessaryTypeIgnoreComment=false
+try:
+    from ops._private.harness import ExecArgs, _TestingPebbleClient  # type: ignore
+except ImportError:
+    from ops.testing import ExecArgs, _TestingPebbleClient  # type: ignore
+
+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,
+    SecretNotFoundError,
+    SecretRotate,
+    _format_action_result_dict,
+    _ModelBackend,
+)
+from ops.pebble import Client, ExecError
+
+from scenario.errors import ActionMissingFromContextError
+from scenario.logger import logger as scenario_logger
+from scenario.state import (
+    CharmType,
+    JujuLogLine,
+    Mount,
+    Network,
+    PeerRelation,
+    Relation,
+    RelationBase,
+    Storage,
+    SubordinateRelation,
+    _EntityStatus,
+    _port_cls_by_protocol,
+    _RawPortProtocolLiteral,
+    _RawStatusLiteral,
+)
+
+if TYPE_CHECKING:  # pragma: no cover
+    from scenario.context import Context
+    from scenario.state import Container as ContainerSpec
+    from scenario.state import Exec, Secret, State, _CharmSpec, _Event
+
+logger = scenario_logger.getChild("mocking")
+
+
+class _MockExecProcess:
+    def __init__(
+        self,
+        change_id: int,
+        args: ExecArgs,
+        return_code: int,
+        stdin: Optional[Union[TextIO, io.BytesIO]],
+        stdout: Optional[Union[TextIO, io.BytesIO]],
+        stderr: Optional[Union[TextIO, io.BytesIO]],
+    ):
+        self._change_id = change_id
+        self._args = args
+        self._return_code = return_code
+        self._waited = False
+        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
+        if self._return_code != 0:
+            raise ExecError(list(self._args.command), self._return_code, None, None)
+
+    def wait_output(self):
+        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,  # type: ignore
+                stderr,  # type: ignore
+            )
+        return stdout, stderr
+
+    def send_signal(self, sig: Union[int, str]) -> NoReturn:  # noqa: U100
+        raise NotImplementedError()
+
+
+_NOT_GIVEN = object()  # non-None default value sentinel
+
+
+# pyright: reportIncompatibleMethodOverride=false
+class _MockModelBackend(_ModelBackend):  # type: ignore
+    def __init__(
+        self,
+        state: "State",
+        event: "_Event",
+        charm_spec: "_CharmSpec[CharmType]",
+        context: "Context",
+    ):
+        super().__init__()
+        self._state = state
+        self._event = event
+        self._context = context
+        self._charm_spec = charm_spec
+
+    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,
+        protocol: "_RawPortProtocolLiteral",  # pyright: ignore[reportIncompatibleMethodOverride]
+        port: Optional[int] = None,
+    ):
+        # fixme: the charm will get hit with a StateValidationError
+        #  here, not the expected ModelError...
+        port_ = _port_cls_by_protocol[protocol](port=port)  # type: ignore
+        ports = set(self._state.opened_ports)
+        if port_ not in ports:
+            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_cls_by_protocol[protocol](port=port)  # type: ignore
+        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("/")[
+            3
+        ]  # /charm/containers/<container_name>/pebble.socket
+        container_root = self._context._get_container_root(container_name)
+        try:
+            mounts = self._state.get_container(container_name).mounts
+        except KeyError:
+            # container not defined in state.
+            mounts = {}
+
+        return cast(
+            Client,
+            _MockPebbleClient(
+                socket_path=socket_path,
+                container_root=container_root,
+                mounts=mounts,
+                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: int) -> "RelationBase":
+        try:
+            return self._state.get_relation(rel_id)
+        except KeyError:
+            raise RelationNotFoundError() from None
+
+    def _get_secret(self, id: Optional[str] = None, label: Optional[str] = None):
+        # FIXME: what error would a charm get IRL?
+        # ops 2.0 supports secrets, but juju only supports it from 3.0.2
+        if self._context.juju_version < "3.0.2":
+            raise RuntimeError(
+                "secrets are only available in juju >= 3.0.2."
+                "Set ``Context.juju_version`` to 3.0.2+ to use them.",
+            )
+
+        canonicalize_id = Secret_Ops._canonicalize_id
+
+        if id:
+            # in scenario, you can create Secret(id="foo"),
+            # but ops.Secret will prepend a "secret:" prefix to that ID.
+            # we allow getting secret by either version.
+            try:
+                secrets = [
+                    s
+                    for s in self._state.secrets
+                    if canonicalize_id(s.id, model_uuid=self._state.model.uuid)  # type: ignore
+                    == canonicalize_id(id, model_uuid=self._state.model.uuid)  # type: ignore
+                ]
+            except TypeError:
+                # ops 2.16
+                secrets = [
+                    s
+                    for s in self._state.secrets
+                    if canonicalize_id(s.id) == canonicalize_id(id)  # type: ignore
+                ]
+            if not secrets:
+                raise SecretNotFoundError(id)
+            return secrets[0]
+
+        elif label:
+            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.")
+
+    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")
+
+        if not is_app:
+            return
+
+        version = JujuVersion(self._context.juju_version)
+        if not version.has_app_data():
+            raise RuntimeError(
+                f"setting application data is not supported on Juju version {version}",
+            )
+
+    def relation_get(self, relation_id: int, member_name: str, is_app: bool):
+        self._check_app_data_access(is_app)
+        relation = self._get_relation_by_id(relation_id)
+        if is_app and member_name == self.app_name:
+            return relation.local_app_data
+        elif is_app:
+            if isinstance(relation, PeerRelation):
+                return relation.local_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
+
+        unit_id = int(member_name.split("/")[-1])
+        return relation._get_databag_for_remote(unit_id)  # noqa
+
+    def is_leader(self):
+        return self._state.leader
+
+    def status_get(self, *, is_app: bool = False):
+        status = self._state.app_status if is_app else self._state.unit_status
+        return {"status": status.name, "message": status.message}
+
+    def relation_ids(self, relation_name: str):
+        return [
+            rel.id for rel in self._state.relations if rel.endpoint == relation_name
+        ]
+
+    def relation_list(self, relation_id: int) -> Tuple[str, ...]:
+        relation = self._get_relation_by_id(relation_id)
+
+        if isinstance(relation, PeerRelation):
+            return tuple(
+                f"{self.app_name}/{unit_id}" for unit_id in relation.peers_data
+            )
+        remote_name = self.relation_remote_app_name(relation_id)
+        return tuple(
+            f"{remote_name}/{unit_id}" for unit_id in relation._remote_unit_ids
+        )
+
+    def config_get(self):
+        state_config = self._state.config.copy()  # dedup or we'll mutate the state!
+
+        # add defaults
+        charm_config = self._charm_spec.config
+        if not charm_config:
+            return state_config
+
+        for key, value in charm_config["options"].items():
+            # if it has a default, and it's not overwritten from State, use it:
+            if key not in state_config:
+                default_value = value.get("default", _NOT_GIVEN)
+                if default_value is not _NOT_GIVEN:  # accept False as default value
+                    state_config[key] = default_value
+
+        return state_config  # full config
+
+    def network_get(self, binding_name: str, relation_id: Optional[int] = None):
+        # validation:
+        extra_bindings = self._charm_spec.meta.get("extra-bindings", ())
+        all_endpoints = self._charm_spec.get_all_relations()
+        non_sub_relations = {
+            name for name, meta in all_endpoints if meta.get("scope") != "container"
+        }
+
+        # - is binding_name a valid binding name?
+        if binding_name in extra_bindings:
+            logger.warning("extra-bindings is a deprecated feature")  # fyi
+
+            # - verify that if the binding is an extra binding, we're not ignoring a relation_id
+            if relation_id is not None:
+                # this should not happen
+                logger.error(
+                    "cannot pass relation_id to network_get if the binding name is "
+                    "that of an extra-binding. Extra-bindings are not mapped to relation IDs.",
+                )
+        elif binding_name == "juju-info":
+            # implicit relation that always exists
+            pass
+        # - verify that the binding is a relation endpoint name, but not a subordinate one
+        elif binding_name not in non_sub_relations:
+            logger.error(
+                f"cannot get network binding for {binding_name}: is not a valid relation "
+                f"endpoint name nor an extra-binding.",
+            )
+            raise RelationNotFoundError()
+
+        # We look in State.networks for an override. If not given, we return a default network.
+        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):
+        if workload_version := self._state.workload_version:
+            # do not record if empty = unset
+            self._context.workload_version_history.append(workload_version)
+
+        self._state._update_workload_version(version)
+
+    def status_set(
+        self,
+        status: _RawStatusLiteral,
+        message: str = "",
+        *,
+        is_app: bool = False,
+    ):
+        self._context._record_status(self._state, 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))
+
+    def relation_set(self, relation_id: int, key: str, value: str, is_app: bool):
+        self._check_app_data_access(is_app)
+        relation = self._get_relation_by_id(relation_id)
+        if is_app:
+            if not self._state.leader:
+                # will in practice not be reached because RelationData will check leadership
+                # and raise RelationDataAccessError upstream on this path
+                raise RuntimeError("needs leadership to set app data")
+            tgt = relation.local_app_data
+        else:
+            tgt = relation.local_unit_data
+        tgt[key] = value
+        return None
+
+    def secret_add(
+        self,
+        content: Dict[str, str],
+        *,
+        label: Optional[str] = None,
+        description: Optional[str] = None,
+        expire: Optional[datetime.datetime] = None,
+        rotate: Optional[SecretRotate] = None,
+        owner: Optional[Literal["unit", "app"]] = None,
+    ) -> str:
+        from scenario.state import Secret
+
+        secret = Secret(
+            content,
+            label=label,
+            description=description,
+            expire=expire,
+            rotate=rotate,
+            owner=owner,
+        )
+        secrets = set(self._state.secrets)
+        secrets.add(secret)
+        self._state._update_secrets(frozenset(secrets))
+        return secret.id
+
+    def _check_can_manage_secret(
+        self,
+        secret: "Secret",
+    ):
+        if secret.owner is None:
+            raise SecretNotFoundError(
+                "this secret is not owned by this unit/app or granted to it. "
+                "Did you forget passing it to State.secrets?",
+            )
+        if secret.owner == "app" and not self.is_leader():
+            understandable_error = SecretNotFoundError(
+                f"App-owned secret {secret.id!r} can only be "
+                f"managed by the leader.",
+            )
+            # charm-facing side: respect ops error
+            raise ModelError("ERROR permission denied") from understandable_error
+
+    def secret_get(
+        self,
+        *,
+        id: Optional[str] = None,
+        label: Optional[str] = None,
+        refresh: bool = False,
+        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)  # type: ignore
+        juju_version = self._context.juju_version
+        if not (juju_version == "3.1.7" or juju_version >= "3.3.1"):
+            # 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
+
+        if peek or refresh:
+            if refresh:
+                secret._track_latest_revision()
+            assert secret.latest_content is not None
+            return secret.latest_content
+
+        return secret.tracked_content
+
+    def secret_info_get(
+        self,
+        *,
+        id: Optional[str] = None,
+        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)  # type: ignore
+
+        # only "manage"=write access level can read secret info
+        self._check_can_manage_secret(secret)
+
+        return SecretInfo(
+            id=secret.id,
+            label=secret.label,
+            revision=secret._latest_revision,
+            expires=secret.expire,
+            rotation=secret.rotate,
+            rotates=None,  # not implemented yet.
+        )
+
+    def secret_set(
+        self,
+        id: str,
+        *,
+        content: Optional[Dict[str, str]] = None,
+        label: Optional[str] = None,
+        description: Optional[str] = None,
+        expire: Optional[datetime.datetime] = None,
+        rotate: Optional[SecretRotate] = None,
+    ):
+        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,
+            description=description,
+            expire=expire,
+            rotate=rotate,
+        )
+
+    def secret_grant(self, id: str, relation_id: int, *, unit: Optional[str] = None):
+        secret = self._get_secret(id)
+        self._check_can_manage_secret(secret)
+
+        grantee = unit or self.relation_remote_app_name(
+            relation_id,
+            _raise_on_error=True,
+        )
+
+        if not secret.remote_grants.get(relation_id):
+            secret.remote_grants[relation_id] = set()
+
+        secret.remote_grants[relation_id].add(cast(str, grantee))
+
+    def secret_revoke(self, id: str, relation_id: int, *, unit: Optional[str] = None):
+        secret = self._get_secret(id)
+        self._check_can_manage_secret(secret)
+
+        grantee = unit or self.relation_remote_app_name(
+            relation_id,
+            _raise_on_error=True,
+        )
+        secret.remote_grants[relation_id].remove(cast(str, grantee))
+        if not secret.remote_grants[relation_id]:
+            del secret.remote_grants[relation_id]
+
+    def secret_remove(self, id: str, *, revision: Optional[int] = None):
+        secret = self._get_secret(id)
+        self._check_can_manage_secret(secret)
+
+        # 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,
+        relation_id: int,
+        _raise_on_error: bool = False,
+    ) -> Optional[str]:
+        # ops catches RelationNotFoundErrors and returns None:
+        try:
+            relation = self._get_relation_by_id(relation_id)
+        except RelationNotFoundError:
+            if _raise_on_error:
+                raise
+            return None
+
+        if isinstance(relation, PeerRelation):
+            return self.app_name
+        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:
+            raise ActionMissingFromContextError(
+                "not in the context of an action event: cannot action-set",
+            )
+        # let ops validate the results dict
+        _format_action_result_dict(results)
+        # but then we will store it in its unformatted,
+        # original form for testing ease
+        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 = 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)
+
+    def action_get(self):
+        action = self._event.action
+        if not action:
+            raise ActionMissingFromContextError(
+                "not in the context of an action event: cannot action-get",
+            )
+        return action.params
+
+    def storage_add(self, name: str, count: int = 1):
+        if not isinstance(count, int) or isinstance(count, bool):
+            raise TypeError(
+                f"storage count must be integer, got: {count} ({type(count)})",
+            )
+
+        if "/" in name:
+            # this error is raised by Harness but not by ops at runtime
+            raise ModelError('storage name cannot contain "/"')
+
+        self._context.requested_storages[name] = count
+
+    def storage_list(self, name: str) -> List[int]:
+        return [
+            storage.index for storage in self._state.storages if storage.name == name
+        ]
+
+    def _storage_event_details(self) -> Tuple[int, str]:
+        storage = self._event.storage
+        if not storage:
+            # only occurs if this method is called when outside the scope of a storage event
+            raise RuntimeError('unable to find storage key in ""')
+        fs_path = storage.get_filesystem(self._context)
+        return storage.index, str(fs_path)
+
+    def storage_get(self, storage_name_id: str, attribute: str) -> str:
+        if not len(attribute) > 0:  # assume it's an empty string.
+            raise RuntimeError(
+                'calling storage_get with `attribute=""` will return a dict '
+                "and not a string. This usage is not supported.",
+            )
+
+        if attribute != "location":
+            # this should not happen: in ops it's hardcoded to be "location"
+            raise NotImplementedError(
+                f"storage-get not implemented for attribute={attribute}",
+            )
+
+        name, index = storage_name_id.split("/")
+        index = int(index)
+        storages: List[Storage] = [
+            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.
+        if not storages:
+            raise RuntimeError(f"Storage with name={name} and index={index} not found.")
+        if len(storages) > 1:
+            raise RuntimeError(
+                f"Multiple Storage instances with name={name} and index={index} found. "
+                f"Inconsistent state.",
+            )
+
+        storage = storages[0]
+        fs_path = storage.get_filesystem(self._context)
+        return str(fs_path)
+
+    def planned_units(self) -> int:
+        return self._state.planned_units
+
+    # legacy ops API that we don't intend to mock:
+    def pod_spec_set(
+        self,
+        spec: Mapping[str, Any],  # noqa: U100
+        k8s_resources: Optional[Mapping[str, Any]] = None,  # noqa: U100
+    ) -> NoReturn:
+        raise NotImplementedError(
+            "pod-spec-set is not implemented in Scenario (and probably never will be: "
+            "it's deprecated API)",
+        )
+
+    def add_metrics(
+        self,
+        metrics: Mapping[str, Union[int, float]],  # noqa: U100
+        labels: Optional[Mapping[str, str]] = None,  # noqa: U100
+    ) -> NoReturn:
+        raise NotImplementedError(
+            "add-metrics is not implemented in Scenario (and probably never will be: "
+            "it's deprecated API)",
+        )
+
+    # TODO: It seems like this method has no tests.
+    def resource_get(self, resource_name: str) -> str:
+        # 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_Ops:
+        if not self._context.app_trusted:
+            raise ModelError(
+                "ERROR charm is not trusted, initialise Context with `app_trusted=True`",
+            )
+        if not self._state.model.cloud_spec:
+            raise ModelError(
+                "ERROR cloud spec is empty, initialise it with "
+                "`State(model=Model(..., cloud_spec=ops.CloudSpec(...)))`",
+            )
+        return self._state.model.cloud_spec._to_ops()
+
+
+class _MockPebbleClient(_TestingPebbleClient):
+    def __init__(
+        self,
+        socket_path: str,
+        container_root: Path,
+        mounts: Dict[str, Mount],
+        *,
+        state: "State",
+        event: "_Event",
+        charm_spec: "_CharmSpec[CharmType]",
+        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():
+            # Path.rmdir will fail if root is nonempty
+            shutil.rmtree(container_root)
+
+        # initialize simulated filesystem
+        container_root.mkdir(parents=True)
+        for _, mount in mounts.items():
+            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.source)
+
+        self._root = container_root
+
+        self._notices: Dict[Tuple[str, str], pebble.Notice] = {}
+        self._last_notice_id = 0
+        self._changes: Dict[str, pebble.Change] = {}
+
+        # 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"):
+                    notice_type = cast(pebble.NoticeType, notice.type).value
+                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
+
+    @property
+    def _container(self) -> "ContainerSpec":
+        container_name = self.socket_path.split("/")[-2]
+        try:
+            return next(
+                filter(lambda x: x.name == container_name, self._state.containers),
+            )
+        except StopIteration:
+            raise RuntimeError(
+                f"container with name={container_name!r} not found. "
+                f"Did you forget a Container, or is the socket path "
+                f"{self.socket_path!r} wrong?",
+            )
+
+    @property
+    def _layers(self) -> Dict[str, pebble.Layer]:
+        return self._container.layers
+
+    @property
+    def _service_status(self) -> Dict[str, pebble.ServiceStatus]:
+        return self._container.service_statuses
+
+    # Based on a method of the same name from Harness.
+    def _find_exec_handler(self, command: List[str]) -> 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,
+        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: Any,
+    ):
+        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)}, ...)}})'",
+            )
+
+        if stdin is None:
+            proc_stdin = self._transform_exec_handler_output("", encoding)  # type: ignore
+        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)  # type: ignore
+        else:
+            proc_stdout = None
+            stdout.write(handler.stdout)
+        if stderr is None:
+            proc_stderr = self._transform_exec_handler_output(handler.stderr, encoding)  # type: ignore
+        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,  # type: ignore
+                stdout=proc_stdout,  # type: ignore
+                stderr=proc_stderr,  # type: ignore
+            ),
+        )
+
+    def _check_connection(self):
+        if not self._container.can_connect:
+            msg = (
+                f"Cannot connect to Pebble; did you forget to set "
+                f"can_connect=True for container {self._container.name}?"
+            )
+            raise pebble.ConnectionError(msg)
diff --git a/testing/src/ops_main_mock.py b/testing/src/ops_main_mock.py
new file mode 100644
index 000000000..324b2f9e5
--- /dev/null
+++ b/testing/src/ops_main_mock.py
@@ -0,0 +1,288 @@
+#!/usr/bin/env python3
+# Copyright 2023 Canonical Ltd.
+# See LICENSE file for licensing details.
+
+import inspect
+import os
+import pathlib
+import sys
+from typing import TYPE_CHECKING, Any, Optional, Sequence, Type, cast
+
+import ops.charm
+import ops.framework
+import ops.model
+import ops.storage
+from ops import CharmBase
+
+# use logger from ops._main so that juju_log will be triggered
+try:
+    from ops._main import CHARM_STATE_FILE, _Dispatcher, _get_event_args  # type: ignore
+    from ops._main import logger as ops_logger  # type: ignore
+except ImportError:
+    # Ops 2.16
+    from ops.main import CHARM_STATE_FILE, _Dispatcher, _get_event_args  # type: ignore
+    from ops.main import logger as ops_logger  # type: ignore
+from ops.charm import CharmMeta
+from ops.log import setup_root_logging
+
+from scenario.errors import BadOwnerPath, NoObserverError
+
+if TYPE_CHECKING:  # pragma: no cover
+    from scenario.context import Context
+    from scenario.state import CharmType, State, _CharmSpec, _Event
+
+# pyright: reportPrivateUsage=false
+# TODO: Remove the line below after the ops compatibility code is removed.
+# pyright: reportUnnecessaryTypeIgnoreComment=false
+
+
+# TODO: Use ops.jujucontext's _JujuContext.charm_dir.
+def _get_charm_dir():
+    charm_dir = os.environ.get("JUJU_CHARM_DIR")
+    if charm_dir is None:
+        # Assume $JUJU_CHARM_DIR/lib/op/main.py structure.
+        charm_dir = pathlib.Path(f"{__file__}/../../..").resolve()
+    else:
+        charm_dir = pathlib.Path(charm_dir).resolve()
+    return charm_dir
+
+
+def _get_owner(root: Any, path: Sequence[str]) -> ops.ObjectEvents:
+    """Walk path on root to an ObjectEvents instance."""
+    obj = root
+    for step in path:
+        try:
+            obj = getattr(obj, step)
+        except AttributeError:
+            raise BadOwnerPath(
+                f"event_owner_path {path!r} invalid: {step!r} leads to nowhere.",
+            )
+    if not isinstance(obj, ops.ObjectEvents):
+        raise BadOwnerPath(
+            f"event_owner_path {path!r} invalid: does not lead to "
+            f"an ObjectEvents instance.",
+        )
+    return obj
+
+
+def _emit_charm_event(
+    charm: "CharmBase",
+    event_name: str,
+    event: Optional["_Event"] = None,
+):
+    """Emits a charm event based on a Juju event name.
+
+    Args:
+        charm: A charm instance to emit an event from.
+        event_name: A Juju event name to emit on a charm.
+        event_owner_path: Event source lookup path.
+    """
+    owner = _get_owner(charm, event.owner_path) if event else charm.on
+
+    try:
+        event_to_emit = getattr(owner, event_name)
+    except AttributeError:
+        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).",
+        )
+
+    try:
+        args, kwargs = _get_event_args(charm, event_to_emit)  # type: ignore
+    except TypeError:
+        # ops 2.16+
+        import ops.jujucontext  # type: ignore
+
+        args, kwargs = _get_event_args(
+            charm,
+            event_to_emit,
+            ops.jujucontext._JujuContext.from_dict(os.environ),  # type: ignore
+        )
+    ops_logger.debug("Emitting Juju event %s.", event_name)
+    event_to_emit.emit(*args, **kwargs)
+
+
+def setup_framework(
+    charm_dir: pathlib.Path,
+    state: "State",
+    event: "_Event",
+    context: "Context",
+    charm_spec: "_CharmSpec[CharmType]",
+):
+    from scenario.mocking import _MockModelBackend
+
+    model_backend = _MockModelBackend(
+        state=state,
+        event=event,
+        context=context,
+        charm_spec=charm_spec,
+    )
+    debug = "JUJU_DEBUG" in os.environ
+    setup_root_logging(model_backend, debug=debug)
+    # ops sets sys.excepthook to go to Juju's debug-log, but that's not useful
+    # in a testing context, so reset it.
+    sys.excepthook = sys.__excepthook__
+    ops_logger.debug(
+        "Operator Framework %s up and running.",
+        ops.__version__,
+    )  # type:ignore
+
+    metadata = (charm_dir / "metadata.yaml").read_text()
+    actions_meta = charm_dir / "actions.yaml"
+    if actions_meta.exists():
+        actions_metadata = actions_meta.read_text()
+    else:
+        actions_metadata = None
+
+    meta = CharmMeta.from_yaml(metadata, actions_metadata)
+
+    # ops >= 2.10
+    if inspect.signature(ops.model.Model).parameters.get("broken_relation_id"):
+        # 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.id  # type: ignore
+            if event.name.endswith("_relation_broken")
+            else None
+        )
+
+        model = ops.model.Model(
+            meta,
+            model_backend,
+            broken_relation_id=broken_relation_id,
+        )
+    else:
+        ops_logger.warning(
+            "It looks like this charm is using an older `ops` version. "
+            "You may experience weirdness. Please update ops.",
+        )
+        model = ops.model.Model(meta, model_backend)
+
+    charm_state_path = charm_dir / CHARM_STATE_FILE
+
+    # TODO: add use_juju_for_storage support
+    store = ops.storage.SQLiteStorage(charm_state_path)
+    framework = ops.Framework(store, charm_dir, meta, model)
+    framework.set_breakpointhook()
+    return framework
+
+
+def setup_charm(
+    charm_class: Type[ops.CharmBase], framework: ops.Framework, dispatcher: _Dispatcher
+):
+    sig = inspect.signature(charm_class)
+    sig.bind(framework)  # signature check
+
+    charm = charm_class(framework)
+    dispatcher.ensure_event_links(charm)
+    return charm
+
+
+def setup(
+    state: "State",
+    event: "_Event",
+    context: "Context",
+    charm_spec: "_CharmSpec[CharmType]",
+):
+    """Setup dispatcher, framework and charm objects."""
+    charm_class = charm_spec.charm_type
+    charm_dir = _get_charm_dir()
+
+    try:
+        dispatcher = _Dispatcher(charm_dir)  # type: ignore
+    except TypeError:
+        # ops 2.16+
+        import ops.jujucontext  # type: ignore
+
+        dispatcher = _Dispatcher(
+            charm_dir,
+            ops.jujucontext._JujuContext.from_dict(os.environ),  # type: ignore
+        )
+    dispatcher.run_any_legacy_hook()
+
+    framework = setup_framework(charm_dir, state, event, context, charm_spec)
+    charm = setup_charm(charm_class, framework, dispatcher)
+    return dispatcher, framework, charm
+
+
+class Ops:
+    """Class to manage stepping through ops setup, event emission and framework commit."""
+
+    def __init__(
+        self,
+        state: "State",
+        event: "_Event",
+        context: "Context",
+        charm_spec: "_CharmSpec[CharmType]",
+    ):
+        self.state = state
+        self.event = event
+        self.context = context
+        self.charm_spec = charm_spec
+
+        # set by setup()
+        self.dispatcher: Optional[_Dispatcher] = None
+        self.framework: Optional[ops.Framework] = None
+        self.charm: Optional[ops.CharmBase] = None
+
+        self._has_setup = False
+        self._has_emitted = False
+        self._has_committed = False
+
+    def setup(self):
+        """Setup framework, charm and dispatcher."""
+        self._has_setup = True
+        self.dispatcher, self.framework, self.charm = setup(
+            self.state,
+            self.event,
+            self.context,
+            self.charm_spec,
+        )
+
+    def emit(self):
+        """Emit the event on the charm."""
+        if not self._has_setup:
+            raise RuntimeError("should .setup() before you .emit()")
+        self._has_emitted = True
+
+        dispatcher = cast(_Dispatcher, self.dispatcher)
+        charm = cast(CharmBase, self.charm)
+        framework = cast(ops.Framework, self.framework)
+
+        try:
+            if not dispatcher.is_restricted_context():
+                framework.reemit()
+
+            _emit_charm_event(charm, dispatcher.event_name, self.event)
+
+        except Exception:
+            framework.close()
+            raise
+
+    def commit(self):
+        """Commit the framework and teardown."""
+        if not self._has_emitted:
+            raise RuntimeError("should .emit() before you .commit()")
+
+        framework = cast(ops.Framework, self.framework)
+        charm = cast(CharmBase, self.charm)
+
+        # emit collect-status events
+        ops.charm._evaluate_status(charm)
+
+        self._has_committed = True
+
+        try:
+            framework.commit()
+        finally:
+            framework.close()
+
+    def finalize(self):
+        """Step through all non-manually-called procedures and run them."""
+        if not self._has_setup:
+            self.setup()
+        if not self._has_emitted:
+            self.emit()
+        if not self._has_committed:
+            self.commit()
diff --git a/testing/src/py.typed b/testing/src/py.typed
new file mode 100644
index 000000000..e69de29bb
diff --git a/testing/src/runtime.py b/testing/src/runtime.py
new file mode 100644
index 000000000..613258e4a
--- /dev/null
+++ b/testing/src/runtime.py
@@ -0,0 +1,584 @@
+#!/usr/bin/env python3
+# Copyright 2023 Canonical Ltd.
+# See LICENSE file for licensing details.
+
+import copy
+import dataclasses
+import marshal
+import os
+import re
+import tempfile
+import typing
+from contextlib import contextmanager
+from pathlib import Path
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    Dict,
+    FrozenSet,
+    List,
+    Optional,
+    Set,
+    Type,
+    TypeVar,
+    Union,
+)
+
+import yaml
+from ops import CollectStatusEvent, pebble
+from ops.framework import (
+    CommitEvent,
+    EventBase,
+    Framework,
+    Handle,
+    NoTypeError,
+    PreCommitEvent,
+    _event_regex,
+)
+from ops.storage import NoSnapshotError, SQLiteStorage
+from ops._private.harness import ActionFailed
+
+from scenario.errors import NoObserverError, UncaughtCharmError
+from scenario.logger import logger as scenario_logger
+from scenario.state import (
+    DeferredEvent,
+    PeerRelation,
+    Relation,
+    StoredState,
+    SubordinateRelation,
+)
+
+if TYPE_CHECKING:  # pragma: no cover
+    from scenario.context import Context
+    from scenario.state import CharmType, State, _CharmSpec, _Event
+
+logger = scenario_logger.getChild("runtime")
+STORED_STATE_REGEX = re.compile(
+    r"((?P<owner_path>.*)\/)?(?P<_data_type_name>\D+)\[(?P<name>.*)\]",
+)
+EVENT_REGEX = re.compile(_event_regex)
+
+RUNTIME_MODULE = Path(__file__).parent
+
+
+class UnitStateDB:
+    """Represents the unit-state.db."""
+
+    def __init__(self, db_path: Union[Path, str]):
+        self._db_path = db_path
+        self._state_file = Path(self._db_path)
+
+    def _open_db(self) -> SQLiteStorage:
+        """Open the db."""
+        return SQLiteStorage(self._state_file)
+
+    def get_stored_states(self) -> FrozenSet["StoredState"]:
+        """Load any StoredState data structures from the db."""
+
+        db = self._open_db()
+
+        stored_states: Set[StoredState] = set()
+        for handle_path in db.list_snapshots():
+            if not EVENT_REGEX.match(handle_path) and (
+                match := STORED_STATE_REGEX.match(handle_path)
+            ):
+                stored_state_snapshot = db.load_snapshot(handle_path)
+                kwargs = match.groupdict()
+                sst = StoredState(content=stored_state_snapshot, **kwargs)
+                stored_states.add(sst)
+
+        db.close()
+        return frozenset(stored_states)
+
+    def get_deferred_events(self) -> List["DeferredEvent"]:
+        """Load any DeferredEvent data structures from the db."""
+
+        db = self._open_db()
+
+        deferred: List[DeferredEvent] = []
+        for handle_path in db.list_snapshots():
+            if EVENT_REGEX.match(handle_path):
+                notices = db.notices(handle_path)
+                for handle, owner, observer in notices:
+                    try:
+                        snapshot_data = db.load_snapshot(handle)
+                    except NoSnapshotError:
+                        snapshot_data: Dict[str, Any] = {}
+
+                    event = DeferredEvent(
+                        handle_path=handle,
+                        owner=owner,
+                        observer=observer,
+                        snapshot_data=snapshot_data,
+                    )
+                    deferred.append(event)
+
+        db.close()
+        return deferred
+
+    def apply_state(self, state: "State"):
+        """Add DeferredEvent and StoredState from this State instance to the storage."""
+        db = self._open_db()
+        for event in state.deferred:
+            db.save_notice(event.handle_path, event.owner, event.observer)
+            try:
+                marshal.dumps(event.snapshot_data)
+            except ValueError as e:
+                raise ValueError(
+                    f"unable to save the data for {event}, it must contain only simple types.",
+                ) from e
+            db.save_snapshot(event.handle_path, event.snapshot_data)
+
+        for stored_state in state.stored_states:
+            db.save_snapshot(stored_state._handle_path, stored_state.content)
+
+        db.close()
+
+
+class _OpsMainContext:  # type: ignore
+    """Context manager representing ops.main execution context.
+
+    When entered, ops.main sets up everything up until the charm.
+    When .emit() is called, ops.main proceeds with emitting the event.
+    When exited, if .emit has not been called manually, it is called automatically.
+    """
+
+    def __init__(self):
+        self._has_emitted = False
+
+    def __enter__(self):
+        pass
+
+    def emit(self):
+        self._has_emitted = True
+
+    def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any):  # noqa: U100
+        if not self._has_emitted:
+            self.emit()
+
+
+class Runtime:
+    """Charm runtime wrapper.
+
+    This object bridges a local environment and a charm artifact.
+    """
+
+    def __init__(
+        self,
+        charm_spec: "_CharmSpec[CharmType]",
+        charm_root: Optional[Union[str, Path]] = None,
+        juju_version: str = "3.0.0",
+        app_name: Optional[str] = None,
+        unit_id: Optional[int] = 0,
+    ):
+        self._charm_spec = charm_spec
+        self._juju_version = juju_version
+        self._charm_root = charm_root
+
+        app_name = app_name or self._charm_spec.meta.get("name")
+        if not app_name:
+            raise ValueError('invalid metadata: mandatory "name" field is missing.')
+
+        self._app_name = app_name
+        self._unit_id = unit_id
+
+    @staticmethod
+    def _cleanup_env(env: Dict[str, str]):
+        # TODO consider cleaning up env on __delete__, but ideally you should be
+        #  running this in a clean env or a container anyway.
+        # cleanup the env, in case we'll be firing multiple events, we don't want to pollute it.
+        for key in 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):
+        """Build the simulated environment the operator framework expects."""
+        env = {
+            "JUJU_VERSION": self._juju_version,
+            "JUJU_UNIT_NAME": f"{self._app_name}/{self._unit_id}",
+            "_": "./dispatch",
+            "JUJU_DISPATCH_PATH": f"hooks/{event.name}",
+            "JUJU_MODEL_NAME": state.model.name,
+            "JUJU_MODEL_UUID": state.model.uuid,
+            "JUJU_CHARM_DIR": str(charm_root.absolute()),
+            # todo consider setting pwd, (python)path
+        }
+
+        if event._is_action_event and (action := event.action):
+            env.update(
+                {
+                    # TODO: we should check we're doing the right thing here.
+                    "JUJU_ACTION_NAME": action.name.replace("_", "-"),
+                    "JUJU_ACTION_UUID": action.id,
+                },
+            )
+
+        if event._is_relation_event and (relation := event.relation):
+            if isinstance(relation, PeerRelation):
+                remote_app_name = self._app_name
+            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.id),
+                    "JUJU_REMOTE_APP": remote_app_name,
+                },
+            )
+
+            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 and not event.name.endswith(
+                ("_relation_created", "relation_broken"),
+            ):
+                remote_unit_ids = relation._remote_unit_ids
+
+                if len(remote_unit_ids) == 1:
+                    remote_unit_id = remote_unit_ids[0]
+                    logger.info(
+                        "there's only one remote unit, so we set JUJU_REMOTE_UNIT to it, "
+                        "but you probably should be parametrizing the event with `remote_unit_id` "
+                        "to be explicit.",
+                    )
+                elif len(remote_unit_ids) > 1:
+                    remote_unit_id = remote_unit_ids[0]
+                    logger.warning(
+                        "remote unit ID unset, and multiple remote unit IDs are present; "
+                        "We will pick the first one and hope for the best. You should be passing "
+                        "`remote_unit_id` to the Event constructor.",
+                    )
+                else:
+                    logger.warning(
+                        "remote unit ID unset; no remote unit data present. "
+                        "Is this a realistic scenario?",  # TODO: is it?
+                    )
+
+            if remote_unit_id is not None:
+                remote_unit = f"{remote_app_name}/{remote_unit_id}"
+                env["JUJU_REMOTE_UNIT"] = remote_unit
+                if event.name.endswith("_relation_departed"):
+                    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})
+
+        if notice := event.notice:
+            if hasattr(notice.type, "value"):
+                notice_type = typing.cast(pebble.NoticeType, notice.type).value
+            else:
+                notice_type = str(notice.type)
+            env.update(
+                {
+                    "JUJU_NOTICE_ID": notice.id,
+                    "JUJU_NOTICE_TYPE": notice_type,
+                    "JUJU_NOTICE_KEY": notice.key,
+                },
+            )
+
+        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}"})
+
+        if secret := event.secret:
+            env.update(
+                {
+                    "JUJU_SECRET_ID": secret.id,
+                    "JUJU_SECRET_LABEL": secret.label or "",
+                },
+            )
+            # 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
+
+    @staticmethod
+    def _wrap(charm_type: Type["CharmType"]) -> Type["CharmType"]:
+        # dark sorcery to work around framework using class attrs to hold on to event sources
+        # todo this should only be needed if we call play multiple times on the same runtime.
+        #  can we avoid it?
+        class WrappedEvents(charm_type.on.__class__):
+            pass
+
+        WrappedEvents.__name__ = charm_type.on.__class__.__name__
+
+        class WrappedCharm(charm_type):
+            on = WrappedEvents()
+
+        WrappedCharm.__name__ = charm_type.__name__
+        return typing.cast(Type["CharmType"], WrappedCharm)
+
+    @contextmanager
+    def _virtual_charm_root(self):
+        # If we are using runtime on a real charm, we can make some assumptions about the
+        # directory structure we are going to find.
+        # If we're, say, dynamically defining charm types and doing tests on them, we'll have to
+        # generate the metadata files ourselves. To be sure, we ALWAYS use a tempdir. Ground truth
+        # is what the user passed via the CharmSpec
+        spec = self._charm_spec
+
+        if charm_virtual_root := self._charm_root:
+            charm_virtual_root_is_custom = True
+            virtual_charm_root = Path(charm_virtual_root)
+        else:
+            charm_virtual_root = tempfile.TemporaryDirectory()
+            virtual_charm_root = Path(charm_virtual_root.name)
+            charm_virtual_root_is_custom = False
+
+        metadata_yaml = virtual_charm_root / "metadata.yaml"
+        config_yaml = virtual_charm_root / "config.yaml"
+        actions_yaml = virtual_charm_root / "actions.yaml"
+
+        metadata_files_present: Dict[Path, Optional[str]] = {
+            file: file.read_text() if file.exists() else None
+            for file in (metadata_yaml, config_yaml, actions_yaml)
+        }
+
+        any_metadata_files_present_in_charm_virtual_root = any(
+            v is not None for v in metadata_files_present.values()
+        )
+
+        if spec.is_autoloaded and charm_virtual_root_is_custom:
+            # since the spec is autoloaded, in theory the metadata contents won't differ, so we can
+            # overwrite away even if the custom vroot is the real charm root (the local repo).
+            # Still, log it for clarity.
+            if any_metadata_files_present_in_charm_virtual_root:
+                logger.debug(
+                    f"metadata files found in custom charm_root {charm_virtual_root}. "
+                    f"The spec was autoloaded so the contents should be identical. "
+                    f"Proceeding...",
+                )
+
+        elif (
+            not spec.is_autoloaded and any_metadata_files_present_in_charm_virtual_root
+        ):
+            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(). "
+                "Single source of truth are the arguments passed to Context.run(). "
+                "charm_root metadata files will be overwritten for the "
+                "duration of this test, and restored afterwards. "
+                "To avoid this, clean any metadata files from the charm_root before calling run.",
+            )
+
+        metadata_yaml.write_text(yaml.safe_dump(spec.meta))
+        config_yaml.write_text(yaml.safe_dump(spec.config or {}))
+        actions_yaml.write_text(yaml.safe_dump(spec.actions or {}))
+
+        yield virtual_charm_root
+
+        if charm_virtual_root_is_custom:
+            for file, previous_content in metadata_files_present.items():
+                if previous_content is None:  # None == file did not exist before
+                    file.unlink()
+                else:
+                    file.write_text(previous_content)
+
+        else:
+            # charm_virtual_root is a tempdir
+            typing.cast(tempfile.TemporaryDirectory, charm_virtual_root).cleanup()  # type: ignore
+
+    @staticmethod
+    def _get_state_db(temporary_charm_root: Path):
+        charm_state_path = temporary_charm_root / ".unit-state.db"
+        return UnitStateDB(charm_state_path)
+
+    def _initialize_storage(self, state: "State", temporary_charm_root: Path):
+        """Before we start processing this event, store the relevant parts of State."""
+        store = self._get_state_db(temporary_charm_root)
+        store.apply_state(state)
+
+    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_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 customising capture_events
+            with _capture_events(
+                include_deferred=ctx.capture_deferred_events,
+                include_framework=ctx.capture_framework_events,
+            ) as captured:
+                yield (temporary_charm_root, captured)
+
+    @contextmanager
+    def exec(
+        self,
+        state: "State",
+        event: "_Event",
+        context: "Context",
+    ):
+        """Runs an event with this state as initial state on a charm.
+
+        Returns the 'output state', that is, the state as mutated by the charm during the
+        event handling.
+
+        This will set the environment up and call ops.main.main().
+        After that it's up to ops.
+        """
+        # 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
+
+        check_consistency(state, event, self._charm_spec, self._juju_version)
+
+        charm_type = self._charm_spec.charm_type
+        logger.info(f"Preparing to fire {event.name} on {charm_type.__name__}")
+
+        # we make a copy to avoid mutating the input state
+        output_state = copy.deepcopy(state)
+
+        logger.info(" - generating virtual charm root")
+        with self._exec_ctx(context) as (temporary_charm_root, captured):
+            logger.info(" - initializing storage")
+            self._initialize_storage(state, temporary_charm_root)
+
+            logger.info(" - preparing env")
+            env = self._get_event_env(
+                state=state,
+                event=event,
+                charm_root=temporary_charm_root,
+            )
+            os.environ.update(env)
+
+            logger.info(" - Entering ops.main (mocked).")
+            from scenario.ops_main_mock import Ops  # noqa: F811
+
+            try:
+                ops = Ops(
+                    state=output_state,
+                    event=event,
+                    context=context,
+                    charm_spec=dataclasses.replace(
+                        self._charm_spec,
+                        charm_type=self._wrap(charm_type),
+                    ),
+                )
+                ops.setup()
+
+                yield ops
+
+                # if the caller did not manually emit or commit: do that.
+                ops.finalize()
+
+            except (NoObserverError, ActionFailed):
+                raise  # propagate along
+            except Exception as e:
+                raise UncaughtCharmError(
+                    f"Uncaught exception ({type(e)}) in operator/charm code: {e!r}",
+                ) from e
+
+            finally:
+                logger.info(" - Exited ops.main.")
+
+                logger.info(" - Clearing env")
+                self._cleanup_env(env)
+
+            logger.info(" - closing storage")
+            output_state = self._close_storage(output_state, temporary_charm_root)
+
+        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: bool = False,
+    include_deferred: bool = 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: List[EventBase] = []
+    _real_emit = Framework._emit
+    _real_reemit = Framework.reemit
+
+    def _wrapped_emit(self: Framework, evt: EventBase):
+        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):
+        # 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
+
+    yield captured
+
+    Framework._emit = _real_emit
+    Framework.reemit = _real_reemit
diff --git a/testing/src/state.py b/testing/src/state.py
new file mode 100644
index 000000000..5ff4d71ed
--- /dev/null
+++ b/testing/src/state.py
@@ -0,0 +1,2041 @@
+#!/usr/bin/env python3
+# Copyright 2023 Canonical Ltd.
+# See LICENSE file for licensing details.
+
+"""The core Scenario State object, and the components inside it."""
+
+import dataclasses
+import datetime
+import inspect
+import random
+import re
+import string
+from enum import Enum
+from itertools import chain
+from pathlib import Path, PurePosixPath
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    Callable,
+    ClassVar,
+    Dict,
+    Final,
+    FrozenSet,
+    Generic,
+    Iterable,
+    List,
+    Literal,
+    Mapping,
+    NoReturn,
+    Optional,
+    Sequence,
+    Set,
+    Tuple,
+    Type,
+    TypeVar,
+    Union,
+    cast,
+)
+from uuid import uuid4
+
+import ops
+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
+
+if TYPE_CHECKING:  # pragma: no cover
+    from scenario import Context
+
+AnyJson = Union[str, bool, Dict[str, "AnyJson"], int, float, List["AnyJson"]]
+RawSecretRevisionContents = RawDataBagContents = Dict[str, str]
+UnitID = int
+
+CharmType = TypeVar("CharmType", bound=CharmBase)
+
+logger = scenario_logger.getChild("state")
+
+ATTACH_ALL_STORAGES = "ATTACH_ALL_STORAGES"
+CREATE_ALL_RELATIONS = "CREATE_ALL_RELATIONS"
+BREAK_ALL_RELATIONS = "BREAK_ALL_RELATIONS"
+DETACH_ALL_STORAGES = "DETACH_ALL_STORAGES"
+
+_ACTION_EVENT_SUFFIX = "_action"
+# all builtin events except secret events. They're special because they carry secret metadata.
+_BUILTIN_EVENTS = {
+    "start",
+    "stop",
+    "install",
+    "install",
+    "start",
+    "stop",
+    "remove",
+    "update_status",
+    "config_changed",
+    "upgrade_charm",
+    "pre_series_upgrade",
+    "post_series_upgrade",
+    "leader_elected",
+    "leader_settings_changed",
+    "collect_metrics",
+}
+_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"
+_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_detaching",
+    "_storage_attached",
+}
+
+_SECRET_EVENTS = {
+    "secret_changed",
+    "secret_remove",
+    "secret_rotate",
+    "secret_expired",
+}
+
+
+# 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.
+
+        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: Any, **kwargs: Any):
+            # 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.__init__).parameters
+            required_args = [
+                name
+                for name in tuple(parameters)
+                if parameters[name].default is inspect.Parameter.empty
+                and name not in kwargs
+                and name != "self"
+            ]
+            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), ())
+
+    return _MaxPositionalArgs
+
+
+@dataclasses.dataclass(frozen=True)
+class JujuLogLine(_max_posargs(2)):
+    """An entry in the Juju debug-log."""
+
+    level: str
+    """The level of the message, for example ``INFO`` or ``ERROR``."""
+    message: str
+    """The log message."""
+
+
+@dataclasses.dataclass(frozen=True)
+class CloudCredential(_max_posargs(0)):
+    __doc__ = ops.CloudCredential.__doc__
+
+    auth_type: str
+    """Authentication type."""
+
+    attributes: Dict[str, str] = dataclasses.field(default_factory=dict)
+    """A dictionary containing cloud credentials.
+    For example, for AWS, it contains `access-key` and `secret-key`;
+    for Azure, `application-id`, `application-password` and `subscription-id`
+    can be found here.
+    """
+
+    redacted: List[str] = dataclasses.field(default_factory=list)
+    """A list of redacted generic cloud API secrets."""
+
+    def _to_ops(self) -> CloudCredential_Ops:
+        return CloudCredential_Ops(
+            auth_type=self.auth_type,
+            attributes=self.attributes,
+            redacted=self.redacted,
+        )
+
+
+@dataclasses.dataclass(frozen=True)
+class CloudSpec(_max_posargs(1)):
+    __doc__ = ops.CloudSpec.__doc__
+
+    type: str
+    """Type of the cloud."""
+
+    name: str = "localhost"
+    """Juju cloud name."""
+
+    region: Optional[str] = None
+    """Region of the cloud."""
+
+    endpoint: Optional[str] = None
+    """Endpoint of the cloud."""
+
+    identity_endpoint: Optional[str] = None
+    """Identity endpoint of the cloud."""
+
+    storage_endpoint: Optional[str] = None
+    """Storage endpoint of the cloud."""
+
+    credential: Optional[CloudCredential] = None
+    """Cloud credentials with key-value attributes."""
+
+    ca_certificates: List[str] = dataclasses.field(default_factory=list)
+    """A list of CA certificates."""
+
+    skip_tls_verify: bool = False
+    """Whether to skip TLS verification."""
+
+    is_controller_cloud: bool = False
+    """If this is the cloud used by the controller."""
+
+    def _to_ops(self) -> CloudSpec_Ops:
+        return CloudSpec_Ops(
+            type=self.type,
+            name=self.name,
+            region=self.region,
+            endpoint=self.endpoint,
+            identity_endpoint=self.identity_endpoint,
+            storage_endpoint=self.storage_endpoint,
+            credential=self.credential._to_ops() if self.credential else None,
+            ca_certificates=self.ca_certificates,
+            skip_tls_verify=self.skip_tls_verify,
+            is_controller_cloud=self.is_controller_cloud,
+        )
+
+
+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(_max_posargs(1)):
+    """A Juju secret.
+
+    This class is used for both user and charm secrets.
+    """
+
+    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.
+    """
+
+    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."""
+
+    # what revision is currently tracked by this charm. Only meaningful if owner=False
+    _tracked_revision: int = 1
+
+    # what revision is the latest for this secret.
+    _latest_revision: int = 1
+
+    def __hash__(self) -> int:
+        return hash(self.id)
+
+    def __post_init__(self):
+        if self.latest_content is None:
+            # bypass frozen dataclass
+            object.__setattr__(self, "latest_content", self.tracked_content)
+
+    def _set_label(self, label: str):
+        # bypass frozen dataclass
+        object.__setattr__(self, "label", label)
+
+    def _track_latest_revision(self):
+        """Set the current revision to the tracked revision."""
+        # bypass frozen dataclass
+        object.__setattr__(self, "_tracked_revision", self._latest_revision)
+        object.__setattr__(self, "tracked_content", self.latest_content)
+
+    def _update_metadata(
+        self,
+        content: Optional["RawSecretRevisionContents"] = None,
+        label: Optional[str] = None,
+        description: Optional[str] = None,
+        expire: Optional[datetime.datetime] = None,
+        rotate: Optional[SecretRotate] = None,
+    ):
+        """Update the metadata."""
+        # 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:
+            object.__setattr__(self, "description", description)
+        if expire:
+            if isinstance(expire, datetime.timedelta):
+                expire = datetime.datetime.now() + expire
+            object.__setattr__(self, "expire", expire)
+        if rotate:
+            object.__setattr__(self, "rotate", rotate)
+
+
+def _normalise_name(s: str):
+    """Event names, in Scenario, uniformly use underscores instead of dashes."""
+    return s.replace("-", "_")
+
+
+@dataclasses.dataclass(frozen=True)
+class Address(_max_posargs(1)):
+    """An address in a Juju network space."""
+
+    value: str
+    """The IP address in the space."""
+    hostname: str = ""
+    """A host name that maps to the address in :attr:`value`."""
+    cidr: str = ""
+    """The CIDR of the address in :attr:`value`."""
+
+    @property
+    def address(self):
+        """A deprecated alias for :attr:`value`."""
+        return self.value
+
+    @address.setter
+    def address(self, value: str):
+        object.__setattr__(self, "value", value)
+
+
+@dataclasses.dataclass(frozen=True)
+class BindAddress(_max_posargs(1)):
+    """An address bound to a network interface in a Juju space."""
+
+    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):
+        # dumps itself to dict in the same format the hook tool would
+        # todo support for legacy (deprecated) `interfacename` and `macaddress` fields?
+        dct = {
+            "interface-name": self.interface_name,
+            "addresses": [dataclasses.asdict(addr) for addr in self.addresses],
+        }
+        if self.mac_address:
+            dct["mac-address"] = self.mac_address
+        return dct
+
+
+@dataclasses.dataclass(frozen=True)
+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 __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
+            ],
+            "egress-subnets": self.egress_subnets,
+            "ingress-addresses": self.ingress_addresses,
+        }
+
+
+_next_relation_id_counter = 1
+
+
+def _next_relation_id(*, update: bool = 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:
+        _next_relation_id_counter += 1
+    return cur
+
+
+@dataclasses.dataclass(frozen=True)
+class RelationBase(_max_posargs(2)):
+    endpoint: str
+    """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 the metadata.
+    If left empty, it will be automatically derived from the metadata."""
+
+    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."""
+
+    local_app_data: "RawDataBagContents" = dataclasses.field(default_factory=dict)
+    """This application's databag for this relation."""
+
+    local_unit_data: "RawDataBagContents" = dataclasses.field(
+        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."""
+        yield self.local_app_data
+        yield self.local_unit_data
+
+    @property
+    def _remote_unit_ids(self) -> Tuple["UnitID", ...]:
+        """Ids of the units on the other end of this relation."""
+        raise NotImplementedError()
+
+    def _get_databag_for_remote(
+        self,
+        unit_id: int,  # noqa: U100
+    ) -> "RawDataBagContents":
+        """Return the databag for some remote unit ID."""
+        raise NotImplementedError()
+
+    def __post_init__(self):
+        if type(self) is RelationBase:
+            raise RuntimeError(
+                "RelationBase cannot be instantiated directly; "
+                "please use Relation, PeerRelation, or SubordinateRelation",
+            )
+
+        for databag in self._databags:
+            self._validate_databag(databag)
+
+    def __hash__(self) -> int:
+        return hash(self.id)
+
+    def _validate_databag(self, databag: Dict[str, str]):
+        if not isinstance(databag, dict):
+            raise StateValidationError(
+                f"all databags should be dicts, not {type(databag)}",
+            )
+        for v in databag.values():
+            if not isinstance(v, str):
+                raise StateValidationError(
+                    f"all databags should be Dict[str,str]; "
+                    f"found a value of type {type(v)}",
+                )
+
+
+_DEFAULT_IP = "192.0.2.0"
+_DEFAULT_JUJU_DATABAG = {
+    "egress-subnets": _DEFAULT_IP,
+    "ingress-address": _DEFAULT_IP,
+    "private-address": _DEFAULT_IP,
+}
+
+
+@dataclasses.dataclass(frozen=True)
+class Relation(RelationBase):
+    """An integration between the charm and another application."""
+
+    remote_app_name: str = "remote"
+    """The name of the remote application, as in the charm's metadata."""
+
+    # local limit
+    limit: int = 1
+    """The maximum number of integrations on this endpoint."""
+
+    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
+    )
+    """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?"""
+        return self.remote_app_name
+
+    @property
+    def _remote_unit_ids(self) -> Tuple["UnitID", ...]:
+        """Ids of the units on the other end of this relation."""
+        return tuple(self.remote_units_data)
+
+    def _get_databag_for_remote(self, unit_id: "UnitID") -> "RawDataBagContents":
+        """Return the databag for some remote unit ID."""
+        return self.remote_units_data[unit_id]
+
+    @property
+    def _databags(self):  # type: ignore
+        """Yield all databags in this relation."""
+        yield self.local_app_data
+        yield self.local_unit_data
+        yield self.remote_app_data
+        yield from self.remote_units_data.values()
+
+
+@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(),
+    )
+    """The current content of the remote unit databag."""
+
+    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]:
+        """Ids of the units on the other end of this relation."""
+        return (self.remote_unit_id,)
+
+    def _get_databag_for_remote(self, unit_id: int) -> "RawDataBagContents":
+        """Return the databag for some remote unit ID."""
+        if unit_id is not self.remote_unit_id:
+            raise ValueError(
+                f"invalid unit id ({unit_id}): subordinate relation only has one "
+                f"remote and that has id {self.remote_unit_id}",
+            )
+        return self.remote_unit_data
+
+    @property
+    def _databags(self):
+        """Yield all databags in this relation."""
+        yield self.local_app_data
+        yield self.local_unit_data
+        yield self.remote_app_data
+        yield self.remote_unit_data
+
+    @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}"
+
+
+@dataclasses.dataclass(frozen=True)
+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()},
+    )
+    """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):  # type: ignore
+        """Yield all databags in this relation."""
+        yield self.local_app_data
+        yield self.local_unit_data
+        yield from self.peers_data.values()
+
+    @property
+    def _remote_unit_ids(self) -> Tuple["UnitID", ...]:
+        """Ids of the units on the other end of this relation."""
+        return tuple(self.peers_data)
+
+    def _get_databag_for_remote(self, unit_id: "UnitID") -> "RawDataBagContents":
+        """Return the databag for some remote unit ID."""
+        return self.peers_data[unit_id]
+
+
+def _random_model_name():
+    import random
+    import string
+
+    space = string.ascii_letters + string.digits
+    return "".join(random.choice(space) for _ in range(20))
+
+
+@dataclasses.dataclass(frozen=True)
+class Model(_max_posargs(1)):
+    """The Juju model in which the charm is deployed."""
+
+    name: str = dataclasses.field(default_factory=_random_model_name)
+    """The name of the model."""
+    uuid: str = dataclasses.field(default_factory=lambda: str(uuid4()))
+    """A unique identifier for the model, typically generated by Juju."""
+
+    # whatever juju models --format=json | jq '.models[<current-model-index>].type' gives back.
+    # TODO: make this exhaustive.
+    type: Literal["kubernetes", "lxd"] = "kubernetes"
+    """The type of Juju model."""
+
+    cloud_spec: Optional[CloudSpec] = None
+    """Cloud specification information (metadata) including credentials."""
+
+
+# for now, proc mock allows you to map one command to one mocked output.
+# todo extend: one input -> multiple outputs, at different times
+
+
+_CHANGE_IDS = 0
+
+
+def _generate_new_change_id():
+    global _CHANGE_IDS
+    _CHANGE_IDS += 1  # type: ignore
+    logger.info(
+        f"change ID unset; automatically assigning {_CHANGE_IDS}. "
+        f"If there are problems, pass one manually.",
+    )
+    return _CHANGE_IDS
+
+
+@dataclasses.dataclass(frozen=True)
+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.
+
+    Use 0 to mock the process ending successfully, and other values for failure.
+    """
+    stdout: str = ""
+    """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.
+
+    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
+
+
+@dataclasses.dataclass(frozen=True)
+class Mount(_max_posargs(0)):
+    """Maps local files to a :class:`Container` filesystem."""
+
+    location: Union[str, PurePosixPath]
+    """The location inside of the container."""
+    source: Union[str, Path]
+    """The content to provide when the charm does :meth:`ops.Container.pull`."""
+
+
+def _now_utc():
+    return datetime.datetime.now(tz=datetime.timezone.utc)
+
+
+_next_notice_id_counter = 1
+
+
+def _next_notice_id(*, update: bool = 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:
+        _next_notice_id_counter += 1
+    return str(cur)
+
+
+@dataclasses.dataclass(frozen=True)
+class Notice(_max_posargs(1)):
+    """A Pebble notice."""
+
+    key: str
+    """The notice key, a string that differentiates notices of this type.
+
+    This is in the format ``domain/path``; for example:
+    ``canonical.com/postgresql/backup`` or ``example.com/mycharm/notice``.
+    """
+
+    id: str = dataclasses.field(default_factory=_next_notice_id)
+    """Unique ID for this notice."""
+
+    user_id: Optional[int] = None
+    """UID of the user who may view this notice (None means notice is public)."""
+
+    type: Union[pebble.NoticeType, str] = pebble.NoticeType.CUSTOM
+    """Type of the notice."""
+
+    first_occurred: datetime.datetime = dataclasses.field(default_factory=_now_utc)
+    """The first time one of these notices (type and key combination) occurs."""
+
+    last_occurred: datetime.datetime = dataclasses.field(default_factory=_now_utc)
+    """The last time one of these notices occurred."""
+
+    last_repeated: datetime.datetime = dataclasses.field(default_factory=_now_utc)
+    """The time this notice was last repeated.
+
+    See Pebble's `Notices documentation <https://canonical-pebble.readthedocs-hosted.com/en/latest/reference/notices/>`_
+    for an explanation of what "repeated" means.
+    """
+
+    occurrences: int = 1
+    """The number of times one of these notices has occurred."""
+
+    last_data: Dict[str, str] = dataclasses.field(default_factory=dict)
+    """Additional data captured from the last occurrence of one of these notices."""
+
+    repeat_after: Optional[datetime.timedelta] = None
+    """Minimum time after one of these was last repeated before Pebble will repeat it again."""
+
+    expire_after: Optional[datetime.timedelta] = None
+    """How long since one of these last occurred until Pebble will drop the notice."""
+
+    def _to_ops(self) -> pebble.Notice:
+        return pebble.Notice(
+            id=self.id,
+            user_id=self.user_id,
+            type=self.type,
+            key=self.key,
+            first_occurred=self.first_occurred,
+            last_occurred=self.last_occurred,
+            last_repeated=self.last_repeated,
+            occurrences=self.occurrences,
+            last_data=self.last_data,
+            repeat_after=self.repeat_after,
+            expire_after=self.expire_after,
+        )
+
+
+@dataclasses.dataclass(frozen=True)
+class CheckInfo(_max_posargs(1)):
+    """A health check for a Pebble workload container."""
+
+    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(_max_posargs(1)):
+    """A Kubernetes container where a charm's workload runs."""
+
+    name: str
+    """Name of the container, as found in the charm metadata."""
+
+    can_connect: bool = False
+    """When False, all Pebble operations will fail."""
+
+    # This is the base plan. On top of it, one can add layers.
+    # We need to model pebble in this way because it's impossible to retrieve the layers from
+    # pebble or derive them from the resulting plan (which one CAN get from pebble).
+    # So if we are instantiating Container by fetching info from a 'live' charm, the 'layers'
+    # will be unknown. all that we can know is the resulting plan (the 'computed plan').
+    _base_plan: Dict[str, Any] = dataclasses.field(default_factory=dict)
+    # We expect most of the user-facing testing to be covered by this 'layers' attribute,
+    # 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_statuses: Dict[str, pebble.ServiceStatus] = dataclasses.field(
+        default_factory=dict,
+    )
+    """The current status of each Pebble service running in the container."""
+
+    # 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)
+    """Provides access to the contents of the simulated container filesystem.
+
+    For example, suppose you want to express that your container has:
+
+    * ``/home/foo/bar.py``
+    * ``/bin/bash``
+    * ``/bin/baz``
+
+    this becomes::
+
+        mounts = {
+            '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/')),
+        }
+    """
+
+    execs: Iterable[Exec] = frozenset()
+    """Simulate executing commands in the container.
+
+    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 = Container(
+            name='foo',
+            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()
+        services: Dict[str, pebble.Service] = {}
+        for key in sorted(self.layers.keys()):
+            layer = self.layers[key]
+            for name, service in layer.services.items():
+                services[name] = service
+        return services
+
+    @property
+    def plan(self) -> pebble.Plan:
+        """The 'computed' Pebble plan.
+
+        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().
+        plan = pebble.Plan(yaml.safe_dump(self._base_plan))
+        services = self._render_services()
+        if not services:
+            return plan
+        for name in sorted(services.keys()):
+            plan.services[name] = services[name]
+        return plan
+
+    @property
+    def services(self) -> Dict[str, pebble.ServiceInfo]:
+        """The Pebble services as rendered in the plan."""
+        services = self._render_services()
+        infos: Dict[str, pebble.ServiceInfo] = {}
+        names = sorted(services.keys())
+        for name in names:
+            try:
+                service = services[name]
+            except KeyError:
+                # in pebble, it just returns "nothing matched" if there are 0 matches,
+                # but it ignores services it doesn't recognize
+                continue
+            status = self.service_statuses.get(name, pebble.ServiceStatus.INACTIVE)
+            if service.startup == "":
+                startup = pebble.ServiceStartup.DISABLED
+            else:
+                startup = pebble.ServiceStartup(service.startup)
+            info = pebble.ServiceInfo(
+                name,
+                startup=startup,
+                current=pebble.ServiceStatus(status),
+            )
+            infos[name] = info
+        return infos
+
+    def get_filesystem(self, ctx: "Context") -> Path:
+        """Simulated Pebble filesystem in this context.
+
+        Returns:
+            A temporary filesystem containing any files or directories the
+            charm pushed to the container.
+        """
+        return ctx._get_container_root(self.name)
+
+
+_RawStatusLiteral = Literal[
+    "waiting",
+    "blocked",
+    "active",
+    "unknown",
+    "error",
+    "maintenance",
+]
+
+
+@dataclasses.dataclass(frozen=True)
+class _EntityStatus:
+    """This class represents StatusBase and should not be interacted with directly."""
+
+    # 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: Any):
+        if isinstance(other, (StatusBase, _EntityStatus)):
+            return (self.name, self.message) == (other.name, other.message)
+        return super().__eq__(other)
+
+    def __repr__(self):
+        status_type_name = self.name.title() + "Status"
+        if self.name == "unknown":
+            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(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 __init__(self):
+        super().__init__(name=self.name)
+
+
+@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(_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"
+
+    @property
+    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(_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.
+    """
+
+    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):
+        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  # type: ignore
+    """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` outside bounds [1:65535], got {self.port}",
+            )
+
+
+@dataclasses.dataclass(frozen=True)
+class UDPPort(Port):
+    """Represents a UDP port on the charm host."""
+
+    port: int  # type: ignore
+    """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: bool = 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.
+    Pass update=True if you also want to bump it.
+    """
+    global _next_storage_index_counter
+    cur = _next_storage_index_counter
+    if update:
+        _next_storage_index_counter += 1
+    return cur
+
+
+@dataclasses.dataclass(frozen=True)
+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.
+
+    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)
+
+
+@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(_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.unit_status`, is-leader will
+    return data from `State.leader`, and so on.
+    """
+
+    config: Dict[str, Union[str, int, float, bool]] = dataclasses.field(
+        default_factory=dict,
+    )
+    """The present configuration of this charm."""
+    relations: Iterable["RelationBase"] = dataclasses.field(default_factory=frozenset)
+    """All relations that currently exist for this charm."""
+    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.
+
+    .. 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: Iterable[Container] = dataclasses.field(default_factory=frozenset)
+    """All containers (whether they can connect or not) that this charm is aware of."""
+    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: 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: 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: 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
+    # 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_states: Iterable["StoredState"] = dataclasses.field(
+        default_factory=frozenset,
+    )
+    """Contents of a charm's stored state."""
+
+    # the current statuses.
+    app_status: _EntityStatus = UnknownStatus()
+    """Status of the application."""
+    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, _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."""
+        # We don't keep a full history because we don't expect the app version to change more
+        # than once per hook.
+
+        # bypass frozen dataclass
+        object.__setattr__(self, "workload_version", new_workload_version)
+
+    def _update_status(
+        self,
+        new_status: _EntityStatus,
+        is_app: bool = False,
+    ):
+        """Update the current app/unit status."""
+        name = "app_status" if is_app else "unit_status"
+        # bypass frozen dataclass
+        object.__setattr__(self, name, new_status)
+
+    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 _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_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["RelationBase", ...]:
+        """Get all relations on this endpoint from the current state."""
+
+        # we rather normalize the endpoint than worry about cursed metadata situations such as:
+        # requires:
+        #   foo-bar: ...
+        #   foo_bar: ...
+
+        normalized_endpoint = _normalise_name(endpoint)
+        return tuple(
+            r
+            for r in self.relations
+            if _normalise_name(r.endpoint) == normalized_endpoint
+        )
+
+
+def _is_valid_charmcraft_25_metadata(meta: Dict[str, Any]):
+    # Check whether this dict has the expected mandatory metadata fields according to the
+    # charmcraft >2.5 charmcraft.yaml schema
+    if (config_type := meta.get("type")) != "charm":
+        logger.debug(
+            f"Not a charm: charmcraft yaml config ``.type`` is {config_type!r}.",
+        )
+        return False
+    if not all(field in meta for field in {"name", "summary", "description"}):
+        logger.debug("Not a charm: charmcraft yaml misses some required fields")
+        return False
+    return True
+
+
+@dataclasses.dataclass(frozen=True)
+class _CharmSpec(Generic[CharmType]):
+    """Charm spec."""
+
+    charm_type: Type[CharmBase]
+    meta: Dict[str, Any]
+    actions: Optional[Dict[str, Any]] = None
+    config: Optional[Dict[str, Any]] = None
+
+    # autoloaded means: we are running a 'real' charm class, living in some
+    # /src/charm.py, and the metadata files are 'real' metadata files.
+    is_autoloaded: bool = False
+
+    @staticmethod
+    def _load_metadata_legacy(charm_root: Path):
+        """Load metadata from charm projects created with Charmcraft < 2.5."""
+        # back in the days, we used to have separate metadata.yaml, config.yaml and actions.yaml
+        # files for charm metadata.
+        metadata_path = charm_root / "metadata.yaml"
+        meta: Dict[str, Any] = (
+            yaml.safe_load(metadata_path.open()) if metadata_path.exists() else {}
+        )
+
+        config_path = charm_root / "config.yaml"
+        config = yaml.safe_load(config_path.open()) if config_path.exists() else None
+
+        actions_path = charm_root / "actions.yaml"
+        actions = yaml.safe_load(actions_path.open()) if actions_path.exists() else None
+        return meta, config, actions
+
+    @staticmethod
+    def _load_metadata(charm_root: Path):
+        """Load metadata from charm projects created with Charmcraft >= 2.5."""
+        metadata_path = charm_root / "charmcraft.yaml"
+        meta: Dict[str, Any] = (
+            yaml.safe_load(metadata_path.open()) if metadata_path.exists() else {}
+        )
+        if not _is_valid_charmcraft_25_metadata(meta):
+            meta = {}
+        config = meta.pop("config", None)
+        actions = meta.pop("actions", None)
+        return meta, config, actions
+
+    @staticmethod
+    def autoload(charm_type: Type[CharmBase]) -> "_CharmSpec[CharmType]":
+        """Construct a ``_CharmSpec`` object by looking up the metadata from the charm's repo root.
+
+        Will attempt to load the metadata off the ``charmcraft.yaml`` file
+        """
+        charm_source_path = Path(inspect.getfile(charm_type))
+        charm_root = charm_source_path.parent.parent
+
+        # attempt to load metadata from unified charmcraft.yaml
+        meta, config, actions = _CharmSpec._load_metadata(charm_root)
+
+        if not meta:
+            # try to load using legacy metadata.yaml/actions.yaml/config.yaml files
+            meta, config, actions = _CharmSpec._load_metadata_legacy(charm_root)
+
+        if not meta:
+            # still no metadata? bug out
+            raise MetadataNotFoundError(
+                f"invalid charm root {charm_root!r}; "
+                f"expected to contain at least a `charmcraft.yaml` file "
+                f"(or a `metadata.yaml` file if it's an old charm).",
+            )
+
+        return _CharmSpec(
+            charm_type=charm_type,
+            meta=meta,
+            actions=actions,
+            config=config,
+            is_autoloaded=True,
+        )
+
+    def get_all_relations(self) -> List[Tuple[str, Dict[str, str]]]:
+        """A list of all relation endpoints defined in the metadata."""
+        return list(
+            chain(
+                self.meta.get("requires", {}).items(),
+                self.meta.get("provides", {}).items(),
+                self.meta.get("peers", {}).items(),
+            ),
+        )
+
+
+@dataclasses.dataclass(frozen=True)
+class DeferredEvent:
+    """An event that has been deferred to run prior to the next Juju event.
+
+    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
+    observer: str
+
+    # needs to be marshal.dumps-able.
+    snapshot_data: Dict[Any, Any] = 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]
+
+
+class _EventType(str, Enum):
+    framework = "framework"
+    builtin = "builtin"
+    relation = "relation"
+    action = "action"
+    secret = "secret"
+    storage = "storage"
+    workload = "workload"
+    custom = "custom"
+
+
+class _EventPath(str):
+    if TYPE_CHECKING:  # pragma: no cover
+        name: str
+        owner_path: List[str]
+        suffix: str
+        prefix: str
+        is_custom: bool
+        type: _EventType
+
+    def __new__(cls, string: str):
+        string = _normalise_name(string)
+        instance = super().__new__(cls, string)
+
+        instance.name = name = string.split(".")[-1]
+        instance.owner_path = string.split(".")[:-1] or ["on"]
+
+        instance.suffix, instance.type = suffix, _ = _EventPath._get_suffix_and_type(
+            name,
+        )
+        if suffix:
+            instance.prefix, _ = string.rsplit(suffix)
+        else:
+            instance.prefix = string
+
+        instance.is_custom = suffix == ""
+        return instance
+
+    @staticmethod
+    def _get_suffix_and_type(s: str) -> Tuple[str, _EventType]:
+        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 in _SECRET_EVENTS:
+            return s, _EventType.secret
+
+        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:
+            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.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:  # type: ignore
+    """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,
+    and for more complex events, an ``event`` property will be available on the
+    related object (e.g. ``relation.joined_event``).
+    """
+
+    path: str
+    args: Tuple[Any, ...] = ()
+    kwargs: Dict[str, Any] = dataclasses.field(default_factory=dict)
+
+    storage: Optional["Storage"] = None
+    """If this is a storage event, the storage it refers to."""
+    relation: Optional["RelationBase"] = None
+    """If this is a relation event, the relation it refers to."""
+    relation_remote_unit_id: Optional[int] = None
+    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."""
+
+    check_info: Optional[CheckInfo] = None
+    """If this is a Pebble check event, the check info it provides."""
+
+    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 __post_init__(self):
+        path = _EventPath(self.path)
+        # bypass frozen dataclass
+        object.__setattr__(self, "path", path)
+
+    @property
+    def _path(self) -> _EventPath:
+        # we converted it in __post_init__, but the type checker doesn't know about that
+        return cast(_EventPath, self.path)
+
+    @property
+    def name(self) -> str:
+        """Full event name.
+
+        Consists of a 'prefix' and a 'suffix'. The suffix denotes the type of the event, the
+        prefix the name of the entity the event is about.
+
+        "foo-relation-changed":
+         - "foo"=prefix (name of a relation),
+         - "-relation-changed"=suffix (relation event)
+        """
+        return self._path.name
+
+    @property
+    def owner_path(self) -> List[str]:
+        """Path to the ObjectEvents instance owning this event.
+
+        If this event is defined on the toplevel charm class, it should be ['on'].
+        """
+        return self._path.owner_path
+
+    @property
+    def _is_relation_event(self) -> bool:
+        """Whether the event name indicates that this is a relation event."""
+        return self._path.type is _EventType.relation
+
+    @property
+    def _is_action_event(self) -> bool:
+        """Whether the event name indicates that this is a relation event."""
+        return self._path.type is _EventType.action
+
+    @property
+    def _is_secret_event(self) -> bool:
+        """Whether the event name indicates that this is a secret event."""
+        return self._path.type is _EventType.secret
+
+    @property
+    def _is_storage_event(self) -> bool:
+        """Whether the event name indicates that this is a storage event."""
+        return self._path.type is _EventType.storage
+
+    @property
+    def _is_workload_event(self) -> bool:
+        """Whether the event name indicates that this is a workload event."""
+        return self._path.type is _EventType.workload
+
+    # this method is private because _CharmSpec is not quite user-facing; also,
+    # the user should know.
+    def _is_builtin_event(self, charm_spec: "_CharmSpec[CharmType]") -> bool:
+        """Determine whether the event is a custom-defined one or a builtin one."""
+        event_name = self.name
+
+        # simple case: this is an event type owned by our charm base.on
+        if hasattr(charm_spec.charm_type.on, event_name):
+            return hasattr(CharmEvents, event_name)
+
+        # this could be an event defined on some other Object, e.g. a charm lib.
+        # We don't support (yet) directly emitting those, but they COULD have names that conflict
+        # with events owned by the base charm. E.g. if the charm has a `foo` relation, the charm
+        # will get a  charm.on.foo_relation_created. Your charm lib is free to define its own
+        # `foo_relation_created`  custom event, because its handle will be
+        # `charm.lib.on.foo_relation_created` and therefore be  unique and the Framework is happy.
+        # However, our Event data structure ATM has no knowledge of which Object/Handle it is
+        # owned by. So the only thing we can do right now is: check whether the event name,
+        # 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 deferred(self, handler: Callable[..., Any], event_id: int = 1) -> DeferredEvent:
+        """Construct a DeferredEvent from this Event."""
+        handler_repr = repr(handler)
+        handler_re = re.compile(r"<function (.*) at .*>")
+        match = handler_re.match(handler_repr)
+        if not match:
+            raise ValueError(
+                f"cannot construct DeferredEvent from {handler}; please create one manually.",
+            )
+        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: Dict[str, Any] = {}
+
+        # 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:
+            # 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
+                else:
+                    notice_type = str(self.notice.type)
+                snapshot_data.update(
+                    {
+                        "notice_id": self.notice.id,
+                        "notice_key": self.notice.key,
+                        "notice_type": notice_type,
+                    },
+                )
+            elif self.check_info:
+                snapshot_data["check_name"] = self.check_info.name
+
+        elif self._is_relation_event:
+            # 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"
+            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)),
+                },
+            )
+
+        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,
+            owner_name,
+            handler_name,
+            snapshot_data=snapshot_data,
+        )
+
+
+_next_action_id_counter = 1
+
+
+def _next_action_id(*, update: bool = 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:
+        _next_action_id_counter += 1
+    # Juju currently uses numbers for the ID, but in the past used UUIDs, so
+    # we need these to be strings.
+    return str(cur)
+
+
+@dataclasses.dataclass(frozen=True)
+class _Action(_max_posargs(1)):
+    """A ``juju run`` command.
+
+    Used to simulate ``juju run``, passing in any parameters. For example::
+
+        def test_backup_action():
+            ctx = scenario.Context(MyCharm)
+            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: Mapping[str, "AnyJson"] = dataclasses.field(default_factory=dict)
+    """Parameter values passed to the action."""
+
+    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."""
diff --git a/testing/tests/__init__.py b/testing/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/testing/tests/helpers.py b/testing/tests/helpers.py
new file mode 100644
index 000000000..49ffaeff9
--- /dev/null
+++ b/testing/tests/helpers.py
@@ -0,0 +1,86 @@
+import dataclasses
+import logging
+from pathlib import Path
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    Callable,
+    Dict,
+    List,
+    Optional,
+    Type,
+    TypeVar,
+    Union,
+)
+
+import jsonpatch
+
+from scenario.context import _DEFAULT_JUJU_VERSION, Context
+
+if TYPE_CHECKING:  # pragma: no cover
+    from scenario.state import CharmType, State, _Event
+
+    _CT = TypeVar("_CT", bound=Type[CharmType])
+
+logger = logging.getLogger()
+
+
+def trigger(
+    state: "State",
+    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[Union[str, Path]] = None,
+    juju_version: str = _DEFAULT_JUJU_VERSION,
+) -> "State":
+    ctx = Context(
+        charm_type=charm_type,
+        meta=meta,
+        actions=actions,
+        config=config,
+        charm_root=charm_root,
+        juju_version=juju_version,
+    )
+    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/testing/tests/readme-conftest.py b/testing/tests/readme-conftest.py
new file mode 100644
index 000000000..d125b930f
--- /dev/null
+++ b/testing/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/testing/tests/test_charm_spec_autoload.py b/testing/tests/test_charm_spec_autoload.py
new file mode 100644
index 000000000..a5b24d1ee
--- /dev/null
+++ b/testing/tests/test_charm_spec_autoload.py
@@ -0,0 +1,164 @@
+import importlib
+import sys
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Type
+
+import pytest
+import yaml
+
+from scenario import Context, Relation, State
+from scenario.context import ContextSetupError
+from scenario.state import CharmType, MetadataNotFoundError, _CharmSpec
+
+CHARM = """
+from ops import CharmBase
+
+class MyCharm(CharmBase): pass
+"""
+
+
+@contextmanager
+def import_name(name: str, source: Path) -> Type[CharmType]:
+    pkg_path = str(source.parent)
+    sys.path.append(pkg_path)
+    charm = importlib.import_module("mycharm")
+    obj = getattr(charm, name)
+    sys.path.remove(pkg_path)
+    yield obj
+    del sys.modules["mycharm"]
+
+
+@contextmanager
+def create_tempcharm(
+    root: Path,
+    charm: str = CHARM,
+    meta=None,
+    actions=None,
+    config=None,
+    name: str = "MyCharm",
+    legacy: bool = False,
+):
+    src = root / "src"
+    src.mkdir(parents=True)
+    charmpy = src / "mycharm.py"
+    charmpy.write_text(charm)
+
+    # we add a charmcraft.yaml file to verify that _CharmSpec._load_metadata
+    # is able to tell that the presence of charmcraft.yaml ALONE is not enough
+    # to make this a valid charm
+    charmcraft = {"builds-on": "literally anywhere! isn't that awesome?"}
+    (root / "charmcraft.yaml").write_text(yaml.safe_dump(charmcraft))
+
+    if legacy:
+        if meta is not None:
+            (root / "metadata.yaml").write_text(yaml.safe_dump(meta))
+
+        if actions is not None:
+            (root / "actions.yaml").write_text(yaml.safe_dump(actions))
+
+        if config is not None:
+            (root / "config.yaml").write_text(yaml.safe_dump(config))
+    else:
+        unified_meta = meta or {}
+
+        if actions:
+            unified_meta["actions"] = actions
+        if config:
+            unified_meta["config"] = config
+        if unified_meta:
+            (root / "charmcraft.yaml").write_text(yaml.safe_dump(unified_meta))
+
+    with import_name(name, charmpy) as charm:
+        yield charm
+
+
+def test_autoload_no_meta_fails(tmp_path):
+    with create_tempcharm(tmp_path) as charm:
+        with pytest.raises(MetadataNotFoundError):
+            _CharmSpec.autoload(charm)
+
+
+def test_autoload_no_type_fails(tmp_path):
+    with create_tempcharm(tmp_path, meta={"name": "foo"}) as charm:
+        with pytest.raises(MetadataNotFoundError):
+            _CharmSpec.autoload(charm)
+
+
+def test_autoload_legacy_no_meta_fails(tmp_path):
+    with create_tempcharm(tmp_path, legacy=True) as charm:
+        with pytest.raises(MetadataNotFoundError):
+            _CharmSpec.autoload(charm)
+
+
+def test_autoload_legacy_no_type_passes(tmp_path):
+    with create_tempcharm(tmp_path, legacy=True, meta={"name": "foo"}) as charm:
+        _CharmSpec.autoload(charm)
+
+
+@pytest.mark.parametrize("config_type", ("charm", "foo"))
+def test_autoload_legacy_type_passes(tmp_path, config_type):
+    with create_tempcharm(
+        tmp_path, legacy=True, meta={"type": config_type, "name": "foo"}
+    ) as charm:
+        _CharmSpec.autoload(charm)
+
+
+@pytest.mark.parametrize("legacy", (True, False))
+def test_meta_autoload(tmp_path, legacy):
+    with create_tempcharm(
+        tmp_path,
+        legacy=legacy,
+        meta={"type": "charm", "name": "foo", "summary": "foo", "description": "foo"},
+    ) as charm:
+        ctx = Context(charm)
+        ctx.run(ctx.on.start(), State())
+
+
+@pytest.mark.parametrize("legacy", (True, False))
+def test_no_meta_raises(tmp_path, legacy):
+    with create_tempcharm(
+        tmp_path,
+        legacy=legacy,
+    ) as charm:
+        # metadata not found:
+        with pytest.raises(ContextSetupError):
+            Context(charm)
+
+
+@pytest.mark.parametrize("legacy", (True, False))
+def test_relations_ok(tmp_path, legacy):
+    with create_tempcharm(
+        tmp_path,
+        legacy=legacy,
+        meta={
+            "type": "charm",
+            "summary": "foo",
+            "description": "foo",
+            "name": "josh",
+            "requires": {"cuddles": {"interface": "arms"}},
+        },
+    ) as charm:
+        # this would fail if there were no 'cuddles' relation defined in meta
+        ctx = Context(charm)
+        ctx.run(ctx.on.start(), State(relations={Relation("cuddles")}))
+
+
+@pytest.mark.parametrize("legacy", (True, False))
+def test_config_defaults(tmp_path, legacy):
+    with create_tempcharm(
+        tmp_path,
+        legacy=legacy,
+        meta={
+            "type": "charm",
+            "name": "josh",
+            "summary": "foo",
+            "description": "foo",
+        },
+        config={"options": {"foo": {"type": "bool", "default": True}}},
+    ) as charm:
+        # this would fail if there were no 'cuddles' relation defined in meta
+        ctx = Context(charm)
+        with ctx(ctx.on.start(), State()) as mgr:
+            mgr.run()
+            assert mgr.charm.config["foo"] is True
diff --git a/testing/tests/test_consistency_checker.py b/testing/tests/test_consistency_checker.py
new file mode 100644
index 000000000..8a738728a
--- /dev/null
+++ b/testing/tests/test_consistency_checker.py
@@ -0,0 +1,737 @@
+import dataclasses
+
+import pytest
+from ops.charm import CharmBase
+
+from scenario._consistency_checker import check_consistency
+from scenario.context import Context
+from scenario.errors import InconsistentScenarioError
+from scenario.state import (
+    _RELATION_EVENTS_SUFFIX,
+    CheckInfo,
+    CloudCredential,
+    CloudSpec,
+    Container,
+    Model,
+    Network,
+    Notice,
+    PeerRelation,
+    Relation,
+    Resource,
+    Secret,
+    State,
+    Storage,
+    StoredState,
+    SubordinateRelation,
+    _CharmSpec,
+    _Event,
+)
+
+
+class MyCharm(CharmBase):
+    pass
+
+
+def assert_inconsistent(
+    state: "State",
+    event: "_Event",
+    charm_spec: "_CharmSpec",
+    juju_version="3.0",
+):
+    with pytest.raises(InconsistentScenarioError):
+        check_consistency(state, event, charm_spec, juju_version)
+
+
+def assert_consistent(
+    state: "State",
+    event: "_Event",
+    charm_spec: "_CharmSpec",
+    juju_version="3.0",
+):
+    check_consistency(state, event, charm_spec, juju_version)
+
+
+def test_base():
+    state = State()
+    event = _Event("update_status")
+    spec = _CharmSpec(MyCharm, {})
+    assert_consistent(state, event, spec)
+
+
+def test_workload_event_without_container():
+    assert_inconsistent(
+        State(),
+        _Event("foo-pebble-ready", container=Container("foo")),
+        _CharmSpec(MyCharm, {}),
+    )
+    assert_consistent(
+        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")),
+        _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),
+        _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-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"),
+        _CharmSpec(MyCharm, {"containers": {"baz": {}}}),
+    )
+    assert_consistent(
+        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"),
+        _CharmSpec(MyCharm, {}),
+    )
+    assert_consistent(
+        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": {}}}),
+    )
+
+
+def test_evt_bad_container_name():
+    assert_inconsistent(
+        State(),
+        _Event("foo-pebble-ready", container=Container("bar")),
+        _CharmSpec(MyCharm, {}),
+    )
+    assert_consistent(
+        State(containers={Container("bar")}),
+        _Event("bar-pebble-ready", container=Container("bar")),
+        _CharmSpec(MyCharm, {"containers": {"bar": {}}}),
+    )
+
+
+@pytest.mark.parametrize("suffix", _RELATION_EVENTS_SUFFIX)
+def test_evt_bad_relation_name(suffix):
+    assert_inconsistent(
+        State(),
+        _Event(f"foo{suffix}", relation=Relation("bar")),
+        _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "xxx"}}}),
+    )
+    relation = Relation("bar")
+    assert_consistent(
+        State(relations={relation}),
+        _Event(f"bar{suffix}", relation=relation),
+        _CharmSpec(MyCharm, {"requires": {"bar": {"interface": "xxx"}}}),
+    )
+
+
+@pytest.mark.parametrize("suffix", _RELATION_EVENTS_SUFFIX)
+def test_evt_no_relation(suffix):
+    assert_inconsistent(State(), _Event(f"foo{suffix}"), _CharmSpec(MyCharm, {}))
+    relation = Relation("bar")
+    assert_consistent(
+        State(relations={relation}),
+        _Event(f"bar{suffix}", relation=relation),
+        _CharmSpec(MyCharm, {"requires": {"bar": {"interface": "xxx"}}}),
+    )
+
+
+def test_config_key_missing_from_meta():
+    assert_inconsistent(
+        State(config={"foo": True}),
+        _Event("bar"),
+        _CharmSpec(MyCharm, {}),
+    )
+    assert_consistent(
+        State(config={"foo": True}),
+        _Event("bar"),
+        _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "boolean"}}}),
+    )
+
+
+def test_bad_config_option_type():
+    assert_inconsistent(
+        State(config={"foo": True}),
+        _Event("bar"),
+        _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "string"}}}),
+    )
+    assert_inconsistent(
+        State(config={"foo": True}),
+        _Event("bar"),
+        _CharmSpec(MyCharm, {}, config={"options": {"foo": {}}}),
+    )
+    assert_consistent(
+        State(config={"foo": True}),
+        _Event("bar"),
+        _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "boolean"}}}),
+    )
+
+
+@pytest.mark.parametrize(
+    "config_type",
+    (
+        ("string", "foo", 1),
+        ("int", 1, "1"),
+        ("float", 1.0, 1),
+        ("boolean", False, "foo"),
+    ),
+)
+def test_config_types(config_type):
+    type_name, valid_value, invalid_value = config_type
+    assert_consistent(
+        State(config={"foo": valid_value}),
+        _Event("bar"),
+        _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": type_name}}}),
+    )
+    assert_inconsistent(
+        State(config={"foo": invalid_value}),
+        _Event("bar"),
+        _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": type_name}}}),
+    )
+
+
+@pytest.mark.parametrize("juju_version", ("3.4", "3.5", "4.0"))
+def test_config_secret(juju_version):
+    assert_consistent(
+        State(config={"foo": "secret:co28kefmp25c77utl3n0"}),
+        _Event("bar"),
+        _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}),
+        juju_version=juju_version,
+    )
+    assert_inconsistent(
+        State(config={"foo": 1}),
+        _Event("bar"),
+        _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}),
+    )
+    assert_inconsistent(
+        State(config={"foo": "co28kefmp25c77utl3n0"}),
+        _Event("bar"),
+        _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}),
+    )
+    assert_inconsistent(
+        State(config={"foo": "secret:secret"}),
+        _Event("bar"),
+        _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}),
+    )
+    assert_inconsistent(
+        State(config={"foo": "secret:co28kefmp25c77utl3n!"}),
+        _Event("bar"),
+        _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}),
+    )
+
+
+@pytest.mark.parametrize("juju_version", ("2.9", "3.3"))
+def test_config_secret_old_juju(juju_version):
+    assert_inconsistent(
+        State(config={"foo": "secret:co28kefmp25c77utl3n0"}),
+        _Event("bar"),
+        _CharmSpec(MyCharm, {}, config={"options": {"foo": {"type": "secret"}}}),
+        juju_version=juju_version,
+    )
+
+
+@pytest.mark.skip(
+    "The right exception is raised but pytest.raises doesn't catch it - figure this out!"
+)
+@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({"a": "b"})
+    assert_inconsistent(
+        State(secrets={secret}),
+        _Event("bar"),
+        _CharmSpec(MyCharm, {}),
+        bad_v,
+    )
+    assert_inconsistent(
+        State(secrets={secret}),
+        _Event("secret_changed", secret=secret),
+        _CharmSpec(MyCharm, {}),
+        bad_v,
+    )
+
+    assert_inconsistent(
+        State(),
+        _Event("secret_changed", secret=secret),
+        _CharmSpec(MyCharm, {}),
+        bad_v,
+    )
+
+
+@pytest.mark.parametrize("good_v", ("3.0", "3.1", "3", "3.33", "4", "100"))
+def test_secrets_jujuv_good(good_v):
+    assert_consistent(
+        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"),
+        _CharmSpec(MyCharm, {"peers": {"foo": {"interface": "bar"}}}),
+    )
+    assert_consistent(
+        State(relations={PeerRelation("foo")}),
+        _Event("bar"),
+        _CharmSpec(MyCharm, {"peers": {"foo": {"interface": "bar"}}}),
+    )
+
+
+def test_duplicate_endpoints_inconsistent():
+    assert_inconsistent(
+        State(),
+        _Event("bar"),
+        _CharmSpec(
+            MyCharm,
+            {
+                "requires": {"foo": {"interface": "bar"}},
+                "provides": {"foo": {"interface": "baz"}},
+            },
+        ),
+    )
+
+
+def test_sub_relation_consistency():
+    assert_inconsistent(
+        State(relations={Relation("foo")}),
+        _Event("bar"),
+        _CharmSpec(
+            MyCharm,
+            {"requires": {"foo": {"interface": "bar", "scope": "container"}}},
+        ),
+    )
+
+    assert_consistent(
+        State(relations={SubordinateRelation("foo")}),
+        _Event("bar"),
+        _CharmSpec(
+            MyCharm,
+            {"requires": {"foo": {"interface": "bar", "scope": "container"}}},
+        ),
+    )
+
+
+def test_relation_sub_inconsistent():
+    assert_inconsistent(
+        State(relations={SubordinateRelation("foo")}),
+        _Event("bar"),
+        _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "bar"}}}),
+    )
+
+
+def test_relation_not_in_state():
+    relation = Relation("foo")
+    assert_inconsistent(
+        State(),
+        _Event("foo_relation_changed", relation=relation),
+        _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "bar"}}}),
+    )
+    assert_consistent(
+        State(relations={relation}),
+        _Event("foo_relation_changed", relation=relation),
+        _CharmSpec(MyCharm, {"requires": {"foo": {"interface": "bar"}}}),
+    )
+
+
+def test_action_not_in_meta_inconsistent():
+    ctx = Context(MyCharm, meta={"name": "foo"}, actions={"foo": {}})
+    assert_inconsistent(
+        State(),
+        ctx.on.action("foo", params={"bar": "baz"}),
+        _CharmSpec(MyCharm, meta={}, actions={}),
+    )
+
+
+def test_action_meta_type_inconsistent():
+    ctx = Context(MyCharm, meta={"name": "foo"}, actions={"foo": {}})
+    assert_inconsistent(
+        State(),
+        ctx.on.action("foo", params={"bar": "baz"}),
+        _CharmSpec(
+            MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": "zabazaba"}}}}
+        ),
+    )
+
+    assert_inconsistent(
+        State(),
+        ctx.on.action("foo", params={"bar": "baz"}),
+        _CharmSpec(MyCharm, meta={}, actions={"foo": {"params": {"bar": {}}}}),
+    )
+
+
+def test_action_name():
+    ctx = Context(MyCharm, meta={"name": "foo"}, actions={"foo": {}})
+
+    assert_consistent(
+        State(),
+        ctx.on.action("foo", params={"bar": "baz"}),
+        _CharmSpec(
+            MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": "string"}}}}
+        ),
+    )
+    assert_inconsistent(
+        State(),
+        _Event("box_action", action=ctx.on.action("foo", params={"bar": "baz"})),
+        _CharmSpec(MyCharm, meta={}, actions={"foo": {}}),
+    )
+
+
+_ACTION_TYPE_CHECKS = [
+    ("string", "baz", None),
+    ("boolean", True, "baz"),
+    ("integer", 42, 1.5),
+    ("number", 28.8, "baz"),
+    ("array", ["a", "b", "c"], 1.5),  # A string is an acceptable array.
+    ("object", {"k": "v"}, "baz"),
+]
+
+
+@pytest.mark.parametrize("ptype,good,bad", _ACTION_TYPE_CHECKS)
+def test_action_params_type(ptype, good, bad):
+    ctx = Context(MyCharm, meta={"name": "foo"}, actions={"foo": {}})
+    assert_consistent(
+        State(),
+        ctx.on.action("foo", params={"bar": good}),
+        _CharmSpec(
+            MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": ptype}}}}
+        ),
+    )
+    if bad is not None:
+        assert_inconsistent(
+            State(),
+            ctx.on.action("foo", params={"bar": bad}),
+            _CharmSpec(
+                MyCharm, meta={}, actions={"foo": {"params": {"bar": {"type": ptype}}}}
+            ),
+        )
+
+
+def test_duplicate_relation_ids():
+    assert_inconsistent(
+        State(relations={Relation("foo", id=1), Relation("bar", id=1)}),
+        _Event("start"),
+        _CharmSpec(
+            MyCharm,
+            meta={
+                "requires": {"foo": {"interface": "foo"}, "bar": {"interface": "bar"}}
+            },
+        ),
+    )
+
+
+def test_relation_without_endpoint():
+    assert_inconsistent(
+        State(relations={Relation("foo", id=1), Relation("bar", id=1)}),
+        _Event("start"),
+        _CharmSpec(MyCharm, meta={"name": "charlemagne"}),
+    )
+
+    assert_consistent(
+        State(relations={Relation("foo", id=1), Relation("bar", id=2)}),
+        _Event("start"),
+        _CharmSpec(
+            MyCharm,
+            meta={
+                "requires": {"foo": {"interface": "foo"}, "bar": {"interface": "bar"}}
+            },
+        ),
+    )
+
+
+def test_storage_event():
+    storage = Storage("foo")
+    assert_inconsistent(
+        State(storages={storage}),
+        _Event("foo-storage-attached"),
+        _CharmSpec(MyCharm, meta={"name": "rupert"}),
+    )
+    assert_inconsistent(
+        State(storages={storage}),
+        _Event("foo-storage-attached"),
+        _CharmSpec(
+            MyCharm, meta={"name": "rupert", "storage": {"foo": {"type": "filesystem"}}}
+        ),
+    )
+
+
+def test_storage_states():
+    storage1 = Storage("foo", index=1)
+    storage2 = Storage("foo", index=1)
+
+    assert_inconsistent(
+        State(storages={storage1, storage2}),
+        _Event("start"),
+        _CharmSpec(MyCharm, meta={"name": "everett"}),
+    )
+    assert_consistent(
+        State(storages={storage1, dataclasses.replace(storage2, index=2)}),
+        _Event("start"),
+        _CharmSpec(
+            MyCharm, meta={"name": "frank", "storage": {"foo": {"type": "filesystem"}}}
+        ),
+    )
+    assert_consistent(
+        State(storages={storage1, dataclasses.replace(storage2, name="marx")}),
+        _Event("start"),
+        _CharmSpec(
+            MyCharm,
+            meta={
+                "name": "engels",
+                "storage": {
+                    "foo": {"type": "filesystem"},
+                    "marx": {"type": "filesystem"},
+                },
+            },
+        ),
+    )
+
+
+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={Resource(name="foo", path="/foo/bar.yaml")}),
+        _Event("start"),
+        _CharmSpec(
+            MyCharm,
+            meta={"name": "yamlman", "resources": {"foo": {"type": "oci-image"}}},
+        ),
+    )
+
+    # no resources in state but some in meta: OK. Not realistic wrt juju but fine for testing
+    assert_consistent(
+        State(),
+        _Event("start"),
+        _CharmSpec(
+            MyCharm,
+            meta={"name": "yamlman", "resources": {"foo": {"type": "oci-image"}}},
+        ),
+    )
+
+    # resource not defined in meta
+    assert_inconsistent(
+        State(resources={Resource(name="bar", path="/foo/bar.yaml")}),
+        _Event("start"),
+        _CharmSpec(
+            MyCharm,
+            meta={"name": "yamlman", "resources": {"foo": {"type": "oci-image"}}},
+        ),
+    )
+
+    assert_inconsistent(
+        State(resources={Resource(name="bar", path="/foo/bar.yaml")}),
+        _Event("start"),
+        _CharmSpec(
+            MyCharm,
+            meta={"name": "yamlman"},
+        ),
+    )
+
+
+def test_networks_consistency():
+    assert_inconsistent(
+        State(networks={Network("foo")}),
+        _Event("start"),
+        _CharmSpec(
+            MyCharm,
+            meta={"name": "wonky"},
+        ),
+    )
+
+    assert_inconsistent(
+        State(networks={Network("foo")}),
+        _Event("start"),
+        _CharmSpec(
+            MyCharm,
+            meta={
+                "name": "pinky",
+                "extra-bindings": {"foo": {}},
+                "requires": {"foo": {"interface": "bar"}},
+            },
+        ),
+    )
+
+    assert_consistent(
+        State(networks={Network("foo")}),
+        _Event("start"),
+        _CharmSpec(
+            MyCharm,
+            meta={
+                "name": "pinky",
+                "extra-bindings": {"foo": {}},
+                "requires": {"bar": {"interface": "bar"}},
+            },
+        ),
+    )
+
+
+def test_cloudspec_consistency():
+    cloud_spec = CloudSpec(
+        name="localhost",
+        type="lxd",
+        endpoint="https://127.0.0.1:8443",
+        credential=CloudCredential(
+            auth_type="clientcertificate",
+            attributes={
+                "client-cert": "foo",
+                "client-key": "bar",
+                "server-cert": "baz",
+            },
+        ),
+    )
+
+    assert_consistent(
+        State(model=Model(name="lxd-model", type="lxd", cloud_spec=cloud_spec)),
+        _Event("start"),
+        _CharmSpec(
+            MyCharm,
+            meta={"name": "MyVMCharm"},
+        ),
+    )
+
+    assert_inconsistent(
+        State(model=Model(name="k8s-model", type="kubernetes", cloud_spec=cloud_spec)),
+        _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/testing/tests/test_context.py b/testing/tests/test_context.py
new file mode 100644
index 000000000..0d55ca9e0
--- /dev/null
+++ b/testing/tests/test_context.py
@@ -0,0 +1,70 @@
+from unittest.mock import patch
+
+import pytest
+from ops import CharmBase
+
+from scenario import Context, State
+from scenario.state import _Event, _next_action_id
+
+
+class MyCharm(CharmBase):
+    pass
+
+
+def test_run():
+    ctx = Context(MyCharm, meta={"name": "foo"})
+    state = State()
+
+    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.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 e.name == "start"
+    assert s is state
+
+
+def test_run_action():
+    ctx = Context(MyCharm, meta={"name": "foo"})
+    state = State()
+    expected_id = _next_action_id(update=False)
+
+    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
+    e = p.call_args.kwargs["event"]
+    s = p.call_args.kwargs["state"]
+
+    assert isinstance(e, _Event)
+    assert e.name == "do_foo_action"
+    assert s is state
+    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):
+    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/testing/tests/test_context_on.py b/testing/tests/test_context_on.py
new file mode 100644
index 000000000..32759fd49
--- /dev/null
+++ b/testing/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/testing/tests/test_e2e/__init__.py b/testing/tests/test_e2e/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/testing/tests/test_e2e/test_actions.py b/testing/tests/test_e2e/test_actions.py
new file mode 100644
index 000000000..791297d41
--- /dev/null
+++ b/testing/tests/test_e2e/test_actions.py
@@ -0,0 +1,208 @@
+import pytest
+from ops import __version__ as ops_version
+from ops.charm import ActionEvent, CharmBase
+from ops.framework import Framework
+from ops._private.harness import ActionFailed
+
+from scenario import Context
+from scenario.state import State, _Action, _next_action_id
+
+
+@pytest.fixture(scope="function")
+def mycharm():
+    class MyCharm(CharmBase):
+        _evt_handler = None
+
+        def __init__(self, framework: Framework):
+            super().__init__(framework)
+            for evt in self.on.events().values():
+                self.framework.observe(evt, self._on_event)
+
+        def _on_event(self, event):
+            if handler := self._evt_handler:
+                handler(event)
+
+    return MyCharm
+
+
+@pytest.mark.parametrize("baz_value", (True, False))
+def test_action_event(mycharm, baz_value):
+    ctx = Context(
+        mycharm,
+        meta={"name": "foo"},
+        actions={
+            "foo": {"params": {"bar": {"type": "number"}, "baz": {"type": "boolean"}}}
+        },
+    )
+    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_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}))
+def test_action_event_results_invalid(mycharm, res_value):
+    def handle_evt(charm: CharmBase, evt: ActionEvent):
+        with pytest.raises((TypeError, AttributeError)):
+            evt.set_results(res_value)
+
+    mycharm._evt_handler = handle_evt
+
+    ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}})
+    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(_: CharmBase, evt):
+        if not isinstance(evt, ActionEvent):
+            return
+        evt.set_results(res_value)
+        evt.log("foo")
+        evt.log("bar")
+
+    mycharm._evt_handler = handle_evt
+
+    ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}})
+
+    ctx.run(ctx.on.action("foo"), State())
+
+    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(_: CharmBase, evt: ActionEvent):
+        if not isinstance(evt, ActionEvent):
+            return
+
+        evt.set_results({"my-res": res_value})
+        evt.log("log1")
+        evt.log("log2")
+        evt.fail("failed becozz")
+
+    mycharm._evt_handler = handle_evt
+
+    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 == "failed becozz"
+    assert ctx.action_results == {"my-res": res_value}
+    assert ctx.action_logs == ["log1", "log2"]
+
+
+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):
+    major, minor = (int(v) for v in ops_version.split(".")[:2])
+    if major < wanted_major:
+        return True
+    if major == wanted_major and minor < wanted_minor:
+        return True
+    return False
+
+
+@pytest.mark.skipif(
+    _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(_: CharmBase, evt: ActionEvent):
+        if not isinstance(evt, ActionEvent):
+            return
+        assert isinstance(evt.id, str) and evt.id != ""
+
+    mycharm._evt_handler = handle_evt
+
+    ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}})
+    ctx.run(ctx.on.action("foo"), State())
+
+
+@pytest.mark.skipif(
+    _ops_less_than(2, 11), reason="ops 2.10 and earlier don't have ActionEvent.id"
+)
+def test_action_event_has_override_id(mycharm):
+    uuid = "0ddba11-cafe-ba1d-5a1e-dec0debad"
+
+    def handle_evt(charm: CharmBase, evt: ActionEvent):
+        if not isinstance(evt, ActionEvent):
+            return
+        assert evt.id == uuid
+
+    mycharm._evt_handler = handle_evt
+
+    ctx = Context(mycharm, meta={"name": "foo"}, actions={"foo": {}})
+    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/testing/tests/test_e2e/test_cloud_spec.py b/testing/tests/test_e2e/test_cloud_spec.py
new file mode 100644
index 000000000..c3a092482
--- /dev/null
+++ b/testing/tests/test_e2e/test_cloud_spec.py
@@ -0,0 +1,70 @@
+import ops
+import pytest
+
+import scenario
+
+
+class MyCharm(ops.CharmBase):
+    def __init__(self, framework: ops.Framework):
+        super().__init__(framework)
+        for evt in self.on.events().values():
+            self.framework.observe(evt, self._on_event)
+
+    def _on_event(self, event):
+        pass
+
+
+def test_get_cloud_spec():
+    scenario_cloud_spec = scenario.CloudSpec(
+        type="lxd",
+        name="localhost",
+        endpoint="https://127.0.0.1:8443",
+        credential=scenario.CloudCredential(
+            auth_type="clientcertificate",
+            attributes={
+                "client-cert": "foo",
+                "client-key": "bar",
+                "server-cert": "baz",
+            },
+        ),
+    )
+    expected_cloud_spec = ops.CloudSpec(
+        type="lxd",
+        name="localhost",
+        endpoint="https://127.0.0.1:8443",
+        credential=ops.CloudCredential(
+            auth_type="clientcertificate",
+            attributes={
+                "client-cert": "foo",
+                "client-key": "bar",
+                "server-cert": "baz",
+            },
+        ),
+    )
+    ctx = scenario.Context(MyCharm, meta={"name": "foo"}, app_trusted=True)
+    state = scenario.State(
+        model=scenario.Model(
+            name="lxd-model", type="lxd", cloud_spec=scenario_cloud_spec
+        ),
+    )
+    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(ctx.on.start(), state) as mgr:
+        with pytest.raises(ops.ModelError):
+            mgr.charm.model.get_cloud_spec()
+
+
+def test_get_cloud_spec_untrusted():
+    cloud_spec = ops.CloudSpec(type="lxd", name="localhost")
+    ctx = scenario.Context(MyCharm, meta={"name": "foo"})
+    state = scenario.State(
+        model=scenario.Model(name="lxd-model", type="lxd", cloud_spec=cloud_spec),
+    )
+    with ctx(ctx.on.start(), state) as mgr:
+        with pytest.raises(ops.ModelError):
+            mgr.charm.model.get_cloud_spec()
diff --git a/testing/tests/test_e2e/test_config.py b/testing/tests/test_e2e/test_config.py
new file mode 100644
index 000000000..073410977
--- /dev/null
+++ b/testing/tests/test_e2e/test_config.py
@@ -0,0 +1,101 @@
+import pytest
+from ops.charm import CharmBase
+from ops.framework import Framework
+
+from scenario.state import State
+from ..helpers import trigger
+
+
+@pytest.fixture(scope="function")
+def mycharm():
+    class MyCharm(CharmBase):
+        def __init__(self, framework: Framework):
+            super().__init__(framework)
+            for evt in self.on.events().values():
+                self.framework.observe(evt, self._on_event)
+
+        def _on_event(self, event):
+            pass
+
+    return MyCharm
+
+
+def test_config_get(mycharm):
+    def check_cfg(charm: CharmBase):
+        assert charm.config["foo"] == "bar"
+        assert charm.config["baz"] == 1
+
+    trigger(
+        State(
+            config={"foo": "bar", "baz": 1},
+        ),
+        "update_status",
+        mycharm,
+        meta={"name": "foo"},
+        config={"options": {"foo": {"type": "string"}, "baz": {"type": "int"}}},
+        post_event=check_cfg,
+    )
+
+
+def test_config_get_default_from_meta(mycharm):
+    def check_cfg(charm: CharmBase):
+        assert charm.config["foo"] == "bar"
+        assert charm.config["baz"] == 2
+        assert charm.config["qux"] is False
+
+    trigger(
+        State(
+            config={"foo": "bar"},
+        ),
+        "update_status",
+        mycharm,
+        meta={"name": "foo"},
+        config={
+            "options": {
+                "foo": {"type": "string"},
+                "baz": {"type": "int", "default": 2},
+                "qux": {"type": "boolean", "default": False},
+            },
+        },
+        post_event=check_cfg,
+    )
+
+
+@pytest.mark.parametrize(
+    "cfg_in",
+    (
+        {"foo": "bar"},
+        {"baz": 4, "foo": "bar"},
+        {"baz": 4, "foo": "bar", "qux": True},
+    ),
+)
+def test_config_in_not_mutated(mycharm, cfg_in):
+    class MyCharm(CharmBase):
+        def __init__(self, framework: Framework):
+            super().__init__(framework)
+            for evt in self.on.events().values():
+                self.framework.observe(evt, self._on_event)
+
+        def _on_event(self, event):
+            # access the config to trigger a config-get
+            foo_cfg = self.config["foo"]  # noqa: F841
+            baz_cfg = self.config["baz"]  # noqa: F841
+            qux_cfg = self.config["qux"]  # noqa: F841
+
+    state_out = trigger(
+        State(
+            config=cfg_in,
+        ),
+        "update_status",
+        MyCharm,
+        meta={"name": "foo"},
+        config={
+            "options": {
+                "foo": {"type": "string"},
+                "baz": {"type": "int", "default": 2},
+                "qux": {"type": "boolean", "default": False},
+            },
+        },
+    )
+    # check config was not mutated by scenario
+    assert state_out.config == cfg_in
diff --git a/testing/tests/test_e2e/test_deferred.py b/testing/tests/test_e2e/test_deferred.py
new file mode 100644
index 000000000..69ce353d6
--- /dev/null
+++ b/testing/tests/test_e2e/test_deferred.py
@@ -0,0 +1,207 @@
+import pytest
+from ops.charm import (
+    CharmBase,
+    CollectStatusEvent,
+    RelationChangedEvent,
+    StartEvent,
+    UpdateStatusEvent,
+    WorkloadEvent,
+)
+from ops.framework import Framework
+
+from scenario import Context
+from scenario.state import Container, Relation, State, _Event
+from ..helpers import trigger
+
+CHARM_CALLED = 0
+
+
+@pytest.fixture(scope="function")
+def mycharm():
+    class MyCharm(CharmBase):
+        META = {
+            "name": "mycharm",
+            "requires": {"foo": {"interface": "bar"}},
+            "containers": {"foo": {"type": "oci-image"}},
+        }
+        defer_next = 0
+        captured = []
+
+        def __init__(self, framework: Framework):
+            super().__init__(framework)
+            for evt in self.on.events().values():
+                self.framework.observe(evt, self._on_event)
+
+        def _on_event(self, event):
+            self.captured.append(event)
+            if self.defer_next > 0:
+                self.defer_next -= 1
+                return event.defer()
+
+    return MyCharm
+
+
+def test_defer(mycharm):
+    mycharm.defer_next = True
+    out = trigger(State(), "start", mycharm, meta=mycharm.META)
+    assert len(out.deferred) == 1
+    assert out.deferred[0].name == "start"
+
+
+def test_deferred_evt_emitted(mycharm):
+    mycharm.defer_next = 2
+
+    out = trigger(
+        State(deferred=[_Event("update_status").deferred(handler=mycharm._on_event)]),
+        "start",
+        mycharm,
+        meta=mycharm.META,
+    )
+
+    # we deferred the first 2 events we saw: update-status, start.
+    assert len(out.deferred) == 2
+    assert out.deferred[0].name == "start"
+    assert out.deferred[1].name == "update_status"
+
+    # we saw start and update-status.
+    assert len(mycharm.captured) == 3
+    upstat, start, _ = mycharm.captured
+    assert isinstance(upstat, UpdateStatusEvent)
+    assert isinstance(start, StartEvent)
+
+
+def test_deferred_relation_event(mycharm):
+    mycharm.defer_next = 2
+
+    rel = Relation(endpoint="foo", remote_app_name="remote")
+
+    out = trigger(
+        State(
+            relations={rel},
+            deferred=[
+                _Event("foo_relation_changed", relation=rel).deferred(
+                    handler=mycharm._on_event,
+                )
+            ],
+        ),
+        "start",
+        mycharm,
+        meta=mycharm.META,
+    )
+
+    # we deferred the first 2 events we saw: relation-changed, start.
+    assert len(out.deferred) == 2
+    assert out.deferred[0].name == "foo_relation_changed"
+    assert out.deferred[1].name == "start"
+
+    # we saw start and relation-changed.
+    assert len(mycharm.captured) == 3
+    upstat, start, _ = mycharm.captured
+    assert isinstance(upstat, RelationChangedEvent)
+    assert isinstance(start, StartEvent)
+
+
+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},
+            deferred=[
+                ctx.on.relation_changed(rel, remote_unit=1).deferred(
+                    handler=mycharm._on_event
+                )
+            ],
+        ),
+        "start",
+        mycharm,
+        meta=mycharm.META,
+    )
+
+    # we deferred the first 2 events we saw: foo_relation_changed, start.
+    assert len(out.deferred) == 2
+    assert out.deferred[0].name == "foo_relation_changed"
+    assert out.deferred[0].snapshot_data == {
+        "relation_name": rel.endpoint,
+        "relation_id": rel.id,
+        "app_name": "remote",
+        "unit_name": "remote/1",
+    }
+    assert out.deferred[1].name == "start"
+
+    # we saw start and foo_relation_changed.
+    assert len(mycharm.captured) == 3
+    upstat, start, collect_status = mycharm.captured
+    assert isinstance(upstat, RelationChangedEvent)
+    assert isinstance(start, StartEvent)
+    assert isinstance(collect_status, CollectStatusEvent)
+
+
+def test_deferred_workload_event(mycharm):
+    mycharm.defer_next = 2
+
+    ctr = Container("foo")
+
+    out = trigger(
+        State(
+            containers={ctr},
+            deferred=[
+                _Event("foo_pebble_ready", container=ctr).deferred(
+                    handler=mycharm._on_event
+                )
+            ],
+        ),
+        "start",
+        mycharm,
+        meta=mycharm.META,
+    )
+
+    # we deferred the first 2 events we saw: foo_pebble_ready, start.
+    assert len(out.deferred) == 2
+    assert out.deferred[0].name == "foo_pebble_ready"
+    assert out.deferred[1].name == "start"
+
+    # we saw start and foo_pebble_ready.
+    assert len(mycharm.captured) == 3
+    upstat, start, collect_status = mycharm.captured
+    assert isinstance(upstat, WorkloadEvent)
+    assert isinstance(start, StartEvent)
+    assert isinstance(collect_status, CollectStatusEvent)
+
+
+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(ctx.on.update_status(), State())
+
+    mycharm.defer_next = 0
+    state_2 = ctx.run(ctx.on.start(), state_1)
+
+    assert [type(e).__name__ for e in ctx.emitted_events] == [
+        "UpdateStatusEvent",
+        "UpdateStatusEvent",
+        "StartEvent",
+    ]
+    assert len(state_1.deferred) == 1
+    assert not state_2.deferred
+
+
+def test_defer_reemit_relation_event(mycharm):
+    ctx = Context(mycharm, meta=mycharm.META, capture_deferred_events=True)
+
+    rel = Relation("foo")
+    mycharm.defer_next = 1
+    state_1 = ctx.run(ctx.on.relation_created(rel), State(relations={rel}))
+
+    mycharm.defer_next = 0
+    state_2 = ctx.run(ctx.on.start(), state_1)
+
+    assert [type(e).__name__ for e in ctx.emitted_events] == [
+        "RelationCreatedEvent",
+        "RelationCreatedEvent",
+        "StartEvent",
+    ]
+    assert len(state_1.deferred) == 1
+    assert not state_2.deferred
diff --git a/testing/tests/test_e2e/test_event.py b/testing/tests/test_e2e/test_event.py
new file mode 100644
index 000000000..2efc7285f
--- /dev/null
+++ b/testing/tests/test_e2e/test_event.py
@@ -0,0 +1,100 @@
+import ops
+import pytest
+from ops import CharmBase
+
+from scenario import Context
+from scenario.state import State, _CharmSpec, _Event, _EventType
+
+
+@pytest.mark.parametrize(
+    "evt, expected_type",
+    (
+        ("foo_relation_changed", _EventType.relation),
+        ("foo_relation_created", _EventType.relation),
+        ("foo_bar_baz_relation_created", _EventType.relation),
+        ("foo_storage_attached", _EventType.storage),
+        ("foo_storage_detaching", _EventType.storage),
+        ("foo_bar_baz_storage_detaching", _EventType.storage),
+        ("foo_pebble_ready", _EventType.workload),
+        ("foo_bar_baz_pebble_ready", _EventType.workload),
+        ("foo_pebble_custom_notice", _EventType.workload),
+        ("foo_bar_baz_pebble_custom_notice", _EventType.workload),
+        ("secret_remove", _EventType.secret),
+        ("pre_commit", _EventType.framework),
+        ("commit", _EventType.framework),
+        ("collect_unit_status", _EventType.framework),
+        ("collect_app_status", _EventType.framework),
+        ("foo", _EventType.custom),
+        ("kaboozle_bar_baz", _EventType.custom),
+    ),
+)
+def test_event_type(evt, expected_type):
+    event = _Event(evt)
+    assert event._path.type is expected_type
+
+    assert event._is_relation_event is (expected_type is _EventType.relation)
+    assert event._is_storage_event is (expected_type is _EventType.storage)
+    assert event._is_workload_event is (expected_type is _EventType.workload)
+    assert event._is_secret_event is (expected_type is _EventType.secret)
+    assert event._is_action_event is (expected_type is _EventType.action)
+
+    class MyCharm(CharmBase):
+        pass
+
+    spec = _CharmSpec(
+        MyCharm,
+        meta={
+            "requires": {
+                "foo": {"interface": "bar"},
+                "foo_bar_baz": {"interface": "bar"},
+            },
+            "storage": {
+                "foo": {"type": "filesystem"},
+                "foo_bar_baz": {"type": "filesystem"},
+            },
+            "containers": {"foo": {}, "foo_bar_baz": {}},
+        },
+    )
+    assert event._is_builtin_event(spec) is (expected_type is not _EventType.custom)
+
+
+def test_emitted_framework():
+    class MyCharm(CharmBase):
+        META = {"name": "joop"}
+
+    ctx = Context(MyCharm, meta=MyCharm.META, capture_framework_events=True)
+    ctx.run(ctx.on.update_status(), State())
+    assert len(ctx.emitted_events) == 4
+    assert list(map(type, ctx.emitted_events)) == [
+        ops.UpdateStatusEvent,
+        ops.CollectStatusEvent,
+        ops.PreCommitEvent,
+        ops.CommitEvent,
+    ]
+
+
+def test_emitted_deferred():
+    class MyCharm(CharmBase):
+        META = {"name": "joop"}
+
+        def _foo(self, e):
+            pass
+
+    ctx = Context(
+        MyCharm,
+        meta=MyCharm.META,
+        capture_deferred_events=True,
+        capture_framework_events=True,
+    )
+    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] == [
+        "update_status",
+        "start",
+        "collect_unit_status",
+        "pre_commit",
+        "commit",
+    ]
diff --git a/testing/tests/test_e2e/test_juju_log.py b/testing/tests/test_e2e/test_juju_log.py
new file mode 100644
index 000000000..1efd1e709
--- /dev/null
+++ b/testing/tests/test_e2e/test_juju_log.py
@@ -0,0 +1,38 @@
+import logging
+
+import pytest
+from ops.charm import CharmBase, CollectStatusEvent
+
+from scenario import Context
+from scenario.state import JujuLogLine, State
+
+logger = logging.getLogger("testing logger")
+
+
+@pytest.fixture(scope="function")
+def mycharm():
+    class MyCharm(CharmBase):
+        META = {"name": "mycharm"}
+
+        def __init__(self, framework):
+            super().__init__(framework)
+            for evt in self.on.events().values():
+                self.framework.observe(evt, self._on_event)
+
+        def _on_event(self, event):
+            if isinstance(event, CollectStatusEvent):
+                return
+            print("foo!")
+            logger.warning("bar!")
+
+    return MyCharm
+
+
+def test_juju_log(mycharm):
+    ctx = Context(mycharm, meta=mycharm.META)
+    ctx.run(ctx.on.start(), State())
+    assert ctx.juju_log[-2] == JujuLogLine(
+        level="DEBUG", message="Emitting Juju event start."
+    )
+    assert ctx.juju_log[-1] == JujuLogLine(level="WARNING", message="bar!")
+    # prints are not juju-logged.
diff --git a/testing/tests/test_e2e/test_manager.py b/testing/tests/test_e2e/test_manager.py
new file mode 100644
index 000000000..06e97286c
--- /dev/null
+++ b/testing/tests/test_e2e/test_manager.py
@@ -0,0 +1,69 @@
+import pytest
+from ops import ActiveStatus
+from ops.charm import CharmBase, CollectStatusEvent
+
+from scenario import Context, State
+from scenario.context import AlreadyEmittedError, Manager
+
+
+@pytest.fixture(scope="function")
+def mycharm():
+    class MyCharm(CharmBase):
+        META = {"name": "mycharm"}
+        ACTIONS = {"do-x": {}}
+
+        def __init__(self, framework):
+            super().__init__(framework)
+            for evt in self.on.events().values():
+                self.framework.observe(evt, self._on_event)
+
+        def _on_event(self, e):
+            if isinstance(e, CollectStatusEvent):
+                return
+
+            self.unit.status = ActiveStatus(e.handle.kind)
+
+    return MyCharm
+
+
+def test_manager(mycharm):
+    ctx = Context(mycharm, meta=mycharm.META)
+    with Manager(ctx, ctx.on.start(), State()) as manager:
+        assert isinstance(manager.charm, mycharm)
+        state_out = manager.run()
+
+    assert isinstance(state_out, State)
+
+
+def test_manager_implicit(mycharm):
+    ctx = Context(mycharm, meta=mycharm.META)
+    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
+
+
+def test_manager_reemit_fails(mycharm):
+    ctx = Context(mycharm, meta=mycharm.META)
+    with Manager(ctx, ctx.on.start(), State()) as manager:
+        manager.run()
+        with pytest.raises(AlreadyEmittedError):
+            manager.run()
+
+
+def test_context_manager(mycharm):
+    ctx = Context(mycharm, meta=mycharm.META)
+    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"
+
+
+def test_context_action_manager(mycharm):
+    ctx = Context(mycharm, meta=mycharm.META, actions=mycharm.ACTIONS)
+    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/testing/tests/test_e2e/test_network.py b/testing/tests/test_e2e/test_network.py
new file mode 100644
index 000000000..7fe786677
--- /dev/null
+++ b/testing/tests/test_e2e/test_network.py
@@ -0,0 +1,161 @@
+import pytest
+from ops import RelationNotFoundError
+from ops.charm import CharmBase
+from ops.framework import Framework
+
+from scenario import Context
+from scenario.state import (
+    Address,
+    BindAddress,
+    Network,
+    Relation,
+    State,
+    SubordinateRelation,
+)
+
+
+@pytest.fixture(scope="function")
+def mycharm():
+    class MyCharm(CharmBase):
+        _call = None
+        called = False
+
+        def __init__(self, framework: Framework):
+            super().__init__(framework)
+
+            for evt in self.on.events().values():
+                self.framework.observe(evt, self._on_event)
+
+        def _on_event(self, event):
+            if MyCharm._call:
+                MyCharm.called = True
+                MyCharm._call(self, event)
+
+    return MyCharm
+
+
+def test_ip_get(mycharm):
+    ctx = Context(
+        mycharm,
+        meta={
+            "name": "foo",
+            "requires": {
+                "metrics-endpoint": {"interface": "foo"},
+                "deadnodead": {"interface": "bar"},
+            },
+            "extra-bindings": {"foo": {}},
+        },
+    )
+
+    with ctx(
+        ctx.on.update_status(),
+        State(
+            relations=[
+                Relation(
+                    interface="foo",
+                    remote_app_name="remote",
+                    endpoint="metrics-endpoint",
+                    id=1,
+                ),
+            ],
+            networks={Network("foo", [BindAddress([Address("4.4.4.4")])])},
+        ),
+    ) as mgr:
+        # we have a network for the relation
+        rel = mgr.charm.model.get_relation("metrics-endpoint")
+        assert str(mgr.charm.model.get_binding(rel).network.bind_address) == "192.0.2.0"
+
+        # we have a network for a binding without relations on it
+        assert (
+            str(mgr.charm.model.get_binding("deadnodead").network.bind_address)
+            == "192.0.2.0"
+        )
+
+        # and an extra binding
+        assert str(mgr.charm.model.get_binding("foo").network.bind_address) == "4.4.4.4"
+
+
+def test_no_sub_binding(mycharm):
+    ctx = Context(
+        mycharm,
+        meta={
+            "name": "foo",
+            "requires": {"bar": {"interface": "foo", "scope": "container"}},
+        },
+    )
+
+    with ctx(
+        ctx.on.update_status(),
+        State(
+            relations=[
+                SubordinateRelation("bar"),
+            ]
+        ),
+    ) as mgr:
+        with pytest.raises(RelationNotFoundError):
+            # sub relations have no network
+            mgr.charm.model.get_binding("bar").network
+
+
+def test_no_relation_error(mycharm):
+    """Attempting to call get_binding on a non-existing relation -> RelationNotFoundError"""
+
+    ctx = Context(
+        mycharm,
+        meta={
+            "name": "foo",
+            "requires": {"metrics-endpoint": {"interface": "foo"}},
+            "extra-bindings": {"bar": {}},
+        },
+    )
+
+    with ctx(
+        ctx.on.update_status(),
+        State(
+            relations=[
+                Relation(
+                    interface="foo",
+                    remote_app_name="remote",
+                    endpoint="metrics-endpoint",
+                    id=1,
+                ),
+            ],
+            networks={Network("bar")},
+        ),
+    ) as mgr:
+        with pytest.raises(RelationNotFoundError):
+            mgr.charm.model.get_binding("foo").network
+
+
+def test_juju_info_network_default(mycharm):
+    ctx = Context(
+        mycharm,
+        meta={"name": "foo"},
+    )
+
+    with ctx(
+        ctx.on.update_status(),
+        State(),
+    ) as mgr:
+        # we have a network for the relation
+        assert (
+            str(mgr.charm.model.get_binding("juju-info").network.bind_address)
+            == "192.0.2.0"
+        )
+
+
+def test_explicit_juju_info_network_override(mycharm):
+    ctx = Context(
+        mycharm,
+        meta={
+            "name": "foo",
+            # this charm for whatever reason explicitly defines a juju-info endpoint
+            "requires": {"juju-info": {"interface": "juju-info"}},
+        },
+    )
+
+    with ctx(
+        ctx.on.update_status(),
+        State(),
+    ) as mgr:
+        assert mgr.charm.model.get_binding("juju-info").network.bind_address
diff --git a/testing/tests/test_e2e/test_pebble.py b/testing/tests/test_e2e/test_pebble.py
new file mode 100644
index 000000000..884fa1366
--- /dev/null
+++ b/testing/tests/test_e2e/test_pebble.py
@@ -0,0 +1,548 @@
+import dataclasses
+import datetime
+import io
+import tempfile
+from pathlib import Path
+
+import pytest
+from ops import PebbleCustomNoticeEvent, pebble
+from ops.charm import CharmBase
+from ops.framework import Framework
+from ops.pebble import ExecError, ServiceStartup, ServiceStatus
+
+from scenario import Context
+from scenario.state import CheckInfo, Container, Exec, Mount, Notice, State
+from ..helpers import jsonpatch_delta, trigger
+
+
+@pytest.fixture(scope="function")
+def charm_cls():
+    class MyCharm(CharmBase):
+        def __init__(self, framework: Framework):
+            super().__init__(framework)
+            for evt in self.on.events().values():
+                self.framework.observe(evt, self._on_event)
+
+        def _on_event(self, event):
+            pass
+
+    return MyCharm
+
+
+def test_no_containers(charm_cls):
+    def callback(self: CharmBase):
+        assert not self.unit.containers
+
+    trigger(
+        State(),
+        charm_type=charm_cls,
+        meta={"name": "foo"},
+        event="start",
+        post_event=callback,
+    )
+
+
+def test_containers_from_meta(charm_cls):
+    def callback(self: CharmBase):
+        assert self.unit.containers
+        assert self.unit.get_container("foo")
+
+    trigger(
+        State(),
+        charm_type=charm_cls,
+        meta={"name": "foo", "containers": {"foo": {}}},
+        event="start",
+        post_event=callback,
+    )
+
+
+@pytest.mark.parametrize("can_connect", (True, False))
+def test_connectivity(charm_cls, can_connect):
+    def callback(self: CharmBase):
+        assert can_connect == self.unit.get_container("foo").can_connect()
+
+    trigger(
+        State(containers={Container(name="foo", can_connect=can_connect)}),
+        charm_type=charm_cls,
+        meta={"name": "foo", "containers": {"foo": {}}},
+        event="start",
+        post_event=callback,
+    )
+
+
+def test_fs_push(charm_cls):
+    text = "lorem ipsum/n alles amat gloriae foo"
+    file = tempfile.NamedTemporaryFile()
+    pth = Path(file.name)
+    pth.write_text(text)
+
+    def callback(self: CharmBase):
+        container = self.unit.get_container("foo")
+        baz = container.pull("/bar/baz.txt")
+        assert baz.read() == text
+
+    trigger(
+        State(
+            containers={
+                Container(
+                    name="foo",
+                    can_connect=True,
+                    mounts={"bar": Mount(location="/bar/baz.txt", source=pth)},
+                )
+            }
+        ),
+        charm_type=charm_cls,
+        meta={"name": "foo", "containers": {"foo": {}}},
+        event="start",
+        post_event=callback,
+    )
+
+
+@pytest.mark.parametrize("make_dirs", (True, False))
+def test_fs_pull(charm_cls, make_dirs):
+    text = "lorem ipsum/n alles amat gloriae foo"
+
+    def callback(self: CharmBase):
+        container = self.unit.get_container("foo")
+        if make_dirs:
+            container.push("/foo/bar/baz.txt", text, make_dirs=make_dirs)
+            # check that pulling immediately 'works'
+            baz = container.pull("/foo/bar/baz.txt")
+            assert baz.read() == text
+        else:
+            with pytest.raises(pebble.PathError):
+                container.push("/foo/bar/baz.txt", text, make_dirs=make_dirs)
+
+            # check that nothing was changed
+            with pytest.raises((FileNotFoundError, pebble.PathError)):
+                container.pull("/foo/bar/baz.txt")
+
+    td = tempfile.TemporaryDirectory()
+    container = Container(
+        name="foo",
+        can_connect=True,
+        mounts={"foo": Mount(location="/foo", source=td.name)},
+    )
+    state = State(containers={container})
+
+    ctx = Context(
+        charm_type=charm_cls,
+        meta={"name": "foo", "containers": {"foo": {}}},
+    )
+    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"].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"].source) / "bar" / "baz.txt"
+        )
+
+        # but that is actually a symlink to the context's root tmp folder:
+        assert (
+            Path(ctx._tmp.name) / "containers" / "foo" / "foo" / "bar" / "baz.txt"
+        ).read_text() == text
+        assert file.read_text() == text
+
+        # shortcut for API niceness purposes:
+        file = container.get_filesystem(ctx) / "foo" / "bar" / "baz.txt"
+        assert file.read_text() == text
+
+    else:
+        # nothing has changed
+        out_purged = dataclasses.replace(out, stored_states=state.stored_states)
+        assert not jsonpatch_delta(out_purged, state)
+
+
+LS = """
+.rw-rw-r--  228 ubuntu ubuntu 18 jan 12:05 -- charmcraft.yaml    
+.rw-rw-r--  497 ubuntu ubuntu 18 jan 12:05 -- config.yaml        
+.rw-rw-r--  900 ubuntu ubuntu 18 jan 12:05 -- CONTRIBUTING.md    
+drwxrwxr-x    - ubuntu ubuntu 18 jan 12:06 -- lib                
+.rw-rw-r--  11k ubuntu ubuntu 18 jan 12:05 -- LICENSE            
+.rw-rw-r-- 1,6k ubuntu ubuntu 18 jan 12:05 -- metadata.yaml      
+.rw-rw-r--  845 ubuntu ubuntu 18 jan 12:05 -- pyproject.toml     
+.rw-rw-r--  831 ubuntu ubuntu 18 jan 12:05 -- README.md          
+.rw-rw-r--   13 ubuntu ubuntu 18 jan 12:05 -- requirements.txt   
+drwxrwxr-x    - ubuntu ubuntu 18 jan 12:05 -- src                
+drwxrwxr-x    - ubuntu ubuntu 18 jan 12:05 -- tests              
+.rw-rw-r-- 1,9k ubuntu ubuntu 18 jan 12:05 -- tox.ini            
+"""
+PS = """
+    PID TTY          TIME CMD    
+ 298238 pts/3    00:00:04 zsh    
+1992454 pts/3    00:00:00 ps     
+"""
+
+
+@pytest.mark.parametrize(
+    "cmd, out",
+    (
+        ("ls", LS),
+        ("ps", PS),
+    ),
+)
+def test_exec(charm_cls, cmd, out):
+    def callback(self: CharmBase):
+        container = self.unit.get_container("foo")
+        proc = container.exec([cmd])
+        proc.wait()
+        assert proc.stdout.read() == out
+
+    trigger(
+        State(
+            containers={
+                Container(
+                    name="foo",
+                    can_connect=True,
+                    execs={Exec([cmd], stdout=out)},
+                )
+            }
+        ),
+        charm_type=charm_cls,
+        meta={"name": "foo", "containers": {"foo": {}}},
+        event="start",
+        post_event=callback,
+    )
+
+
+@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")
+        assert foo.can_connect()
+
+    container = Container(name="foo", can_connect=True)
+
+    trigger(
+        State(containers={container}),
+        charm_type=charm_cls,
+        meta={"name": "foo", "containers": {"foo": {}}},
+        event="pebble_ready",
+        post_event=callback,
+    )
+
+
+@pytest.mark.parametrize("starting_service_status", pebble.ServiceStatus)
+def test_pebble_plan(charm_cls, starting_service_status):
+    class PlanCharm(charm_cls):
+        def __init__(self, framework):
+            super().__init__(framework)
+            framework.observe(self.on.foo_pebble_ready, self._on_ready)
+
+        def _on_ready(self, event):
+            foo = event.workload
+
+            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"}},
+                },
+            )
+
+            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",
+        can_connect=True,
+        layers={
+            "foo": pebble.Layer({
+                "summary": "bla",
+                "description": "deadbeef",
+                "services": {"fooserv": {"startup": "enabled"}},
+            })
+        },
+        service_statuses={
+            "fooserv": pebble.ServiceStatus.ACTIVE,
+            # todo: should we disallow setting status for services that aren't known YET?
+            "barserv": starting_service_status,
+        },
+    )
+
+    out = trigger(
+        State(containers={container}),
+        charm_type=PlanCharm,
+        meta={"name": "foo", "containers": {"foo": {}}},
+        event="pebble_ready",
+    )
+
+    def serv(name, obj):
+        return pebble.Service(name, raw=obj)
+
+    container = out.get_container(container.name)
+    assert container.plan.services == {
+        "barserv": serv("barserv", {"startup": "disabled"}),
+        "fooserv": serv("fooserv", {"startup": "enabled"}),
+    }
+    assert container.services["fooserv"].current == pebble.ServiceStatus.ACTIVE
+    assert container.services["fooserv"].startup == pebble.ServiceStartup.ENABLED
+
+    assert container.services["barserv"].current == pebble.ServiceStatus.ACTIVE
+    assert container.services["barserv"].startup == pebble.ServiceStartup.DISABLED
+
+
+def test_exec_wait_error(charm_cls):
+    state = State(
+        containers={
+            Container(
+                name="foo",
+                can_connect=True,
+                execs={Exec(["foo"], stdout="hello pebble", return_code=1)},
+            )
+        }
+    )
+
+    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) as exc_info:
+            proc.wait_output()
+        assert exc_info.value.stdout == "hello pebble"
+
+
+@pytest.mark.parametrize("command", (["foo"], ["foo", "bar"], ["foo", "bar", "baz"]))
+def test_exec_wait_output(charm_cls, command):
+    state = State(
+        containers={
+            Container(
+                name="foo",
+                can_connect=True,
+                execs={Exec(["foo"], stdout="hello pebble", stderr="oepsie")},
+            )
+        }
+    )
+
+    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(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={
+            Container(
+                name="foo",
+                can_connect=True,
+                execs={Exec(["foo"], stdout="hello pebble", return_code=1)},
+            )
+        }
+    )
+
+    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_output()
+
+
+def test_pebble_custom_notice(charm_cls):
+    notices = [
+        Notice(key="example.com/foo"),
+        Notice(key="example.com/bar", last_data={"a": "b"}),
+        Notice(key="example.com/baz", occurrences=42),
+    ]
+    container = Container(
+        name="foo",
+        can_connect=True,
+        notices=notices,
+    )
+
+    state = State(containers=[container])
+    ctx = Context(charm_cls, meta={"name": "foo", "containers": {"foo": {}}})
+    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]
+
+
+def test_pebble_custom_notice_in_charm():
+    key = "example.com/test/charm"
+    data = {"foo": "bar"}
+    user_id = 100
+    first_occurred = datetime.datetime(1979, 1, 25, 11, 0, 0)
+    last_occured = datetime.datetime(2006, 8, 28, 13, 28, 0)
+    last_repeated = datetime.datetime(2023, 9, 4, 9, 0, 0)
+    occurrences = 42
+    repeat_after = datetime.timedelta(days=7)
+    expire_after = datetime.timedelta(days=365)
+
+    class MyCharm(CharmBase):
+        def __init__(self, framework):
+            super().__init__(framework)
+            framework.observe(self.on.foo_pebble_custom_notice, self._on_custom_notice)
+
+        def _on_custom_notice(self, event: PebbleCustomNoticeEvent):
+            notice = event.notice
+            assert notice.type == pebble.NoticeType.CUSTOM
+            assert notice.key == key
+            assert notice.last_data == data
+            assert notice.user_id == user_id
+            assert notice.first_occurred == first_occurred
+            assert notice.last_occurred == last_occured
+            assert notice.last_repeated == last_repeated
+            assert notice.occurrences == occurrences
+            assert notice.repeat_after == repeat_after
+            assert notice.expire_after == expire_after
+
+    notices = [
+        Notice("example.com/test/other"),
+        Notice("example.org/test/charm", last_data={"foo": "baz"}),
+        Notice(
+            key,
+            last_data=data,
+            user_id=user_id,
+            first_occurred=first_occurred,
+            last_occurred=last_occured,
+            last_repeated=last_repeated,
+            occurrences=occurrences,
+            repeat_after=repeat_after,
+            expire_after=expire_after,
+        ),
+    ]
+    container = Container(
+        name="foo",
+        can_connect=True,
+        notices=notices,
+    )
+    state = State(containers=[container])
+    ctx = Context(MyCharm, meta={"name": "foo", "containers": {"foo": {}}})
+    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/testing/tests/test_e2e/test_play_assertions.py b/testing/tests/test_e2e/test_play_assertions.py
new file mode 100644
index 000000000..35d44f410
--- /dev/null
+++ b/testing/tests/test_e2e/test_play_assertions.py
@@ -0,0 +1,123 @@
+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 ..helpers import jsonpatch_delta, trigger
+
+
+@pytest.fixture(scope="function")
+def mycharm():
+    class MyCharm(CharmBase):
+        _call = None
+        called = False
+
+        def __init__(self, framework: Framework):
+            super().__init__(framework)
+
+            for evt in self.on.events().values():
+                self.framework.observe(evt, self._on_event)
+
+        def _on_event(self, event):
+            if MyCharm._call:
+                MyCharm.called = True
+                MyCharm._call(self, event)
+
+    return MyCharm
+
+
+def test_charm_heals_on_start(mycharm):
+    def pre_event(charm):
+        pre_event._called = True
+        assert charm.unit.status == BlockedStatus("foo")
+        assert not charm.called
+
+    def call(charm, _):
+        if charm.unit.status.message == "foo":
+            charm.unit.status = ActiveStatus("yabadoodle")
+
+    def post_event(charm):
+        post_event._called = True
+        assert charm.unit.status == ActiveStatus("yabadoodle")
+        assert charm.called
+
+    mycharm._call = call
+
+    initial_state = State(
+        config={"foo": "bar"}, leader=True, unit_status=BlockedStatus("foo")
+    )
+
+    out = trigger(
+        initial_state,
+        charm_type=mycharm,
+        meta={"name": "foo"},
+        config={"options": {"foo": {"type": "string"}}},
+        event="start",
+        post_event=post_event,
+        pre_event=pre_event,
+    )
+
+    assert out.unit_status == ActiveStatus("yabadoodle")
+
+    out_purged = dataclasses.replace(out, stored_states=initial_state.stored_states)
+    assert jsonpatch_delta(out_purged, initial_state) == [
+        {
+            "op": "replace",
+            "path": "/unit_status/message",
+            "value": "yabadoodle",
+        },
+        {
+            "op": "replace",
+            "path": "/unit_status/name",
+            "value": "active",
+        },
+    ]
+
+
+def test_relation_data_access(mycharm):
+    mycharm._call = lambda *_: True
+
+    def check_relation_data(charm):
+        foo_relations = charm.model.relations["relation_test"]
+        assert len(foo_relations) == 1
+        foo_rel = foo_relations[0]
+        assert len(foo_rel.units) == 2
+
+        remote_units_data = {}
+        for remote_unit in foo_rel.units:
+            remote_units_data[remote_unit.name] = dict(foo_rel.data[remote_unit])
+
+        remote_app_data = foo_rel.data[foo_rel.app]
+
+        assert remote_units_data == {
+            "karlos/0": {"foo": "bar"},
+            "karlos/1": {"baz": "qux"},
+        }
+
+        assert remote_app_data == {"yaba": "doodle"}
+
+    state_in = State(
+        relations={
+            Relation(
+                endpoint="relation_test",
+                interface="azdrubales",
+                id=1,
+                remote_app_name="karlos",
+                remote_app_data={"yaba": "doodle"},
+                remote_units_data={0: {"foo": "bar"}, 1: {"baz": "qux"}},
+            )
+        }
+    )
+    trigger(
+        state_in,
+        charm_type=mycharm,
+        meta={
+            "name": "foo",
+            "requires": {"relation_test": {"interface": "azdrubales"}},
+        },
+        event="update_status",
+        post_event=check_relation_data,
+    )
diff --git a/testing/tests/test_e2e/test_ports.py b/testing/tests/test_e2e/test_ports.py
new file mode 100644
index 000000000..9e46665a8
--- /dev/null
+++ b/testing/tests/test_e2e/test_ports.py
@@ -0,0 +1,53 @@
+import pytest
+from ops import CharmBase, Framework, StartEvent, StopEvent
+
+from scenario import Context, State
+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():
+    return Context(MyCharm, meta=MyCharm.META)
+
+
+def test_open_port(ctx):
+    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):
+    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/testing/tests/test_e2e/test_relations.py b/testing/tests/test_e2e/test_relations.py
new file mode 100644
index 000000000..c9ae2dfcc
--- /dev/null
+++ b/testing/tests/test_e2e/test_relations.py
@@ -0,0 +1,493 @@
+from typing import Type
+
+import pytest
+from ops.charm import (
+    CharmBase,
+    CharmEvents,
+    CollectStatusEvent,
+    RelationBrokenEvent,
+    RelationCreatedEvent,
+    RelationDepartedEvent,
+    RelationEvent,
+)
+from ops.framework import EventBase, Framework
+
+from scenario import Context
+from scenario.errors import UncaughtCharmError
+from scenario.state import (
+    _DEFAULT_JUJU_DATABAG,
+    PeerRelation,
+    Relation,
+    RelationBase,
+    State,
+    StateValidationError,
+    SubordinateRelation,
+    _next_relation_id,
+)
+from tests.helpers import trigger
+
+
+@pytest.fixture(scope="function")
+def mycharm():
+    class MyCharmEvents(CharmEvents):
+        @classmethod
+        def define_event(cls, event_kind: str, event_type: "Type[EventBase]"):
+            if getattr(cls, event_kind, None):
+                delattr(cls, event_kind)
+            return super().define_event(event_kind, event_type)
+
+    class MyCharm(CharmBase):
+        _call = None
+        called = False
+        on = MyCharmEvents()
+
+        def __init__(self, framework: Framework):
+            super().__init__(framework)
+            for evt in self.on.events().values():
+                self.framework.observe(evt, self._on_event)
+
+        def _on_event(self, event):
+            if self._call:
+                MyCharm.called = True
+                MyCharm._call(self, event)
+
+    return MyCharm
+
+
+def test_get_relation(mycharm):
+    def pre_event(charm: CharmBase):
+        assert charm.model.get_relation("foo")
+        assert charm.model.get_relation("bar") is None
+        assert charm.model.get_relation("qux")
+        assert charm.model.get_relation("zoo") is None
+
+    trigger(
+        State(
+            config={"foo": "bar"},
+            leader=True,
+            relations={
+                Relation(endpoint="foo", interface="foo", remote_app_name="remote"),
+                Relation(endpoint="qux", interface="qux", remote_app_name="remote"),
+            },
+        ),
+        "start",
+        mycharm,
+        meta={
+            "name": "local",
+            "requires": {
+                "foo": {"interface": "foo"},
+                "bar": {"interface": "bar"},
+            },
+            "provides": {
+                "qux": {"interface": "qux"},
+                "zoo": {"interface": "zoo"},
+            },
+        },
+        config={"options": {"foo": {"type": "string"}}},
+        pre_event=pre_event,
+    )
+
+
+@pytest.mark.parametrize(
+    "evt_name",
+    ("changed", "broken", "departed", "joined", "created"),
+)
+@pytest.mark.parametrize(
+    "remote_app_name",
+    ("remote", "prometheus", "aodeok123"),
+)
+def test_relation_events(mycharm, evt_name, remote_app_name):
+    relation = Relation(
+        endpoint="foo", interface="foo", remote_app_name=remote_app_name
+    )
+
+    def callback(charm: CharmBase, e):
+        if not isinstance(e, RelationEvent):
+            return  # filter out collect status events
+
+        if evt_name == "broken":
+            assert charm.model.get_relation("foo") is None
+            assert e.relation.app.name == remote_app_name
+        else:
+            assert charm.model.get_relation("foo").app.name == remote_app_name
+
+    mycharm._call = callback
+
+    trigger(
+        State(
+            relations={
+                relation,
+            },
+        ),
+        f"relation_{evt_name}",
+        mycharm,
+        meta={
+            "name": "local",
+            "requires": {
+                "foo": {"interface": "foo"},
+            },
+        },
+    )
+
+
+@pytest.mark.parametrize(
+    "evt_name,has_unit",
+    [
+        ("changed", True),
+        ("broken", False),
+        ("departed", True),
+        ("joined", True),
+        ("created", False),
+    ],
+)
+@pytest.mark.parametrize(
+    "remote_app_name",
+    ("remote", "prometheus", "aodeok123"),
+)
+@pytest.mark.parametrize(
+    "remote_unit_id",
+    (0, 1),
+)
+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
+    )
+
+    def callback(charm: CharmBase, event):
+        if isinstance(event, CollectStatusEvent):
+            return
+
+        assert event.app
+        if not isinstance(event, (RelationCreatedEvent, RelationBrokenEvent)):
+            assert event.unit
+        if isinstance(event, RelationDepartedEvent):
+            assert event.departing_unit
+
+    mycharm._call = callback
+
+    ctx = Context(
+        charm_type=mycharm,
+        meta={
+            "name": "local",
+            "requires": {
+                "foo": {"interface": "foo"},
+            },
+        },
+    )
+    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(
+    "evt_name",
+    ("changed", "broken", "departed", "joined", "created"),
+)
+@pytest.mark.parametrize(
+    "remote_app_name",
+    ("remote", "prometheus", "aodeok123"),
+)
+def test_relation_events_no_attrs(mycharm, evt_name, remote_app_name, caplog):
+    relation = Relation(
+        endpoint="foo",
+        interface="foo",
+        remote_app_name=remote_app_name,
+        remote_units_data={0: {}, 1: {}},  # 2 units
+    )
+
+    def callback(charm: CharmBase, event):
+        if isinstance(event, CollectStatusEvent):
+            return
+
+        assert event.app  # that's always present
+        # .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={
+                relation,
+            },
+        ),
+        f"relation_{evt_name}",
+        mycharm,
+        meta={
+            "name": "local",
+            "requires": {
+                "foo": {"interface": "foo"},
+            },
+        },
+    )
+
+    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}
+
+
+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
+
+
+def test_relation_default_unit_data_peer():
+    relation = PeerRelation("baz")
+    assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG
+
+
+@pytest.mark.parametrize(
+    "evt_name",
+    ("changed", "broken", "departed", "joined", "created"),
+)
+def test_relation_events_no_remote_units(mycharm, evt_name, caplog):
+    relation = Relation(
+        endpoint="foo",
+        interface="foo",
+        remote_units_data={},  # no units
+    )
+
+    def callback(charm: CharmBase, event):
+        if isinstance(event, CollectStatusEvent):
+            return
+
+        assert event.app  # that's always present
+        assert not event.unit
+
+    mycharm._call = callback
+
+    trigger(
+        State(
+            relations={
+                relation,
+            },
+        ),
+        f"relation_{evt_name}",
+        mycharm,
+        meta={
+            "name": "local",
+            "requires": {
+                "foo": {"interface": "foo"},
+            },
+        },
+    )
+
+    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""))
+def test_relation_unit_data_bad_types(mycharm, data):
+    with pytest.raises(StateValidationError):
+        Relation(endpoint="foo", interface="foo", remote_units_data={0: {"a": data}})
+
+
+@pytest.mark.parametrize("data", (set(), {}, [], (), 1, 1.0, None, b""))
+def test_relation_app_data_bad_types(mycharm, data):
+    with pytest.raises(StateValidationError):
+        Relation(endpoint="foo", interface="foo", local_app_data={"a": data})
+
+
+@pytest.mark.parametrize(
+    "evt_name",
+    ("changed", "broken", "departed", "joined", "created"),
+)
+@pytest.mark.parametrize(
+    "relation",
+    (Relation("a"), PeerRelation("b"), SubordinateRelation("c")),
+)
+def test_relation_event_trigger(relation, evt_name, mycharm):
+    meta = {
+        "name": "mycharm",
+        "requires": {"a": {"interface": "i1"}},
+        "provides": {
+            "c": {
+                "interface": "i3",
+                # this is a subordinate relation.
+                "scope": "container",
+            }
+        },
+        "peers": {"b": {"interface": "i2"}},
+    }
+    trigger(
+        State(relations={relation}),
+        f"relation_{evt_name}",
+        mycharm,
+        meta=meta,
+    )
+
+
+def test_trigger_sub_relation(mycharm):
+    meta = {
+        "name": "mycharm",
+        "provides": {
+            "foo": {
+                "interface": "bar",
+                # this is a subordinate relation.
+                "scope": "container",
+            }
+        },
+    }
+
+    sub1 = SubordinateRelation(
+        "foo", remote_unit_data={"1": "2"}, remote_app_name="primary1"
+    )
+    sub2 = SubordinateRelation(
+        "foo", remote_unit_data={"3": "4"}, remote_app_name="primary2"
+    )
+
+    def post_event(charm: CharmBase):
+        b_relations = charm.model.relations["foo"]
+        assert len(b_relations) == 2
+        for relation in b_relations:
+            assert len(relation.units) == 1
+
+    trigger(
+        State(relations={sub1, sub2}),
+        "update_status",
+        mycharm,
+        meta=meta,
+        post_event=post_event,
+    )
+
+
+def test_cannot_instantiate_relationbase():
+    with pytest.raises(RuntimeError):
+        RelationBase("")
+
+
+def test_relation_ids():
+    from scenario.state import _next_relation_id_counter
+
+    initial_id = _next_relation_id_counter
+    for i in range(10):
+        rel = Relation("foo")
+        assert rel.id == initial_id + i
+
+
+def test_broken_relation_not_in_model_relations(mycharm):
+    rel = Relation("foo")
+
+    ctx = Context(
+        mycharm, meta={"name": "local", "requires": {"foo": {"interface": "foo"}}}
+    )
+    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"] == []
+
+
+def test_get_relation_when_missing():
+    class MyCharm(CharmBase):
+        def __init__(self, framework):
+            super().__init__(framework)
+            self.framework.observe(self.on.update_status, self._on_update_status)
+            self.framework.observe(self.on.config_changed, self._on_config_changed)
+            self.relation = None
+
+        def _on_update_status(self, _):
+            self.relation = self.model.get_relation("foo")
+
+        def _on_config_changed(self, _):
+            self.relation = self.model.get_relation("foo", self.config["relation-id"])
+
+    ctx = Context(
+        MyCharm,
+        meta={"name": "foo", "requires": {"foo": {"interface": "foo"}}},
+        config={"options": {"relation-id": {"type": "int", "description": "foo"}}},
+    )
+    # There should be no error if the relation is missing - get_relation returns
+    # None in that case.
+    with ctx(ctx.on.update_status(), State()) as mgr:
+        mgr.run()
+        assert mgr.charm.relation is None
+
+    # There should also be no error if the relation is present, of course.
+    rel = Relation("foo")
+    with ctx(ctx.on.update_status(), State(relations={rel})) as mgr:
+        mgr.run()
+        assert mgr.charm.relation.id == rel.id
+
+    # If a relation that doesn't exist is requested, that should also not raise
+    # an error.
+    with ctx(ctx.on.config_changed(), State(config={"relation-id": 42})) as mgr:
+        mgr.run()
+        rel = mgr.charm.relation
+        assert rel.id == 42
+        assert not rel.active
+
+    # If there's no defined relation with the name, then get_relation raises KeyError.
+    ctx = Context(MyCharm, meta={"name": "foo"})
+    with pytest.raises(UncaughtCharmError) as exc:
+        ctx.run(ctx.on.update_status(), State())
+    assert isinstance(exc.value.__cause__, KeyError)
+
+
+@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/testing/tests/test_e2e/test_resource.py b/testing/tests/test_e2e/test_resource.py
new file mode 100644
index 000000000..aebe8a0d2
--- /dev/null
+++ b/testing/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/testing/tests/test_e2e/test_rubbish_events.py b/testing/tests/test_e2e/test_rubbish_events.py
new file mode 100644
index 000000000..9162eff8e
--- /dev/null
+++ b/testing/tests/test_e2e/test_rubbish_events.py
@@ -0,0 +1,89 @@
+import os
+
+import pytest
+from ops.charm import CharmBase, CharmEvents
+from ops.framework import EventBase, EventSource, Framework, Object
+
+from scenario.state import State, _CharmSpec, _Event
+from ..helpers import trigger
+
+
+class QuxEvent(EventBase):
+    pass
+
+
+class SubEvent(EventBase):
+    pass
+
+
+@pytest.fixture(scope="function")
+def mycharm():
+    class MyCharmEvents(CharmEvents):
+        qux = EventSource(QuxEvent)
+
+    class MySubEvents(CharmEvents):
+        sub = EventSource(SubEvent)
+
+    class Sub(Object):
+        on = MySubEvents()
+
+    class MyCharm(CharmBase):
+        on = MyCharmEvents()
+        evts = []
+
+        def __init__(self, framework: Framework):
+            super().__init__(framework)
+            self.sub = Sub(self, "sub")
+            self.framework.observe(self.sub.on.sub, self._on_event)
+            self.framework.observe(self.on.qux, self._on_event)
+
+        def _on_event(self, e):
+            MyCharm.evts.append(e)
+
+    return MyCharm
+
+
+@pytest.mark.parametrize("evt_name", ("rubbish", "foo", "bar", "kazoo_pebble_ready"))
+def test_rubbish_event_raises(mycharm, evt_name):
+    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;
+            # but if we put the container in meta, it will actually register an event!
+
+        trigger(State(), evt_name, mycharm, meta={"name": "foo"})
+
+        if evt_name.startswith("kazoo"):
+            os.environ["SCENARIO_SKIP_CONSISTENCY_CHECKS"] = "false"
+
+
+@pytest.mark.parametrize("evt_name", ("qux",))
+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(AttributeError):
+        trigger(State(), evt_name, mycharm, meta={"name": "foo"})
+
+
+@pytest.mark.parametrize(
+    "evt_name, expected",
+    (
+        ("qux", False),
+        ("sub", False),
+        ("start", True),
+        ("install", True),
+        ("config-changed", True),
+        ("foo-relation-changed", True),
+        ("bar-relation-changed", True),
+    ),
+)
+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
diff --git a/testing/tests/test_e2e/test_secrets.py b/testing/tests/test_e2e/test_secrets.py
new file mode 100644
index 000000000..b7d2cb5ae
--- /dev/null
+++ b/testing/tests/test_e2e/test_secrets.py
@@ -0,0 +1,603 @@
+import datetime
+
+import pytest
+from ops.charm import CharmBase
+from ops.framework import Framework
+from ops.model import ModelError
+from ops.model import Secret as ops_Secret
+from ops.model import SecretNotFoundError, SecretRotate
+
+from scenario import Context
+from scenario.state import Relation, Secret, State
+from tests.helpers import trigger
+
+
+@pytest.fixture(scope="function")
+def mycharm():
+    class MyCharm(CharmBase):
+        def __init__(self, framework: Framework):
+            super().__init__(framework)
+            for evt in self.on.events().values():
+                self.framework.observe(evt, self._on_event)
+
+        def _on_event(self, event):
+            pass
+
+    return MyCharm
+
+
+def test_get_secret_no_secret(mycharm):
+    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")
+
+
+@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:
+        assert mgr.charm.model.get_secret(id=secret.id).get_content()["a"] == "b"
+
+
+@pytest.mark.parametrize("owner", ("app", "unit"))
+def test_get_secret_get_refresh(mycharm, 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=secret.id).get_content(refresh=True)["a"] == "c"
+        )
+
+
+@pytest.mark.parametrize("app", (True, False))
+def test_get_secret_nonowner_peek_update(mycharm, app):
+    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},
+        ),
+    ) as mgr:
+        charm = mgr.charm
+        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"
+        )
+        assert charm.model.get_secret(id=secret.id).get_content()["a"] == "c"
+
+
+@pytest.mark.parametrize("owner", ("app", "unit"))
+def test_get_secret_owner_peek_update(mycharm, 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=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"))
+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):
+        _ = 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(ctx.on, f"secret_{evt_suffix}")(**kwargs)
+
+
+@pytest.mark.parametrize("app", (True, False))
+def test_add(mycharm, app):
+    ctx = Context(mycharm, meta={"name": "local"})
+    with ctx(
+        ctx.on.update_status(),
+        State(leader=app),
+    ) as mgr:
+        charm = mgr.charm
+        if app:
+            charm.app.add_secret({"foo": "bar"}, label="mylabel")
+        else:
+            charm.unit.add_secret({"foo": "bar"}, label="mylabel")
+        output = mgr.run()
+
+    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
+    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
+        secret: ops_Secret = charm.unit.add_secret(rev1, label="mylabel")
+        assert (
+            secret.get_content()
+            == secret.peek_content()
+            == secret.get_content(refresh=True)
+            == rev1
+        )
+
+        secret.set_content(rev2)
+        # 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()
+            == secret.get_content(refresh=True)
+            == rev2
+        )
+
+        state_out = mgr.run()
+
+    assert (
+        state_out.get_secret(label="mylabel").tracked_content
+        == state_out.get_secret(label="mylabel").latest_content
+        == rev2
+    )
+
+
+def test_set(mycharm):
+    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
+        secret: ops_Secret = charm.unit.add_secret(rev1, label="mylabel")
+        assert (
+            secret.get_content()
+            == secret.peek_content()
+            == secret.get_content(refresh=True)
+            == 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
+
+        state_out = mgr.run()
+
+    assert (
+        state_out.get_secret(label="mylabel").tracked_content
+        == state_out.get_secret(label="mylabel").latest_content
+        == rev2
+    )
+
+
+def test_set_juju33(mycharm):
+    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
+        secret: ops_Secret = charm.unit.add_secret(rev1, label="mylabel")
+        assert secret.get_content() == rev1
+
+        secret.set_content(rev2)
+        assert secret.get_content() == rev1
+        assert secret.peek_content() == rev2
+        assert secret.get_content(refresh=True) == rev2
+
+        state_out = mgr.run()
+
+    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):
+    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},
+        ),
+    ) as mgr:
+        charm = mgr.charm
+        assert charm.model.get_secret(label="mylabel")
+
+        secret = charm.model.get_secret(id=secret.id)
+        info = secret.get_info()
+
+        assert secret.label is None
+        assert info.label == "mylabel"
+        assert info.rotation == SecretRotate.HOURLY
+
+
+@pytest.mark.parametrize("leader", (True, False))
+@pytest.mark.parametrize("owner", ("app", "unit", None))
+def test_secret_permission_model(mycharm, leader, owner):
+    expect_manage = bool(
+        # if you're the leader and own this app secret
+        (owner == "app" and leader)
+        # you own this secret
+        or (owner == "unit")
+    )
+
+    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},
+        ),
+    ) as mgr:
+        # 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)
+
+        if expect_manage:
+            assert secret.get_content()
+            assert secret.peek_content()
+            assert secret.get_content(refresh=True)
+
+            assert secret.get_info()
+            secret.set_content({"foo": "boo"})
+            assert secret.get_content() == {"a": "b"}  # rev1!
+            assert secret.get_content(refresh=True) == {"foo": "boo"}
+
+            secret.remove_all_revisions()
+
+        else:  # cannot manage
+            # nothing else to do directly if you can't get a hold of the Secret instance
+            # but we can try some raw backend calls
+            with pytest.raises(ModelError):
+                secret.get_info()
+
+            with pytest.raises(ModelError):
+                secret.set_content(content={"boo": "foo"})
+
+
+@pytest.mark.parametrize("app", (True, False))
+def test_grant(mycharm, app):
+    ctx = Context(
+        mycharm, meta={"name": "local", "requires": {"foo": {"interface": "bar"}}}
+    )
+    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},
+        ),
+    ) as mgr:
+        charm = mgr.charm
+        secret = charm.model.get_secret(label="mylabel")
+        foo = charm.model.get_relation("foo")
+        if app:
+            secret.grant(relation=foo)
+        else:
+            secret.grant(relation=foo, unit=foo.units.pop())
+        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)
+
+    ctx = Context(mycharm, meta={"name": "local"})
+    secret = Secret(
+        {"a": "b"},
+        owner="unit",
+        label="mylabel",
+    )
+    with ctx(
+        ctx.on.update_status(),
+        State(
+            secrets={secret},
+        ),
+    ) as mgr:
+        secret = mgr.charm.model.get_secret(label="mylabel")
+        secret.set_info(
+            label="babbuccia",
+            description="blu",
+            expire=exp,
+            rotate=SecretRotate.DAILY,
+        )
+        output = mgr.run()
+
+    secret_out = output.get_secret(label="babbuccia")
+    assert secret_out.label == "babbuccia"
+    assert secret_out.rotate == SecretRotate.DAILY
+    assert secret_out.description == "blu"
+    assert secret_out.expire == exp
+
+
+@pytest.mark.parametrize("leader", (True, False))
+def test_grant_after_add(leader):
+    class GrantingCharm(CharmBase):
+        def __init__(self, *args):
+            super().__init__(*args)
+            self.framework.observe(self.on.start, self._on_start)
+
+        def _on_start(self, _):
+            if leader:
+                secret = self.app.add_secret({"foo": "bar"})
+            else:
+                secret = self.unit.add_secret({"foo": "bar"})
+            secret.grant(self.model.relations["bar"][0])
+
+    state = State(leader=leader, relations={Relation("bar")})
+    ctx = Context(
+        GrantingCharm, meta={"name": "foo", "provides": {"bar": {"interface": "bar"}}}
+    )
+    ctx.run(ctx.on.start(), state)
+
+
+def test_grant_nonowner(mycharm):
+    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)
+
+    trigger(
+        State(
+            relations={Relation("foo", "remote")},
+            secrets={secret},
+        ),
+        "update_status",
+        mycharm,
+        meta={"name": "local", "requires": {"foo": {"interface": "bar"}}},
+        post_event=post_event,
+    )
+
+
+def test_add_grant_revoke_remove():
+    class GrantingCharm(CharmBase):
+        pass
+
+    ctx = Context(
+        GrantingCharm, meta={"name": "foo", "provides": {"bar": {"interface": "bar"}}}
+    )
+    relation_remote_app = "remote_secret_desirerer"
+    relation_id = 42
+
+    state = State(
+        leader=True,
+        relations={
+            Relation("bar", remote_app_name=relation_remote_app, id=relation_id)
+        },
+    )
+
+    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 output.secrets
+    scenario_secret = output.get_secret(label="mylabel")
+    assert relation_remote_app in scenario_secret.remote_grants[relation_id]
+
+    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 = output.get_secret(label="mylabel")
+    assert scenario_secret.remote_grants == {}
+
+    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()
+
+    with pytest.raises(KeyError):
+        output.get_secret(label="mylabel")
+
+
+def test_secret_removed_event():
+    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):
+            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/testing/tests/test_e2e/test_state.py b/testing/tests/test_e2e/test_state.py
new file mode 100644
index 000000000..e2ed6f4de
--- /dev/null
+++ b/testing/tests/test_e2e/test_state.py
@@ -0,0 +1,327 @@
+import copy
+from dataclasses import asdict, replace
+from typing import Type
+
+import pytest
+from ops.charm import CharmBase, CharmEvents, CollectStatusEvent
+from ops.framework import EventBase, Framework
+from ops.model import ActiveStatus, UnknownStatus, WaitingStatus
+
+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",
+    "relation_joined",
+    "relation_changed",
+    "relation_departed",
+    "relation_broken",
+    "storage_attached",
+    "storage_detaching",
+    "action",
+    "pebble_ready",
+    "pebble_custom_notice",
+}
+
+
+@pytest.fixture(scope="function")
+def mycharm():
+    class MyCharmEvents(CharmEvents):
+        @classmethod
+        def define_event(cls, event_kind: str, event_type: "Type[EventBase]"):
+            if getattr(cls, event_kind, None):
+                delattr(cls, event_kind)
+            return super().define_event(event_kind, event_type)
+
+    class MyCharm(CharmBase):
+        _call = None
+        called = False
+        on = MyCharmEvents()
+
+        def __init__(self, framework: Framework):
+            super().__init__(framework)
+            for evt in self.on.events().values():
+                self.framework.observe(evt, self._on_event)
+
+        def _on_event(self, event):
+            if self._call:
+                MyCharm.called = True
+                MyCharm._call(self, event)
+
+    return MyCharm
+
+
+@pytest.fixture
+def state():
+    return State(config={"foo": "bar"}, leader=True)
+
+
+def test_bare_event(state, mycharm):
+    out = trigger(
+        state,
+        "start",
+        mycharm,
+        meta={"name": "foo"},
+        config={"options": {"foo": {"type": "string"}}},
+    )
+    out_purged = replace(out, stored_states=state.stored_states)
+    assert jsonpatch_delta(state, out_purged) == []
+
+
+def test_leader_get(state, mycharm):
+    def pre_event(charm):
+        assert charm.unit.is_leader()
+
+    trigger(
+        state,
+        "start",
+        mycharm,
+        meta={"name": "foo"},
+        config={"options": {"foo": {"type": "string"}}},
+        pre_event=pre_event,
+    )
+
+
+def test_status_setting(state, mycharm):
+    def call(charm: CharmBase, e):
+        if isinstance(e, CollectStatusEvent):
+            return
+
+        assert isinstance(charm.unit.status, UnknownStatus)
+        charm.unit.status = ActiveStatus("foo test")
+        charm.app.status = WaitingStatus("foo barz")
+
+    mycharm._call = call
+    out = trigger(
+        state,
+        "start",
+        mycharm,
+        meta={"name": "foo"},
+        config={"options": {"foo": {"type": "string"}}},
+    )
+    assert out.unit_status == ActiveStatus("foo test")
+    assert out.app_status == WaitingStatus("foo barz")
+    assert out.workload_version == ""
+
+    # ignore stored state in the delta
+    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"},
+        {"op": "replace", "path": "/unit_status/message", "value": "foo test"},
+        {"op": "replace", "path": "/unit_status/name", "value": "active"},
+    ])
+
+
+@pytest.mark.parametrize("connect", (True, False))
+def test_container(connect, mycharm):
+    def pre_event(charm: CharmBase):
+        container = charm.unit.get_container("foo")
+        assert container is not None
+        assert container.name == "foo"
+        assert container.can_connect() is connect
+
+    trigger(
+        State(containers={Container(name="foo", can_connect=connect)}),
+        "start",
+        mycharm,
+        meta={
+            "name": "foo",
+            "containers": {"foo": {"resource": "bar"}},
+        },
+        pre_event=pre_event,
+    )
+
+
+def test_relation_get(mycharm):
+    def pre_event(charm: CharmBase):
+        rel = charm.model.get_relation("foo")
+        assert rel is not None
+        assert rel.data[charm.app]["a"] == "because"
+
+        assert rel.data[rel.app]["a"] == "b"
+        assert rel.data[charm.unit]["c"] == "d"
+
+        for unit in rel.units:
+            if unit is charm.unit:
+                continue
+            if unit.name == "remote/1":
+                assert rel.data[unit]["e"] == "f"
+            else:
+                assert not rel.data[unit]
+
+    state = State(
+        relations={
+            Relation(
+                endpoint="foo",
+                interface="bar",
+                local_app_data={"a": "because"},
+                remote_app_name="remote",
+                remote_app_data={"a": "b"},
+                local_unit_data={"c": "d"},
+                remote_units_data={0: {}, 1: {"e": "f"}, 2: {}},
+            )
+        }
+    )
+    trigger(
+        state,
+        "start",
+        mycharm,
+        meta={
+            "name": "local",
+            "requires": {"foo": {"interface": "bar"}},
+        },
+        pre_event=pre_event,
+    )
+
+
+def test_relation_set(mycharm):
+    def event_handler(charm: CharmBase, _):
+        rel = charm.model.get_relation("foo")
+        rel.data[charm.app]["a"] = "b"
+        rel.data[charm.unit]["c"] = "d"
+
+        # 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.
+        with pytest.raises(Exception):
+            rel.data[rel.app]["a"] = "b"
+        with pytest.raises(Exception):
+            rel.data[charm.model.get_unit("remote/1")]["c"] = "d"
+
+        assert charm.unit.is_leader()
+
+    def pre_event(charm: CharmBase):
+        assert charm.model.get_relation("foo")
+        assert charm.model.app.planned_units() == 4
+
+        # 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.
+        # with pytest.raises(Exception):
+        #     rel.data[rel.app]["a"] = "b"
+        # with pytest.raises(Exception):
+        #     rel.data[charm.model.get_unit("remote/1")]["c"] = "d"
+
+    mycharm._call = event_handler
+    relation = Relation(
+        endpoint="foo",
+        interface="bar",
+        remote_app_name="remote",
+        remote_units_data={1: {}, 4: {}},
+    )
+    state = State(
+        leader=True,
+        planned_units=4,
+        relations={relation},
+    )
+
+    assert not mycharm.called
+    out = trigger(
+        state,
+        event="start",
+        charm_type=mycharm,
+        meta={
+            "name": "foo",
+            "requires": {"foo": {"interface": "bar"}},
+        },
+        pre_event=pre_event,
+    )
+    assert mycharm.called
+
+    assert asdict(out.get_relation(relation.id)) == asdict(
+        replace(
+            relation,
+            local_app_data={"a": "b"},
+            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/testing/tests/test_e2e/test_status.py b/testing/tests/test_e2e/test_status.py
new file mode 100644
index 000000000..2f98c6f8b
--- /dev/null
+++ b/testing/tests/test_e2e/test_status.py
@@ -0,0 +1,171 @@
+import ops
+import pytest
+from ops.charm import CharmBase
+from ops.framework import Framework
+
+from scenario import Context
+from scenario.state import (
+    ActiveStatus,
+    BlockedStatus,
+    ErrorStatus,
+    MaintenanceStatus,
+    State,
+    UnknownStatus,
+    WaitingStatus,
+)
+from ..helpers import trigger
+
+
+@pytest.fixture(scope="function")
+def mycharm():
+    class MyCharm(CharmBase):
+        def __init__(self, framework: Framework):
+            super().__init__(framework)
+            for evt in self.on.events().values():
+                self.framework.observe(evt, self._on_event)
+
+        def _on_event(self, event):
+            pass
+
+    return MyCharm
+
+
+def test_initial_status(mycharm):
+    def post_event(charm: CharmBase):
+        assert charm.unit.status == UnknownStatus()
+
+    out = trigger(
+        State(leader=True),
+        "update_status",
+        mycharm,
+        meta={"name": "local"},
+        post_event=post_event,
+    )
+
+    assert out.unit_status == UnknownStatus()
+
+
+def test_status_history(mycharm):
+    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(
+        StatusCharm,
+        meta={"name": "local"},
+    )
+
+    out = ctx.run(ctx.on.update_status(), State(leader=True))
+
+    assert out.unit_status == WaitingStatus("3")
+    assert ctx.unit_status_history == [
+        UnknownStatus(),
+        ActiveStatus("1"),
+        BlockedStatus("2"),
+    ]
+
+    assert out.app_status == ops.WaitingStatus("3")
+    assert ctx.app_status_history == [
+        UnknownStatus(),
+        ActiveStatus("1"),
+        BlockedStatus("2"),
+    ]
+
+
+def test_status_history_preservation(mycharm):
+    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(
+        StatusCharm,
+        meta={"name": "local"},
+    )
+
+    out = ctx.run(
+        ctx.on.update_status(),
+        State(
+            leader=True,
+            unit_status=ActiveStatus("foo"),
+            app_status=ActiveStatus("bar"),
+        ),
+    )
+
+    assert out.unit_status == WaitingStatus("3")
+    assert ctx.unit_status_history == [ActiveStatus("foo")]
+
+    assert out.app_status == WaitingStatus("3")
+    assert ctx.app_status_history == [ActiveStatus("bar")]
+
+
+def test_workload_history(mycharm):
+    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(
+        WorkloadCharm,
+        meta={"name": "local"},
+    )
+
+    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"
+
+
+@pytest.mark.parametrize(
+    "status",
+    (
+        ActiveStatus("foo"),
+        WaitingStatus("bar"),
+        BlockedStatus("baz"),
+        MaintenanceStatus("qux"),
+        ErrorStatus("fiz"),
+        UnknownStatus(),
+    ),
+)
+def test_status_comparison(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/testing/tests/test_e2e/test_storage.py b/testing/tests/test_e2e/test_storage.py
new file mode 100644
index 000000000..34785aa1c
--- /dev/null
+++ b/testing/tests/test_e2e/test_storage.py
@@ -0,0 +1,93 @@
+import pytest
+from ops import CharmBase, ModelError
+
+from scenario import Context, State, Storage
+
+
+class MyCharmWithStorage(CharmBase):
+    META = {"name": "charlene", "storage": {"foo": {"type": "filesystem"}}}
+
+
+class MyCharmWithoutStorage(CharmBase):
+    META = {"name": "patrick"}
+
+
+@pytest.fixture
+def storage_ctx():
+    return Context(MyCharmWithStorage, meta=MyCharmWithStorage.META)
+
+
+@pytest.fixture
+def no_storage_ctx():
+    return Context(MyCharmWithoutStorage, meta=MyCharmWithoutStorage.META)
+
+
+def test_storage_get_null(no_storage_ctx):
+    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(storage_ctx.on.update_status(), State()) as mgr:
+        storages = mgr.charm.model.storages
+        # not in metadata
+        with pytest.raises(KeyError):
+            storages["bar"]
+
+
+def test_storage_request_unknown_name(storage_ctx):
+    with storage_ctx(storage_ctx.on.update_status(), State()) as mgr:
+        storages = mgr.charm.model.storages
+        # not in metadata
+        with pytest.raises(ModelError):
+            storages.request("bar")
+
+
+def test_storage_get_some(storage_ctx):
+    with storage_ctx(storage_ctx.on.update_status(), State()) as mgr:
+        storages = mgr.charm.model.storages
+        # known but none attached
+        assert storages["foo"] == []
+
+
+@pytest.mark.parametrize("n", (1, 3, 5))
+def test_storage_add(storage_ctx, n):
+    with storage_ctx(storage_ctx.on.update_status(), State()) as mgr:
+        storages = mgr.charm.model.storages
+        storages.request("foo", n)
+
+    assert storage_ctx.requested_storages["foo"] == n
+
+
+def test_storage_usage(storage_ctx):
+    storage = Storage("foo")
+    # setup storage with some content
+    (storage.get_filesystem(storage_ctx) / "myfile.txt").write_text("helloworld")
+
+    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"
+        assert path.exists()
+        assert path.read_text() == "helloworld"
+
+        myfile = loc / "path.py"
+        myfile.write_text("helloworlds")
+
+    # post-mortem: inspect fs contents.
+    assert (
+        storage.get_filesystem(storage_ctx) / "path.py"
+    ).read_text() == "helloworlds"
+
+
+def test_storage_attached_event(storage_ctx):
+    storage = Storage("foo")
+    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_ctx.on.storage_detaching(storage), State(storages={storage})
+    )
diff --git a/testing/tests/test_e2e/test_stored_state.py b/testing/tests/test_e2e/test_stored_state.py
new file mode 100644
index 000000000..b4cb7c7a9
--- /dev/null
+++ b/testing/tests/test_e2e/test_stored_state.py
@@ -0,0 +1,78 @@
+import pytest
+from ops.charm import CharmBase
+from ops.framework import Framework
+from ops.framework import StoredState as ops_storedstate
+
+from scenario.state import State, StoredState
+from tests.helpers import trigger
+
+
+@pytest.fixture(scope="function")
+def mycharm():
+    class MyCharm(CharmBase):
+        META = {"name": "mycharm"}
+
+        _read = {}
+        _stored = ops_storedstate()
+        _stored2 = ops_storedstate()
+
+        def __init__(self, framework: Framework):
+            super().__init__(framework)
+            self._stored.set_default(foo="bar", baz={12: 142})
+            self._stored2.set_default(foo="bar", baz={12: 142})
+            for evt in self.on.events().values():
+                self.framework.observe(evt, self._on_event)
+
+        def _on_event(self, event):
+            self._read["foo"] = self._stored.foo
+            self._read["baz"] = self._stored.baz
+
+    return MyCharm
+
+
+def test_stored_state_default(mycharm):
+    out = trigger(State(), "start", mycharm, meta=mycharm.META)
+    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_states={
+                StoredState(
+                    owner_path="MyCharm", name="_stored", content={"foo": "FOOX"}
+                ),
+            }
+        ),
+        "start",
+        mycharm,
+        meta=mycharm.META,
+    )
+    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 is None
+    assert s.content == {}
+    assert s._data_type_name == "StoredStateData"
diff --git a/testing/tests/test_e2e/test_vroot.py b/testing/tests/test_e2e/test_vroot.py
new file mode 100644
index 000000000..f0404b84b
--- /dev/null
+++ b/testing/tests/test_e2e/test_vroot.py
@@ -0,0 +1,91 @@
+import tempfile
+from pathlib import Path
+
+import pytest
+import yaml
+from ops.charm import CharmBase
+from ops.framework import Framework
+from ops.model import ActiveStatus
+
+from scenario import Context, State
+from ..helpers import trigger
+
+
+class MyCharm(CharmBase):
+    META = {"name": "my-charm"}
+
+    def __init__(self, framework: Framework):
+        super().__init__(framework)
+        foo = self.framework.charm_dir / "src" / "foo.bar"
+        baz = self.framework.charm_dir / "src" / "baz" / "qux.kaboodle"
+
+        self.unit.status = ActiveStatus(f"{foo.read_text()} {baz.read_text()}")
+
+
+@pytest.fixture
+def charm_virtual_root():
+    with tempfile.TemporaryDirectory() as mycharm_virtual_root:
+        t = Path(mycharm_virtual_root)
+        src = t / "src"
+        src.mkdir()
+        foobar = src / "foo.bar"
+        foobar.write_text("hello")
+
+        baz = src / "baz"
+        baz.mkdir(parents=True)
+        quxcos = baz / "qux.kaboodle"
+        quxcos.write_text("world")
+
+        yield t
+
+
+def test_charm_virtual_root(charm_virtual_root):
+    out = trigger(
+        State(),
+        "start",
+        charm_type=MyCharm,
+        meta=MyCharm.META,
+        charm_root=charm_virtual_root,
+    )
+    assert out.unit_status == ActiveStatus("hello world")
+
+
+def test_charm_virtual_root_cleanup_if_exists(charm_virtual_root):
+    meta_file = charm_virtual_root / "metadata.yaml"
+    raw_ori_meta = yaml.safe_dump({"name": "karl"})
+    meta_file.write_text(raw_ori_meta)
+
+    ctx = Context(MyCharm, meta=MyCharm.META, charm_root=charm_virtual_root)
+    with ctx(
+        ctx.on.start(),
+        State(),
+    ) as mgr:
+        assert meta_file.exists()
+        assert meta_file.read_text() == yaml.safe_dump({"name": "my-charm"})
+        assert (
+            mgr.charm.meta.name == "my-charm"
+        )  # not karl! Context.meta takes precedence
+        mgr.run()
+        assert meta_file.exists()
+
+    # meta file was restored to its previous contents
+    assert meta_file.read_text() == raw_ori_meta
+    assert meta_file.exists()
+
+
+def test_charm_virtual_root_cleanup_if_not_exists(charm_virtual_root):
+    meta_file = charm_virtual_root / "metadata.yaml"
+
+    assert not meta_file.exists()
+
+    ctx = Context(MyCharm, meta=MyCharm.META, charm_root=charm_virtual_root)
+    with ctx(
+        ctx.on.start(),
+        State(),
+    ) as mgr:
+        assert meta_file.exists()
+        assert meta_file.read_text() == yaml.safe_dump({"name": "my-charm"})
+        mgr.run()
+        assert not meta_file.exists()
+
+    assert not meta_file.exists()
diff --git a/testing/tests/test_emitted_events_util.py b/testing/tests/test_emitted_events_util.py
new file mode 100644
index 000000000..f22a69586
--- /dev/null
+++ b/testing/tests/test_emitted_events_util.py
@@ -0,0 +1,83 @@
+from ops.charm import CharmBase, CharmEvents, CollectStatusEvent, StartEvent
+from ops.framework import CommitEvent, EventBase, EventSource, PreCommitEvent
+
+from scenario import State
+from scenario.runtime import _capture_events
+from scenario.state import _Event
+from .helpers import trigger
+
+
+class Foo(EventBase):
+    pass
+
+
+class MyCharmEvents(CharmEvents):
+    foo = EventSource(Foo)
+
+
+class MyCharm(CharmBase):
+    META = {"name": "mycharm"}
+    on = MyCharmEvents()
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.framework.observe(self.on.start, self._on_start)
+        self.framework.observe(self.on.foo, self._on_foo)
+
+    def _on_start(self, e):
+        self.on.foo.emit()
+
+    def _on_foo(self, e):
+        pass
+
+
+def test_capture_custom_evt_nonspecific_capture_include_fw_evts():
+    with _capture_events(include_framework=True) as emitted:
+        trigger(State(), "start", MyCharm, meta=MyCharm.META)
+
+    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:
+        trigger(State(), "start", MyCharm, meta=MyCharm.META)
+
+    assert len(emitted) == 2
+    assert isinstance(emitted[0], StartEvent)
+    assert isinstance(emitted[1], Foo)
+
+
+def test_capture_deferred_evt():
+    # todo: this test should pass with ops < 2.1 as well
+    with _capture_events() as emitted:
+        trigger(
+            State(deferred=[_Event("foo").deferred(handler=MyCharm._on_foo)]),
+            "start",
+            MyCharm,
+            meta=MyCharm.META,
+        )
+
+    assert len(emitted) == 3
+    assert isinstance(emitted[0], Foo)
+    assert isinstance(emitted[1], StartEvent)
+    assert isinstance(emitted[2], Foo)
+
+
+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:
+        trigger(
+            State(deferred=[_Event("foo").deferred(handler=MyCharm._on_foo)]),
+            "start",
+            MyCharm,
+            meta=MyCharm.META,
+        )
+
+    assert len(emitted) == 2
+    assert isinstance(emitted[0], StartEvent)
+    assert isinstance(emitted[1], Foo)
diff --git a/testing/tests/test_plugin.py b/testing/tests/test_plugin.py
new file mode 100644
index 000000000..766175891
--- /dev/null
+++ b/testing/tests/test_plugin.py
@@ -0,0 +1,37 @@
+import sys
+
+pytest_plugins = "pytester"
+sys.path.append(".")
+
+
+def test_plugin_ctx_run(pytester):
+    # create a temporary pytest test module
+    pytester.makepyfile(
+        """
+        import pytest
+        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(context.on.start(), State())
+    """
+    )
+
+    # run pytest with the following cmd args
+    result = pytester.runpytest("-v")
+
+    # fnmatch_lines does an assertion internally
+    result.stdout.fnmatch_lines([
+        "*::test_sth PASSED*",
+    ])
+
+    # make sure that we get a '0' exit code for the testsuite
+    assert result.ret == 0
diff --git a/testing/tests/test_runtime.py b/testing/tests/test_runtime.py
new file mode 100644
index 000000000..11c46035e
--- /dev/null
+++ b/testing/tests/test_runtime.py
@@ -0,0 +1,114 @@
+import os
+from tempfile import TemporaryDirectory
+
+import pytest
+from ops.charm import CharmBase, CharmEvents
+from ops.framework import EventBase
+
+from scenario import Context
+from scenario.runtime import Runtime, UncaughtCharmError
+from scenario.state import Relation, State, _CharmSpec, _Event
+
+
+def charm_type():
+    class _CharmEvents(CharmEvents):
+        pass
+
+    class MyCharm(CharmBase):
+        on = _CharmEvents()
+        _event = None
+
+        def __init__(self, framework):
+            super().__init__(framework)
+            for evt in self.on.events().values():
+                self.framework.observe(evt, self._catchall)
+
+        def _catchall(self, e):
+            if self._event:
+                return
+            MyCharm._event = e
+
+    return MyCharm
+
+
+def test_event_emission():
+    with TemporaryDirectory():
+        meta = {
+            "name": "foo",
+            "requires": {"ingress-per-unit": {"interface": "ingress_per_unit"}},
+        }
+
+        my_charm_type = charm_type()
+
+        class MyEvt(EventBase):
+            pass
+
+        my_charm_type.on.define_event("bar", MyEvt)
+
+        runtime = Runtime(
+            _CharmSpec(
+                my_charm_type,
+                meta=meta,
+            ),
+        )
+
+        with runtime.exec(
+            state=State(),
+            event=_Event("bar"),
+            context=Context(my_charm_type, meta=meta),
+        ):
+            pass
+
+        assert my_charm_type._event
+        assert isinstance(my_charm_type._event, MyEvt)
+
+
+@pytest.mark.parametrize("app_name", ("foo", "bar-baz", "QuX2"))
+@pytest.mark.parametrize("unit_id", (1, 2, 42))
+def test_unit_name(app_name, unit_id):
+    meta = {
+        "name": app_name,
+    }
+
+    my_charm_type = charm_type()
+
+    runtime = Runtime(
+        _CharmSpec(
+            my_charm_type,
+            meta=meta,
+        ),
+        unit_id=unit_id,
+        app_name=app_name,
+    )
+
+    with runtime.exec(
+        state=State(),
+        event=_Event("start"),
+        context=Context(my_charm_type, meta=meta),
+    ) as ops:
+        assert ops.charm.unit.name == f"{app_name}/{unit_id}"
+
+
+def test_env_cleanup_on_charm_error():
+    meta = {"name": "frank", "requires": {"box": {"interface": "triangle"}}}
+
+    my_charm_type = charm_type()
+
+    runtime = Runtime(
+        _CharmSpec(
+            my_charm_type,
+            meta=meta,
+        ),
+    )
+
+    rel = Relation("box")
+    with pytest.raises(UncaughtCharmError):
+        with runtime.exec(
+            state=State(relations={rel}),
+            event=_Event("box_relation_changed", relation=rel),
+            context=Context(my_charm_type, meta=meta),
+        ):
+            assert os.getenv("JUJU_REMOTE_APP")
+            _ = 1 / 0  # raise some error
+
+    assert os.getenv("JUJU_REMOTE_APP", None) is None
diff --git a/testing/tox.ini b/testing/tox.ini
new file mode 100644
index 000000000..d31557318
--- /dev/null
+++ b/testing/tox.ini
@@ -0,0 +1,100 @@
+[tox]
+requires =
+    tox>=4.2
+env_list =
+    py312
+    py311
+    py38
+    unit
+    lint
+    lint-tests
+    static
+skip_missing_interpreters = true
+
+[vars]
+src_path = {toxinidir}/scenario
+tst_path = {toxinidir}/tests
+all_path = {[vars]src_path}, {[vars]tst_path}
+
+[testenv:unit]
+# don't install as a sdist, instead, install as wheel (create wheel once), then install in all envs
+package = wheel
+wheel_build_env = .pkg
+description = unit tests
+deps =
+    jsonpatch
+    pytest
+    pytest-cov
+setenv =
+    PYTHONPATH = {toxinidir}
+commands =
+    pytest --cov-report html:.cov_html -v --tb native --log-cli-level=INFO -s {posargs} {[vars]tst_path}
+
+[testenv:lint]
+description = Format the code base to adhere to our styles, and complain about what we cannot do automatically.
+skip_install = true
+deps =
+    pre-commit>=3.2.2
+commands =
+    pre-commit run --all-files {posargs}
+    python -c 'print(r"hint: run {envbindir}{/}pre-commit install to add checks as pre-commit hook")'
+
+[testenv:static]
+description = Static typing checks.
+skip_install = true
+deps =
+    ops~=2.15
+    pyright==1.1.347
+commands =
+    pyright scenario
+
+[testenv:lint-tests]
+description = Lint test files.
+skip_install = true
+deps =
+    black
+    coverage[toml]
+    isort
+commands =
+    black --check {[vars]tst_path}
+    isort --check-only --profile black {[vars]tst_path}
+
+[testenv:fmt]
+description = Format code.
+skip_install = true
+deps =
+    ruff
+    isort
+commands =
+    ruff format {[vars]tst_path} {[vars]src_path}
+    isort --profile black {[vars]tst_path} {[vars]src_path}
+
+[testenv:docs-deps]
+description = Compile the requirements.txt file for docs
+deps = pip-tools
+commands =
+    pip-compile --extra=docs -o docs/requirements.txt pyproject.toml
+
+[testenv:docs]
+description = Build the Sphinx docs
+deps = pip-tools
+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