Repository: canonical/operator

Issue:
Unhandled exception during refresh rollback of a charm
Example CI runs of the failure:
* https://github.com/canonical/pgbouncer-operator/actions/runs/13104359003/job/36577546859?pr=478
* https://github.com/canonical/postgresql-k8s-operator/actions/runs/13105001856/job/36577625971?pr=835

The error seems to happen when the tests simulate incompatibility between revisions and re-refresh to the original revision. The PRs update ops from 2.17.1 to 2.18.0

Traceback:
```
 Traceback (most recent call last):
  File "/var/lib/juju/agents/unit-pgbouncer-0/charm/src/charm.py", line 987, in <module>
    main(PgBouncerCharm)
  File "/var/lib/juju/agents/unit-pgbouncer-0/charm/venv/lib/python3.10/site-packages/ops/__init__.py", line 343, in __call__
    return _main.main(charm_class=charm_class, use_juju_for_storage=use_juju_for_storage)
  File "/var/lib/juju/agents/unit-pgbouncer-0/charm/venv/lib/python3.10/site-packages/ops/_main.py", line 558, in main
    manager.run()
  File "/var/lib/juju/agents/unit-pgbouncer-0/charm/venv/lib/python3.10/site-packages/ops/_main.py", line 543, in run
    self._emit()
  File "/var/lib/juju/agents/unit-pgbouncer-0/charm/venv/lib/python3.10/site-packages/ops/_main.py", line 502, in _emit
    self._emit_charm_event(self.dispatcher.event_name)
  File "/var/lib/juju/agents/unit-pgbouncer-0/charm/venv/lib/python3.10/site-packages/ops/_main.py", line 530, in _emit_charm_event
    event_to_emit.emit(*args, **kwargs)
  File "/var/lib/juju/agents/unit-pgbouncer-0/charm/venv/lib/python3.10/site-packages/ops/framework.py", line 347, in emit
    framework._emit(event)
  File "/var/lib/juju/agents/unit-pgbouncer-0/charm/venv/lib/python3.10/site-packages/ops/framework.py", line 908, in _emit
    self._reemit(event_path)
  File "/var/lib/juju/agents/unit-pgbouncer-0/charm/venv/lib/python3.10/site-packages/ops/framework.py", line 963, in _reemit
    event = self.load_snapshot(event_handle)
  File "/var/lib/juju/agents/unit-pgbouncer-0/charm/venv/lib/python3.10/site-packages/ops/framework.py", line 758, in load_snapshot
    data = self._storage.load_snapshot(handle.path)
  File "/var/lib/juju/agents/unit-pgbouncer-0/charm/venv/lib/python3.10/site-packages/ops/storage.py", line 147, in load_snapshot
    raise NoSnapshotError(handle_path)
ops.storage.NoSnapshotError: no snapshot data found for PgBouncerCharm/on/config_changed[516] object
```

Possibly `NoSnapshotError` should also be handled [here](https://github.com/canonical/operator/blob/2.18.0/ops/framework.py#L964), I'm not sure if that will be enough if the issue happens when reverting from ops 2.18.0 to 2.17.1.

Discussion:
tonyandrewmeyer: I'm attempting to reproduce the failure (and then the fix) in pgbouncer-operator and postgresql-k8s-operator, although I've had limited success in the past trying to run the integration tests of complex charms outside of the regular repo.
tonyandrewmeyer: postgresql-k8s-operator [failure in my fork](https://github.com/tonyandrewmeyer/postgresql-k8s-operator/actions/runs/13130433301/job/36634481745). Trying to get it to build with this branch to see the fix.

pgbouncer-operator's `tox -e integration` had 13 failures (25 xfailed, 5 errors) locally, but none matching the expected one - I think perhaps my local Juju version isn't the one expected. I'll try that in CI in my fork also.

Patch:
diff --git a/docs/custom_conf.py b/docs/custom_conf.py
index e13bcad..e5bc315 100644
--- a/docs/custom_conf.py
+++ b/docs/custom_conf.py
@@ -295,7 +295,11 @@ autodoc_default_options = {
 
 # This config value contains the locations and names of other projects
 # that should be linked to in this documentation.
-intersphinx_mapping = {'python': ('https://docs.python.org/3', None)}
+intersphinx_mapping = {
+    'python': ('https://docs.python.org/3', None),
+    'juju': ('https://canonical-juju.readthedocs-hosted.com/en/latest/', None),
+    'charmcraft': ('https://canonical-charmcraft.readthedocs-hosted.com/en/latest/', None),
+}
 
 # -- General configuration ---------------------------------------------------
 
diff --git a/docs/howto/manage-actions.md b/docs/howto/manage-actions.md
index 62d4f66..df56366 100644
--- a/docs/howto/manage-actions.md
+++ b/docs/howto/manage-actions.md
@@ -1,9 +1,6 @@
 (manage-actions)=
 # How to manage actions
-
-<!-- UPDATE LINKS
-> See first: [`juju` | Action](https://juju.is/docs/juju/action), [`juju` | Manage actions](https://juju.is/docs/juju/manage-actions), [`charmcraft` | Manage actions]()
--->
+> See first: {external+juju:ref}`Juju | Charm <action>`, {external+juju:ref}`Juju | Manage actions <manage-actions>`, {external+charmcraft:ref}`Charmcraft | Manage actions <manage-actions>`
 
 ## Implement the feature
 
diff --git a/docs/howto/manage-configurations.md b/docs/howto/manage-configurations.md
index 84a7e1e..f1ae045 100644
--- a/docs/howto/manage-configurations.md
+++ b/docs/howto/manage-configurations.md
@@ -1,9 +1,6 @@
 (manage-configurations)=
 # Manage configurations
-
-<!-- UPDATE LINKS:
-> See first: [`juju` | Application configuration](https://juju.is/docs/juju/configuration#heading--application-configuration), [`juju` | Manage application configurations](https://juju.is/docs/juju/manage-applications#heading--configure-an-application), [`charmcraft` | Manage configurations]()
--->
+> See first: {external+juju:ref}`Juju | <application-configuration>`, {external+juju:ref}`Juju | Manage applications > Configure <configure-an-application>`, {external+charmcraft:ref}`Charmcraft | Manage configurations <manage-configurations>`
 
 
 ## Implement the feature
diff --git a/docs/howto/manage-leadership-changes.md b/docs/howto/manage-leadership-changes.md
index 9251252..f7948bb 100644
--- a/docs/howto/manage-leadership-changes.md
+++ b/docs/howto/manage-leadership-changes.md
@@ -1,9 +1,6 @@
 (manage-leadership-changes)=
 # Manage leadership changes
-
-<!-- UPDATE LINKS:
-> See first: [`juju` | Leader](https://juju.is/docs/juju/leader)
--->
+> See first: {external+juju:ref}`Juju | Leader unit <leader-unit>`
 
 ## Implement response to leadership changes
 
diff --git a/docs/howto/manage-libraries.md b/docs/howto/manage-libraries.md
index 436a8ed..01dfa53 100644
--- a/docs/howto/manage-libraries.md
+++ b/docs/howto/manage-libraries.md
@@ -1,9 +1,8 @@
 (manage-libraries)=
 # Manage libraries
+> See first: {external+charmcraft:ref}`Charmcraft | Manage libraries <manage-libraries>`
+
 
-<!-- UPDATE LINKS:
-> See first: [`juju` | Library](), [`charmcraft` | Manage libraries]()
--->
 ## Write a library
 
 When you're writing libraries, instead of callbacks, you can use custom events; that'll result in a more `ops`-native-feeling API. A custom event is, from a technical standpoint, an EventBase subclass that can be emitted at any point throughout the charm's lifecycle. These events are therefore totally unknown to Juju. They are essentially charm-internal, and can be useful to abstract certain conditional workflows and wrap the toplevel Juju event so it can be observed independently.
@@ -221,10 +220,6 @@ def test_my_object_data(context, endpoint, n_relations):
 
 Fetch the library.
 
-<!-- UPDATE LINKS:
-> See more: [`charmcraft` | Manage libraries]()
--->
-
 In your `src/charm.py`, observe the custom events it puts at your disposal. For example, a database library may have provided a  `database_relation_ready` event -- a high-level wrapper around the relevant `juju` relation events -- so you use it to manage the database integration in your charm as below:
 
 ```python
diff --git a/docs/howto/manage-logs.md b/docs/howto/manage-logs.md
index 9df93db..5afd261 100644
--- a/docs/howto/manage-logs.md
+++ b/docs/howto/manage-logs.md
@@ -1,9 +1,7 @@
 (how-to-log-a-message-in-a-charm)=
 # How to log a message in a charm
 
-<!-- UPDATE LINKS:
-> See first: [`juju` | Log](https://juju.is/docs/juju/log), [`juju` | How to manage logs > Manage the logging configuration](https://juju.is/docs/juju/manage-logs#heading--manage-the-logging-configuration)
--->
+> See first: {external+juju:ref}`Juju | Log <log>`, {external+juju:ref}`Juju | Manage logs <manage-logs>`
 
 <!--
 > 
diff --git a/docs/howto/manage-relations.md b/docs/howto/manage-relations.md
index ac2d287..6198445 100644
--- a/docs/howto/manage-relations.md
+++ b/docs/howto/manage-relations.md
@@ -1,12 +1,9 @@
 (manage-relations)=
 # How to manage relations
+> See first: {external+juju:ref}`Juju | Relation <relation>`, {external+juju:ref}`Juju | Manage relations <manage-relations>`, {external+charmcraft:ref}`Charmcraft | Manage relations <manage-relations>`
 
 To add integration capabilities to a charm, you’ll have to define the relation in your charm’s charmcraft.yaml file and then add relation event handlers in your charm’s `src/charm.py` file.
 
-<!-- UPDATE LINKS
-> See first: [`juju` | Relation (integration)](https://juju.is/docs/juju/relation), [`juju` | Manage relations](https://juju.is/docs/juju/manage-relations), [`charmcraft` | Manage relations]()
--->
-
 ## Implement the feature
 
 ### Declare the relation endpoint
diff --git a/docs/howto/manage-resources.md b/docs/howto/manage-resources.md
index 1c93b2a..7bc8629 100644
--- a/docs/howto/manage-resources.md
+++ b/docs/howto/manage-resources.md
@@ -1,9 +1,7 @@
 (manage-resources)=
 # How to manage resources
 
-<!-- UPDATE LINKS:
-> See also: [`juju` | Resource (charm)](https://juju.is/docs/juju/charm-resource), [`juju` | Manage resources](https://juju.is/docs/juju/manage-charm-resources), [`charmcraft` | Manage resources]()
--->
+> See also: {external+juju:ref}`Juju | Charm resource <charm-resource>`, {external+juju:ref}`Juju | Manage charm resources <manage-charm-resources>`, {external+charmcraft:ref}`Charmcraft | Manage resources <manage-resources>`
 
 ## Implement the feature
 
diff --git a/docs/howto/manage-secrets.md b/docs/howto/manage-secrets.md
index e055dbe..8d4a2bf 100644
--- a/docs/howto/manage-secrets.md
+++ b/docs/howto/manage-secrets.md
@@ -1,9 +1,6 @@
 (manage-secrets)=
 # How to manage secrets
-
-<!-- UPDATE LINKS:
-> See first: [`juju` | Secret](https://juju.is/docs/juju/secret), [`juju` | Manage secrets](https://juju.is/docs/juju/manage-secrets), [`charmcraft` | Manage secrets]()
--->
+> See first: {external+juju:ref}`Juju | Secret <secret>`, {external+juju:ref}`Juju | Manage secrets <manage-secrets>`, {external+charmcraft:ref}`Charmcraft | Manage secrets <manage-secrets>`
 
 > Added in `Juju 3.0.2`
 
diff --git a/docs/howto/manage-storage.md b/docs/howto/manage-storage.md
index 271bc8d..702d1dc 100644
--- a/docs/howto/manage-storage.md
+++ b/docs/howto/manage-storage.md
@@ -1,9 +1,6 @@
 (manage-storage)=
 # How to manage storage
-
-<!-- UPDATE LINKS:
-> See first: [`juju` | Storage](https://juju.is/docs/juju/storage), [`juju` | Manage storage](https://juju.is/docs/juju/manage-storage), [`charmcraft | Manage storage]()
--->
+> See first: {external+juju:ref}`Juju | Storage <storage>`, {external+juju:ref}`Juju | Manage storage <manage-storage>`, {external+charmcraft:ref}`Charmcraft | Manage storage <manage-storage>`
 
 ## Implement the feature
 
diff --git a/docs/howto/manage-stored-state.md b/docs/howto/manage-stored-state.md
index 5bbdfc5..0f1e997 100644
--- a/docs/howto/manage-stored-state.md
+++ b/docs/howto/manage-stored-state.md
@@ -1,7 +1,7 @@
 (manage-stored-state)=
 # How to manage stored state
 
-> See first: [](storedstate-uses-limitations)
+> See first: {ref}`storedstate-uses-limitations`
 
 Data stored on a charm instance will not persist beyond the current Juju event,
 because a new charm instance is created to handle each event. In general, charms
diff --git a/docs/howto/turn-a-hooks-based-charm-into-an-ops-charm.md b/docs/howto/turn-a-hooks-based-charm-into-an-ops-charm.md
index aabacff..ba02882 100644
--- a/docs/howto/turn-a-hooks-based-charm-into-an-ops-charm.md
+++ b/docs/howto/turn-a-hooks-based-charm-into-an-ops-charm.md
@@ -1,9 +1,6 @@
 (turn-a-hooks-based-charm-into-an-ops-charm)=
 # How to turn a hooks-based charm into an ops charm
-
-<!-- UPDATE LINKS:
-> See first: [`juju` | Charm taxonomy]()
--->
+> See first: {external+juju:ref}`Juju | Charm taxonomy <charm-taxonomy>`
 
 Suppose you have a hooks-based charm and you decide to rewrite it using the Ops framework in Python.
 
diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/preserve-your-charms-data.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/preserve-your-charms-data.md
index bc3e2bc..f3a7cf5 100644
--- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/preserve-your-charms-data.md
+++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/preserve-your-charms-data.md
@@ -3,7 +3,7 @@
 
 > <small> {ref}`From Zero to Hero: Write your first Kubernetes charm <from-zero-to-hero-write-your-first-kubernetes-charm>` > Preserve your charm's data </small>
 >
-> **See previous: {ref}`Integrate your charm with PostgreSQL <integrate-your-charm-with-postgresql>`** 
+> **See previous: {ref}`Integrate your charm with PostgreSQL <integrate-your-charm-with-postgresql>`**
 
 
 ````{important}
@@ -20,7 +20,7 @@ git checkout -b  05_preserve_charm_data
 ````
 
 
-Charms are stateless applications. That is, they are reinitialised for every event and do not retain information from previous executions. This means that, if an accident occurs and the Kubernetes pod dies, you will also lose any information you may have collected. 
+Charms are stateless applications. That is, they are reinitialised for every event and do not retain information from previous executions. This means that, if an accident occurs and the Kubernetes pod dies, you will also lose any information you may have collected.
 
 In many cases that is not a problem. However, there are situations where it may be necessary to maintain information from previous runs and to retain the state of the application. As a charm author you should thus know how to preserve state.
 
@@ -30,25 +30,21 @@ First, you can use an Ops construct called `Stored State`. With this strategy yo
 
 > Read more: [](ops.StoredState), {ref}`StoredState: Uses, Limitations <storedstate-uses-limitations>`
 
-Second, you can make use of the Juju notion of 'peer relations'  and 'data bags'  and set up a peer relation data bag. This will help you store the information in the Juju's database backend. 
+Second, you can make use of the Juju notion of 'peer relations'  and 'data bags'  and set up a peer relation data bag. This will help you store the information in the Juju's database backend.
 
 
-<!-- UPDATE LINKS
-> Read more: [Peer relations](https://juju.is/docs/juju/relation#heading--peer)
--->
+> See more: {external+juju:ref}`Juju | Peer relation <peer-relation>`
 
 Third, when you have confidential data, you can use Juju secrets (from Juju 3.1 onwards).
 
-<!-- UPDATE LINKS
-> Read more: [Juju | Secret](https://juju.is/docs/juju/secret)
--->
+> See more: {external+juju:ref}`Juju | Secret <secret>`
 
 
 In this chapter we will adopt the second strategy, that is, we will store charm data in a peer relation databag. (We will explore the third strategy in a different scenario in the next chapter.)  We will illustrate this strategy with an artificial example where we save the counter of how many times the application pod has been restarted.
 
 ## Define a peer relation
 
-The first thing you need to do is define a peer relation. Update the `charmcraft.yaml` file to add a `peers` block before the `requires` block, as below (where `fastapi-peer` is a custom name for the peer relation and `fastapi_demo_peers` is a custom name for the peer relation interface): 
+The first thing you need to do is define a peer relation. Update the `charmcraft.yaml` file to add a `peers` block before the `requires` block, as below (where `fastapi-peer` is a custom name for the peer relation and `fastapi_demo_peers` is a custom name for the peer relation interface):
 
 ```yaml
 peers:
@@ -56,9 +52,8 @@ peers:
     interface: fastapi_demo_peers
 ```
 
-<!-- UPDATE LINKS
-> Read more: [File ‘charmcraft.yaml`]()
--->
+
+> See more: {external+charmcraft:ref}`Charmcraft | File charmcraft.yaml <charmcraft-yaml-file>`
 
 ## Set and get data from the peer relation databag
 
diff --git a/docs/tutorial/write-your-first-machine-charm.md b/docs/tutorial/write-your-first-machine-charm.md
index f3c8588..7721404 100644
--- a/docs/tutorial/write-your-first-machine-charm.md
+++ b/docs/tutorial/write-your-first-machine-charm.md
@@ -1,10 +1,17 @@
 (write-your-first-machine-charm)=
 # Write your first machine charm
 
-In this tutorial you will write a [machine charm](https://juju.is/docs/juju/charmed-operator) for Juju using (Charmcraft and) Ops.
+In this tutorial you will write a machine charm for Juju using ([Charmcraft](https://canonical-charmcraft.readthedocs-hosted.com/en/stable/) and) Ops.
 
+<!-- TODO (tam)
+Add this link above:
 
-**What you'll need:** 
+{external+juju:ref}`machine charm <machine-charm>` for Juju 
+
+When it's available in Juju.
+-->
+
+**What you'll need:**
 - A workstation, e.g., a laptop, with amd64 architecture and which has sufficient resources to launch a virtual machine with 4 CPUs, 8 GB RAM, and 50 GB disk space
 - Familiarity with Linux
 - Familiarity with Juju.
@@ -24,7 +31,7 @@ Should you get stuck at any point: Don't hesitate to get in touch on [Matrix](ht
 
 ## Study your application
 
-In this tutorial we will be writing a charm for Microsample (`microsample`) -- a small educational application that delivers a Flask microservice. 
+In this tutorial we will be writing a charm for Microsample (`microsample`) -- a small educational application that delivers a Flask microservice.
 
 The application has been packaged and published as a snap ([https://snapcraft.io/microsample](https://snapcraft.io/microsample)). We will write our charm such that `juju deploy` will install it from this snap. This will make workload installation straightforward and upgrades automatic (as they will happen automatically through `snapd`).
 
@@ -35,20 +42,19 @@ The application has other features that we can exploit, but for now this is enou
 
 ## Set up your development environment
 
-<!-- UPDATE LINKS -->
-> See [Juju | Set up your development environment automatically](https://juju.is/docs/juju/set-up--tear-down-your-test-environment#set-up-automatically) for instructions on how to set up your development environment so that it's ready for you to test-deploy your charm. At the charm directory step, call it `microsample-vm`. At the cloud step, choose LXD. 
+> See more: {external+juju:ref}`Juju | Manage your deployment environment > Automatically <manage-your-deployment-environment>` for instructions on how to set up your development environment so that it's ready for you to test-deploy your charm. At the charm directory step, call it `microsample-vm`. At the cloud step, choose LXD.
 
 ```{important}
 
 -  Going forward:
     - Use your host machine (on Linux, `cd ~/microsample-vm`) to create and edit your charm files. This will allow you to use your favorite local editor.
-    - Use the Multipass VM shell (on Linux, `ubuntu@charm-dev:~$ cd ~/microsample-vm`) to run Charmcraft and Juju commands. 
+    - Use the Multipass VM shell (on Linux, `ubuntu@charm-dev:~$ cd ~/microsample-vm`) to run Charmcraft and Juju commands.
 
 
 - At any point:
-    - To exit the shell, press `mod key + C` or type `exit`. 
-    - To stop the VM after exiting the VM shell, run `multipass stop charm-dev`. 
-    - To restart the VM and re-open a shell into it, type `multipass shell charm-dev`. 
+    - To exit the shell, press `mod key + C` or type `exit`.
+    - To stop the VM after exiting the VM shell, run `multipass stop charm-dev`.
+    - To restart the VM and re-open a shell into it, type `multipass shell charm-dev`.
 
 ```
 
@@ -66,16 +72,16 @@ ubuntu@charm-dev:~$ cd microsample-vm/
 
 # Initialise the charm tree structure:
 ubuntu@charm-dev:~/microsample-vm$ charmcraft init --profile machine
-Charmed operator package file and directory tree initialised.                              
-                                                                                           
-Now edit the following package files to provide fundamental charm metadata                 
-and other information:                                                                     
-                                                                                           
-charmcraft.yaml                                                                            
-src/charm.py                                                                               
-README.md                                                                                  
-                                                                       
-# Inspect the result:                    
+Charmed operator package file and directory tree initialised.
+
+Now edit the following package files to provide fundamental charm metadata
+and other information:
+
+charmcraft.yaml
+src/charm.py
+README.md
+
+# Inspect the result:
 ubuntu@charm-dev:~/microsample-vm$ ls -R
 .:
 CONTRIBUTING.md  README.md        pyproject.toml    src    tox.ini
@@ -95,16 +101,14 @@ test_charm.py
 
 ```
 
-<!--UPDATE LINKS
-> See more: [`charmcraft` | Manage charms](), [`charmcraft` | List of files in a charm project]()
--->
+> See more: {external+charmcraft:ref}`Charmcraft | Manage charms <manage-charms>`, {external+charmcraft:ref}`Charmcraft | Files <files>`
 
 In your local editor, open the `charmcraft.yaml` file and customise its contents as below (you only have to edit the `title`, `summary`, and `description`):
 
 ```yaml
 # (Required)
 name: microsample-vm
- 
+
 # (Required)
 type: charm
 
@@ -118,12 +122,12 @@ summary: A charm that deploys the microsample snap and allows for a configuratio
 description: |
   A machine charm for the Microsample application, built on top of the `microsample` snap.
 
-  The charm allows you to deploy the application via `juju deploy`. 
+  The charm allows you to deploy the application via `juju deploy`.
   It also defines a channel config that allows you to choose which snap channel to install from during deployment.
 
   This charm makes it easy to deploy the Microsample application on any machine cloud.
 
-  The primary value of this charm is educational -- beginner machine charms can study it to learn how to build a machine charm.   
+  The primary value of this charm is educational -- beginner machine charms can study it to learn how to build a machine charm.
 
 # (Required for 'charm' type)
 bases:
@@ -135,11 +139,9 @@ bases:
       channel: "22.04"
 
 ```
-<!-- UPDATE LINKS
-> See more: [`charmcraft` | File `charmcraft.yaml`]()
--->
+> See more: {external+charmcraft:ref}`Charmcraft | File charmcraft.yaml <charmcraft-yaml-file>`
 
-Now open the `src/charm.py` file and update it as below (you'll have to add an import statement for `os` and an observer and handler for the `install` event -- in the definition of which you'll be using `os` and `ops`). 
+Now open the `src/charm.py` file and update it as below (you'll have to add an import statement for `os` and an observer and handler for the `install` event -- in the definition of which you'll be using `os` and `ops`).
 
 ```python
 #!/usr/bin/env python3
@@ -164,16 +166,14 @@ class MicrosampleVmCharm(ops.CharmBase):
         """Handle install event."""
         self.unit.status = ops.MaintenanceStatus("Installing microsample snap")
         os.system(f"snap install microsample --channel edge")
-        self.unit.status = ops.ActiveStatus("Ready")  
+        self.unit.status = ops.ActiveStatus("Ready")
 
 
 if __name__ == "__main__":  # pragma: nocover
     ops.main(MicrosampleVmCharm)  # type: ignore
 ```
 
-<!-- UPDATE LINKS
-> See more: [`charmcraft` | File `src/charm.py`](),  {ref}`run-workloads-with-a-charm-machines`
--->
+> See more: {external+charmcraft:ref}`Charmcraft | File src/charm.py <src-charm-py-file>`,  {ref}`run-workloads-with-a-charm-machines`
 
 Next, in your Multipass VM shell, inside your project directory, run `charmcraft pack` to pack the charm. It may take a few minutes the first time around but, when it's done, your charm project should contain a `.charm` file. Sample session:
 
@@ -181,9 +181,9 @@ Next, in your Multipass VM shell, inside your project directory, run `charmcraft
 ```text
 # Pack the charm into a '.charm' file:
 ubuntu@charm-dev:~/microsample-vm$ charmcraft pack
-Created 'microsample-vm_ubuntu-22.04-amd64.charm'.                                         
-Charms packed:                                                                             
-    microsample-vm_ubuntu-22.04-amd64.charm                                                      
+Created 'microsample-vm_ubuntu-22.04-amd64.charm'.
+Charms packed:
+    microsample-vm_ubuntu-22.04-amd64.charm
 
 # Inspect the results -- your charm's root directory should contain a .charm file:
 ubuntu@charm-dev:~/microsample-vm$ ls
@@ -192,9 +192,7 @@ LICENSE          microsample-vm_ubuntu-22.04-amd64.charm  src
 README.md        pyproject.toml                           tests
 ```
 
-<!-- UPDATE LINKS
-> See more: [`charmcraft` | Manage charms > Pack]()
--->
+> See more: {external+charmcraft:ref}`Charmcraft | Manage charms > Pack <pack-a-charm>`
 
 Now, open a new shell into your Multipass VM and use it to configure the Juju log verbosity levels and to start a live debug session:
 
@@ -214,17 +212,17 @@ ubuntu@charm-dev:~/microsample-vm$ juju deploy ./microsample-vm_ubuntu-22.04-amd
 Located local charm "microsample-vm", revision 0
 Deploying "microsample" from local charm "microsample-vm", revision 0 on ubuntu@22.04/stable
 
-# Check the deployment status 
+# Check the deployment status
 # (use --watch 1s to update it automatically at 1s intervals):
 ubuntu@charm-dev:~/microsample-vm$ juju status
 Model        Controller  Cloud/Region         Version  SLA          Timestamp
 welcome-lxd  lxd         localhost/localhost  3.1.6    unsupported  12:49:26+01:00
 
 App          Version  Status  Scale  Charm           Channel  Rev  Exposed  Message
-microsample           active      1  microsample-vm             0  no       
+microsample           active      1  microsample-vm             0  no
 
 Unit            Workload  Agent  Machine  Public address  Ports  Message
-microsample/0*  active    idle   1        10.122.219.101         
+microsample/0*  active    idle   1        10.122.219.101
 
 Machine  State    Address         Inst id        Base          AZ  Message
 1        started  10.122.219.101  juju-f25b73-1  ubuntu@22.04      Running
@@ -260,7 +258,7 @@ LICENSE  README.md  dispatch  hooks  manifest.yaml  metadata.yaml  revision  src
 root@microsample-vm-0:/var/lib/juju/agents/unit-microsample-vm-0/charm# cd hooks
 root@microsample-vm-0:/var/lib/juju/agents/unit-microsample-vm-0/charm/hooks# ls
 install  start  upgrade-charm
-root@microsample-vm-0:/var/lib/juju/agents/unit-microsample-vm-0/charm/hooks# 
+root@microsample-vm-0:/var/lib/juju/agents/unit-microsample-vm-0/charm/hooks#
 ```
 -->
 
@@ -289,9 +287,8 @@ config:
       type: string
 ```
 
-<!-- UPDATE LINKS
-> See more: [`charmcraft` | File `charmcraft.yaml` > Key `config`]()
--->
+
+> See more: {external+charmcraft:ref}`Charmcraft | File charmcraft.yaml | Key config <recipe-key-config>`
 
 Then, in the `src/charm.py` file, update the `_on_install` function to make use of the new configuration option, as below:
 
@@ -313,9 +310,9 @@ Now, in your Multipass VM shell, inside your project directory, pack the charm,
 
 # Pack the charm:
 ubuntu@charm-dev:~/microsample-vm$ charmcraft pack
-Created 'microsample-vm_ubuntu-22.04-amd64.charm'.                                        
-Charms packed:                                                                            
-    microsample-vm_ubuntu-22.04-amd64.charm                                               
+Created 'microsample-vm_ubuntu-22.04-amd64.charm'.
+Charms packed:
+    microsample-vm_ubuntu-22.04-amd64.charm
 
 # Refresh the application from the repacked charm:
 ubuntu@charm-dev:~/microsample-vm$ juju refresh microsample --path=./microsample-vm_ubuntu-22.04-amd64.charm
@@ -324,16 +321,16 @@ Added local charm "microsample-vm", revision 1, to the model
 # Verify that the new configuration option is available:
 ubuntu@charm-dev:~/microsample-vm$ juju config microsample
 application: microsample
-application-config: 
-  trust: 
+application-config:
+  trust:
     default: false
     description: Does this application have access to trusted credentials
     source: default
     type: bool
     value: false
 charm: microsample-vm
-settings: 
-  channel: 
+settings:
+  channel:
     default: edge
     description: |
       Channel for the microsample snap.
@@ -367,11 +364,11 @@ Now, in your Multipass VM shell, inside your project directory, pack the charm,
 ```text
 # Pack the charm:
 ubuntu@charm-dev:~/microsample-vm$ charmcraft pack
-Created 'microsample-vm_ubuntu-22.04-amd64.charm'.                                        
-Charms packed:                                                                            
-    microsample-vm_ubuntu-22.04-amd64.charm              
+Created 'microsample-vm_ubuntu-22.04-amd64.charm'.
+Charms packed:
+    microsample-vm_ubuntu-22.04-amd64.charm
 
-# Refresh the application:                                 
+# Refresh the application:
 ubuntu@charm-dev:~/microsample-vm$ juju refresh microsample --path=./microsample-vm_ubuntu-22.04-amd64.charm
 Added local charm "microsample-vm", revision 2, to the model
 
@@ -379,7 +376,7 @@ Added local charm "microsample-vm", revision 2, to the model
 ubuntu@charm-dev:~/microsample-vm$ juju config microsample channel=beta
 
 # Inspect the Message column
-# ('Ready at beta' is what we expect to see if the snap channel has been changed to 'beta'): 
+# ('Ready at beta' is what we expect to see if the snap channel has been changed to 'beta'):
 ubuntu@charm-dev:~/microsample-vm$ juju status
 Model        Controller  Cloud/Region         Version  SLA          Timestamp
 welcome-lxd  lxd         localhost/localhost  3.1.6    unsupported  13:54:53+01:00
@@ -496,9 +493,9 @@ Finally, in your Multipass VM shell, pack the charm, refresh it in Juju, and che
 ```text
 # Pack the charm:
 ubuntu@charm-dev:~/microsample-vm$ charmcraft pack
-Created 'microsample-vm_ubuntu-22.04-amd64.charm'.                                        
-Charms packed:                                                                            
-    microsample-vm_ubuntu-22.04-amd64.charm                                               
+Created 'microsample-vm_ubuntu-22.04-amd64.charm'.
+Charms packed:
+    microsample-vm_ubuntu-22.04-amd64.charm
 
 # Refresh the application:
 ubuntu@charm-dev:~/microsample-vm$ juju refresh microsample --path=./microsample-vm_ubuntu-22.04-amd64.charm
diff --git a/ops/framework.py b/ops/framework.py
index 1d49770..64cfa16 100644
--- a/ops/framework.py
+++ b/ops/framework.py
@@ -892,10 +892,8 @@ class Framework(Object):
                     observer_path,
                     method_name,
                 )
-                # We don't need to save a new notice and snapshot, but we do
-                # want the event to run, because it has been saved previously
-                # and not completed.
-                saved = True
+                # Note that there may be other (event+observer) pairs that are
+                # not in the queue, and so may need to save the snapshot.
                 continue
             if not saved:
                 # Save the event for all known observers before the first notification


Test patch:
diff --git a/docs/howto/write-integration-tests-for-a-charm.md b/docs/howto/write-integration-tests-for-a-charm.md
index a0801d2..8f79404 100644
--- a/docs/howto/write-integration-tests-for-a-charm.md
+++ b/docs/howto/write-integration-tests-for-a-charm.md
@@ -13,7 +13,7 @@ Integration testing is only one part of a comprehensive testing strategy. See {r
 
 The instructions all use the Juju `python-libjuju` client, either through the `pytest-operator` library or directly.
 
-> See more: [`python-libjuju`](https://github.com/charmed-kubernetes/pytest-operator), {ref}`pytest-operator`
+> See more: [`python-libjuju`](https://pythonlibjuju.readthedocs.io/en/latest/), {ref}`pytest-operator`
 
 ## Prepare your environment
 
@@ -100,9 +100,7 @@ As an alternative to `wait_for_idle`, you can explicitly block until the applica
 
 ### Deploy your charm with resources
 
-<!-- UPDATE LINKS
-> See also: [Resource (Charm)](https://juju.is/docs/juju/charm-resource)
--->
+> See first: `manage-resources`
 
 A charm can require `file` or `oci-image` `resources` to work, that can be provided to `ops_test.model.deploy`. In Charmhub, resources have revision numbers. For file resources already stored in Charmhub, you can use `ops_test.download_resources`:
 
@@ -156,10 +154,7 @@ async def test_my_integration(ops_test: OpsTest):
 
 ### Test a configuration
 
-<!-- UPDATE LINKS
-
-> See also: [Configuration]()
--->
+> See first: {ref}`manage-configurations`
 
 You can set a configuration option in your application and check its results. 
 
diff --git a/test/test_framework.py b/test/test_framework.py
index 184094c..d8e7c55 100644
--- a/test/test_framework.py
+++ b/test/test_framework.py
@@ -85,6 +85,18 @@ def fake_script(request: pytest.FixtureRequest) -> FakeScript:
     return FakeScript(request)
 
 
+class SimpleEventWithData(ops.EventBase):
+    def __init__(self, handle: ops.Handle, data: str):
+        super().__init__(handle)
+        self.data: str = data
+
+    def restore(self, snapshot: typing.Dict[str, typing.Any]):
+        self.data = typing.cast(str, snapshot['data'])
+
+    def snapshot(self) -> typing.Dict[str, typing.Any]:
+        return {'data': self.data}
+
+
 class TestFramework:
     def test_deprecated_init(self, caplog: pytest.LogCaptureFixture):
         # For 0.7, this still works, but it is deprecated.
@@ -372,23 +384,12 @@ class TestFramework:
     def test_defer_and_reemit(self, request: pytest.FixtureRequest):
         framework = create_framework(request)
 
-        class MyEvent(ops.EventBase):
-            def __init__(self, handle: ops.Handle, data: str):
-                super().__init__(handle)
-                self.data: str = data
-
-            def restore(self, snapshot: typing.Dict[str, typing.Any]):
-                self.data = typing.cast(str, snapshot['data'])
-
-            def snapshot(self) -> typing.Dict[str, typing.Any]:
-                return {'data': self.data}
-
         class MyNotifier1(ops.Object):
-            a = ops.EventSource(MyEvent)
-            b = ops.EventSource(MyEvent)
+            a = ops.EventSource(SimpleEventWithData)
+            b = ops.EventSource(SimpleEventWithData)
 
         class MyNotifier2(ops.Object):
-            c = ops.EventSource(MyEvent)
+            c = ops.EventSource(SimpleEventWithData)
 
         class MyObserver(ops.Object):
             def __init__(self, parent: ops.Object, key: str):
@@ -453,23 +454,12 @@ class TestFramework:
         class MyEvent(ops.EventBase):
             data: typing.Optional[str] = None
 
-        class MyDataEvent(MyEvent):
-            def __init__(self, handle: ops.Handle, data: str):
-                super().__init__(handle)
-                self.data: typing.Optional[str] = data
-
-            def restore(self, snapshot: typing.Dict[str, typing.Any]):
-                self.data = typing.cast(typing.Optional[str], snapshot['data'])
-
-            def snapshot(self) -> typing.Dict[str, typing.Any]:
-                return {'data': self.data}
-
         class ReleaseEvent(ops.EventBase):
             pass
 
         class MyNotifier(ops.Object):
             n = ops.EventSource(MyEvent)
-            d = ops.EventSource(MyDataEvent)
+            d = ops.EventSource(SimpleEventWithData)
             r = ops.EventSource(ReleaseEvent)
 
         class MyObserver(ops.Object):
@@ -554,6 +544,73 @@ class TestFramework:
         assert len(notices_for_observer(1)) == 0
         assert len(notices_for_observer(2)) == 4
 
+    def test_two_observers_one_deferring(self, request: pytest.FixtureRequest):
+        framework = create_framework(request)
+
+        class MyNotifier(ops.Object):
+            my_event = ops.EventSource(SimpleEventWithData)
+
+        class RecordingObserver(ops.Object):
+            def __init__(self, parent: ops.Object, key: str):
+                super().__init__(parent, key)
+                self.events: typing.List[str] = []
+
+            def on_event(self, _: SimpleEventWithData):
+                self.events.append(self.__class__.__name__)
+
+        class DeferringObserver(RecordingObserver):
+            defer = True
+
+            def on_event(self, event: SimpleEventWithData):
+                super().on_event(event)
+                if self.defer:
+                    event.defer()
+
+        pub = MyNotifier(framework, 'my_event')
+        obs1 = DeferringObserver(framework, '1')
+        obs2 = RecordingObserver(framework, '2')
+
+        framework.observe(pub.my_event, obs1.on_event)
+        framework.observe(pub.my_event, obs2.on_event)
+
+        # We always reemit() to handle the deferred events and then do the
+        # actual emit(). Create a helper function to do this.
+        def emit():
+            framework.reemit()
+            pub.my_event.emit('foo')
+
+        # Emit an event, which will be deferred by one observer, and not by the
+        # other. We should have a single notice with a corresponding snapshot,
+        # which is the deferred event, and each observer will have seen the
+        # event once.
+        emit()
+        notices = tuple(framework._storage.notices())
+        assert len(notices) == 1
+        assert framework._storage.load_snapshot(notices[0][0]) == {'data': 'foo'}
+        assert len(obs1.events) == len(obs2.events) == 1
+
+        # If we emit the event another time, we'll still have one notice and
+        # snapshot, and each observer will have seen the event twice.
+        emit()
+        notices = tuple(framework._storage.notices())
+        assert len(notices) == 1
+        assert framework._storage.load_snapshot(notices[0][0]) == {'data': 'foo'}
+        assert len(obs1.events) == len(obs2.events) == 2
+
+        # If we emit the event with neither observer deferring, we'll have no
+        # remaining notice or snapshot. The observer that didn't defer will have
+        # seen the event a straightforward 3 times. The observer that did defer
+        # will have seen it once more - in this final case, it runs the deferred
+        # event, which clears the queue, and then it runs the actual event
+        # (not skipping because the queue is empty at that point).
+        obs1.defer = False
+        emit()
+        assert len(obs1.events) == 4
+        assert len(obs2.events) == 3
+        notices = tuple(framework._storage.notices())
+        assert len(notices) == 0
+        assert len(tuple(framework._storage.list_snapshots())) == 0
+
     def test_custom_event_data(self, request: pytest.FixtureRequest):
         framework = create_framework(request)