Repository: matrix-org/synapse

Issue:
Thread bundled relationships can return a split-brained latest_event
### Description

The latest_event returned in an `m.thread` bundled relationship can be split-brained if it was edited, the content got updated but the `m.replace` relation is not signalled in any way, which means clients now have to manually fetch the relations on the latest_event to check whether it was edited or not.

`/event/` call on the thread root `await mxMatrixClientPeg.matrixClient.fetchRoomEvent("!cCpRBTFuXwPmOCUFRL:matrix.org", "$2fQ-tX_N6eXHllDPHN3gYD_2ghfbZkGfjvjQdUqxtFQ")`
```json
{
  "content": {
    "body": "root",
    "msgtype": "m.text",
    "org.matrix.msc1767.text": "root"
  },
  "origin_server_ts": 1647960269875,
  "room_id": "!cCpRBTFuXwPmOCUFRL:matrix.org",
  "sender": "@webdevguru_test31:matrix.org",
  "type": "m.room.message",
  "unsigned": {
    "age": 66278821,
    "m.relations": {
      "m.thread": {
        "latest_event": {
          "content": {
            "body": "testfoobar",
            "format": "org.matrix.custom.html",
            "formatted_body": "testfoobar",
            "msgtype": "m.text",
            "org.matrix.msc1767.message": [
              {
                "body": "testfoobar",
                "mimetype": "text/plain"
              },
              {
                "body": "testfoobar",
                "mimetype": "text/html"
              }
            ],
            "m.relates_to": {
              "event_id": "$2fQ-tX_N6eXHllDPHN3gYD_2ghfbZkGfjvjQdUqxtFQ",
              "is_falling_back": true,
              "m.in_reply_to": {
                "event_id": "$6WWJ7Hga3ZtlTehlThl-v2_doVAFs5EaqgCoffLM9gc"
              },
              "rel_type": "m.thread"
            }
          },
          "origin_server_ts": 1648024628463,
          "room_id": "!cCpRBTFuXwPmOCUFRL:matrix.org",
          "sender": "@webdevguru_test31:matrix.org",
          "type": "m.room.message",
          "unsigned": {
            "age": 1920233
          },
          "event_id": "$e8ENHc50vigxQU3NgeZ6I5A68dL1IGqouonJb500jpk",
          "user_id": "@webdevguru_test31:matrix.org",
          "age": 1920233
        },
        "count": 7,
        "current_user_participated": true
      }
    }
  },
  "event_id": "$2fQ-tX_N6eXHllDPHN3gYD_2ghfbZkGfjvjQdUqxtFQ",
  "user_id": "@webdevguru_test31:matrix.org",
  "age": 66278821
}
```

`/event/` call on the latest event in the thread `await mxMatrixClientPeg.matrixClient.fetchRoomEvent("!cCpRBTFuXwPmOCUFRL:matrix.org", "$e8ENHc50vigxQU3NgeZ6I5A68dL1IGqouonJb500jpk")`
```json
{
  "content": {
    "body": "testfoobar",
    "format": "org.matrix.custom.html",
    "formatted_body": "testfoobar",
    "msgtype": "m.text",
    "org.matrix.msc1767.message": [
      {
        "body": "testfoobar",
        "mimetype": "text/plain"
      },
      {
        "body": "testfoobar",
        "mimetype": "text/html"
      }
    ],
    "m.relates_to": {
      "event_id": "$2fQ-tX_N6eXHllDPHN3gYD_2ghfbZkGfjvjQdUqxtFQ",
      "is_falling_back": true,
      "m.in_reply_to": {
        "event_id": "$6WWJ7Hga3ZtlTehlThl-v2_doVAFs5EaqgCoffLM9gc"
      },
      "rel_type": "m.thread"
    }
  },
  "origin_server_ts": 1648024628463,
  "room_id": "!cCpRBTFuXwPmOCUFRL:matrix.org",
  "sender": "@webdevguru_test31:matrix.org",
  "type": "m.room.message",
  "unsigned": {
    "age": 1940276,
    "m.relations": {
      "m.replace": {
        "event_id": "$8fzcKK9FohMXMGhvGM13dYoLM1S6Ekgw8Oz2ojW2dl8",
        "origin_server_ts": 1648025699050,
        "sender": "@webdevguru_test31:matrix.org"
      }
    }
  },
  "event_id": "$e8ENHc50vigxQU3NgeZ6I5A68dL1IGqouonJb500jpk",
  "user_id": "@webdevguru_test31:matrix.org",
  "age": 1940276
}
```

Observe the event ID and content are the same, but the latter includes the `m.replace` relation which should be sanely returned in the first request also.

### Steps to reproduce

- Create a thread with root $root
- Reply to thread $root with event $event (content A)
- Edit event $event with event $edit (content B)
- Ask the server for $root via /event/ API and receive latest_event $event with content B and no details about m.replace or $edit

I would have expected latest_event to either be entirely unedited or edited with the relation.

### Version information

matrix.org



Discussion:
The relations API correctly returns the expected data here

```json
{
  "chunk": [
    {
      "content": {
        "body": "testfoobar1",
        "format": "org.matrix.custom.html",
        "formatted_body": "testfoobar1",
        "msgtype": "m.text",
        "org.matrix.msc1767.message": [
          {
            "body": "testfoobar1",
            "mimetype": "text/plain"
          },
          {
            "body": "testfoobar1",
            "mimetype": "text/html"
          }
        ],
        "m.relates_to": {
          "event_id": "$2fQ-tX_N6eXHllDPHN3gYD_2ghfbZkGfjvjQdUqxtFQ",
          "is_falling_back": true,
          "m.in_reply_to": {
            "event_id": "$6WWJ7Hga3ZtlTehlThl-v2_doVAFs5EaqgCoffLM9gc"
          },
          "rel_type": "m.thread"
        }
      },
      "origin_server_ts": 1648024628463,
      "room_id": "!cCpRBTFuXwPmOCUFRL:matrix.org",
      "sender": "@webdevguru_test31:matrix.org",
      "type": "m.room.message",
      "unsigned": {
        "age": 5181292,
        "m.relations": {
          "m.replace": {
            "event_id": "$S8TYTeiuN7aztAIOvVRRq-yE5bi36XmXJ1cZD2MQaAw",
            "origin_server_ts": 1648028879518,
            "sender": "@webdevguru_test31:matrix.org"
          }
        }
      },
      "event_id": "$e8ENHc50vigxQU3NgeZ6I5A68dL1IGqouonJb500jpk",
      "user_id": "@webdevguru_test31:matrix.org",
      "age": 5181292
    },
    {
      "content": {
        "body": "578",
        "format": "org.matrix.custom.html",
        "formatted_body": "578",
        "msgtype": "m.text",
        "org.matrix.msc1767.message": [
          {
            "body": "578",
            "mimetype": "text/plain"
          },
          {
            "body": "578",
            "mimetype": "text/html"
          }
        ],
        "m.relates_to": {
          "event_id": "$2fQ-tX_N6eXHllDPHN3gYD_2ghfbZkGfjvjQdUqxtFQ",
          "is_falling_back": true,
          "m.in_reply_to": {
            "event_id": "$0t3DFsi2AcLsaNoOWmABioDQfRviL_lX2jEMK3jV1vI"
          },
          "rel_type": "m.thread"
        }
      },
      "origin_server_ts": 1648024407578,
      "room_id": "!cCpRBTFuXwPmOCUFRL:matrix.org",
      "sender": "@webdevguru_test31:matrix.org",
      "type": "m.room.message",
      "unsigned": {
        "age": 5402177,
        "m.relations": {
          "m.replace": {
            "event_id": "$zE3_wtcVjdVBzBRVycEBWu5MvuyuOM8P7MmA1C8WFEs",
            "origin_server_ts": 1648024533680,
            "sender": "@webdevguru_test31:matrix.org"
          }
        }
      },
      "event_id": "$6WWJ7Hga3ZtlTehlThl-v2_doVAFs5EaqgCoffLM9gc",
      "user_id": "@webdevguru_test31:matrix.org",
      "age": 5402177
    },
    {
      "content": {
        "body": "45",
        "format": "org.matrix.custom.html",
        "formatted_body": "45",
        "msgtype": "m.text",
        "org.matrix.msc1767.message": [
          {
            "body": "45",
            "mimetype": "text/plain"
          },
          {
            "body": "45",
            "mimetype": "text/html"
          }
        ],
        "m.relates_to": {
          "event_id": "$2fQ-tX_N6eXHllDPHN3gYD_2ghfbZkGfjvjQdUqxtFQ",
          "is_falling_back": true,
          "m.in_reply_to": {
            "event_id": "$fMyeT873xDGthUi_muAtmyb7retSUsjE8C6hXTT-dKY"
          },
          "rel_type": "m.thread"
        }
      },
      "origin_server_ts": 1648024169964,
      "room_id": "!cCpRBTFuXwPmOCUFRL:matrix.org",
      "sender": "@webdevguru_test31:matrix.org",
      "type": "m.room.message",
      "unsigned": {
        "age": 5639791,
        "m.relations": {
          "m.replace": {
            "event_id": "$12Rnowgxo2Nq6LU8osif1c6efs142nSH2jTB-63q46w",
            "origin_server_ts": 1648024171546,
            "sender": "@webdevguru_test31:matrix.org"
          }
        }
      },
      "event_id": "$0t3DFsi2AcLsaNoOWmABioDQfRviL_lX2jEMK3jV1vI",
      "user_id": "@webdevguru_test31:matrix.org",
      "age": 5639791
    },
    {
      "content": {
        "body": "123",
        "m.relates_to": {
          "event_id": "$2fQ-tX_N6eXHllDPHN3gYD_2ghfbZkGfjvjQdUqxtFQ",
          "is_falling_back": true,
          "m.in_reply_to": {
            "event_id": "$x_AcYE4IMA7ecOO2Ewqn-PtsDeNZzEM02ZiMyat1IpI"
          },
          "rel_type": "m.thread"
        },
        "msgtype": "m.text",
        "org.matrix.msc1767.text": "123"
      },
      "origin_server_ts": 1648024168073,
      "room_id": "!cCpRBTFuXwPmOCUFRL:matrix.org",
      "sender": "@webdevguru_test31:matrix.org",
      "type": "m.room.message",
      "unsigned": {
        "age": 5641682
      },
      "event_id": "$fMyeT873xDGthUi_muAtmyb7retSUsjE8C6hXTT-dKY",
      "user_id": "@webdevguru_test31:matrix.org",
      "age": 5641682
    },
    {
      "content": {
        "body": "1234",
        "format": "org.matrix.custom.html",
        "formatted_body": "1234",
        "msgtype": "m.text",
        "org.matrix.msc1767.message": [
          {
            "body": "1234",
            "mimetype": "text/plain"
          },
          {
            "body": "1234",
            "mimetype": "text/html"
          }
        ],
        "m.relates_to": {
          "event_id": "$2fQ-tX_N6eXHllDPHN3gYD_2ghfbZkGfjvjQdUqxtFQ",
          "is_falling_back": true,
          "m.in_reply_to": {
            "event_id": "$UCY9RizsSWxNJxiWKJrvZ-V0PC6RA0ecHc31rNRoeTU"
          },
          "rel_type": "m.thread"
        }
      },
      "origin_server_ts": 1648022929064,
      "room_id": "!cCpRBTFuXwPmOCUFRL:matrix.org",
      "sender": "@webdevguru_test31:matrix.org",
      "type": "m.room.message",
      "unsigned": {
        "age": 6880691,
        "m.relations": {
          "m.replace": {
            "event_id": "$hE9X0x0kBFmjP1dTBk0ltFQ-wer4RXt4x2smE4QUw0E",
            "origin_server_ts": 1648022972081,
            "sender": "@webdevguru_test31:matrix.org"
          }
        }
      },
      "event_id": "$x_AcYE4IMA7ecOO2Ewqn-PtsDeNZzEM02ZiMyat1IpI",
      "user_id": "@webdevguru_test31:matrix.org",
      "age": 6880691
    }
  ],
  "next_batch": "t62-2793387561_0_0_0_0_0_0_0_0",
  "original_event": {
    "content": {
      "body": "root",
      "msgtype": "m.text",
      "org.matrix.msc1767.text": "root"
    },
    "origin_server_ts": 1647960269875,
    "room_id": "!cCpRBTFuXwPmOCUFRL:matrix.org",
    "sender": "@webdevguru_test31:matrix.org",
    "type": "m.room.message",
    "unsigned": {
      "age": 69539880
    },
    "event_id": "$2fQ-tX_N6eXHllDPHN3gYD_2ghfbZkGfjvjQdUqxtFQ",
    "user_id": "@webdevguru_test31:matrix.org",
    "age": 69539880
  }
}
We don't bundle any relations for the latest event, we should be able to inject that the edit occurred though.

Resolving patch:
diff --git a/synapse/events/utils.py b/synapse/events/utils.py
--- a/synapse/events/utils.py
+++ b/synapse/events/utils.py
@@ -426,13 +426,12 @@ def serialize_event(
 
         # Check if there are any bundled aggregations to include with the event.
         if bundle_aggregations:
-            event_aggregations = bundle_aggregations.get(event.event_id)
-            if event_aggregations:
+            if event.event_id in bundle_aggregations:
                 self._inject_bundled_aggregations(
                     event,
                     time_now,
                     config,
-                    event_aggregations,
+                    bundle_aggregations,
                     serialized_event,
                     apply_edits=apply_edits,
                 )
@@ -471,7 +470,7 @@ def _inject_bundled_aggregations(
         event: EventBase,
         time_now: int,
         config: SerializeEventConfig,
-        aggregations: "BundledAggregations",
+        bundled_aggregations: Dict[str, "BundledAggregations"],
         serialized_event: JsonDict,
         apply_edits: bool,
     ) -> None:
@@ -481,22 +480,37 @@ def _inject_bundled_aggregations(
             event: The event being serialized.
             time_now: The current time in milliseconds
             config: Event serialization config
-            aggregations: The bundled aggregation to serialize.
+            bundled_aggregations: Bundled aggregations to be injected.
+                A map from event_id to aggregation data. Must contain at least an
+                entry for `event`.
+
+                While serializing the bundled aggregations this map may be searched
+                again for additional events in a recursive manner.
             serialized_event: The serialized event which may be modified.
             apply_edits: Whether the content of the event should be modified to reflect
                any replacement in `aggregations.replace`.
         """
+
+        # We have already checked that aggregations exist for this event.
+        event_aggregations = bundled_aggregations[event.event_id]
+
+        # The JSON dictionary to be added under the unsigned property of the event
+        # being serialized.
         serialized_aggregations = {}
 
-        if aggregations.annotations:
-            serialized_aggregations[RelationTypes.ANNOTATION] = aggregations.annotations
+        if event_aggregations.annotations:
+            serialized_aggregations[
+                RelationTypes.ANNOTATION
+            ] = event_aggregations.annotations
 
-        if aggregations.references:
-            serialized_aggregations[RelationTypes.REFERENCE] = aggregations.references
+        if event_aggregations.references:
+            serialized_aggregations[
+                RelationTypes.REFERENCE
+            ] = event_aggregations.references
 
-        if aggregations.replace:
+        if event_aggregations.replace:
             # If there is an edit, optionally apply it to the event.
-            edit = aggregations.replace
+            edit = event_aggregations.replace
             if apply_edits:
                 self._apply_edit(event, serialized_event, edit)
 
@@ -507,19 +521,16 @@ def _inject_bundled_aggregations(
                 "sender": edit.sender,
             }
 
-        # If this event is the start of a thread, include a summary of the replies.
-        if aggregations.thread:
-            thread = aggregations.thread
+        # Include any threaded replies to this event.
+        if event_aggregations.thread:
+            thread = event_aggregations.thread
 
-            # Don't bundle aggregations as this could recurse forever.
-            serialized_latest_event = serialize_event(
-                thread.latest_event, time_now, config=config
+            serialized_latest_event = self.serialize_event(
+                thread.latest_event,
+                time_now,
+                config=config,
+                bundle_aggregations=bundled_aggregations,
             )
-            # Manually apply an edit, if one exists.
-            if thread.latest_edit:
-                self._apply_edit(
-                    thread.latest_event, serialized_latest_event, thread.latest_edit
-                )
 
             thread_summary = {
                 "latest_event": serialized_latest_event,
diff --git a/synapse/handlers/relations.py b/synapse/handlers/relations.py
--- a/synapse/handlers/relations.py
+++ b/synapse/handlers/relations.py
@@ -44,8 +44,6 @@
 class _ThreadAggregation:
     # The latest event in the thread.
     latest_event: EventBase
-    # The latest edit to the latest event in the thread.
-    latest_edit: Optional[EventBase]
     # The total number of events in the thread.
     count: int
     # True if the current user has sent an event to the thread.
@@ -295,7 +293,7 @@ async def get_threads_for_events(
 
         for event_id, summary in summaries.items():
             if summary:
-                thread_count, latest_thread_event, edit = summary
+                thread_count, latest_thread_event = summary
 
                 # Subtract off the count of any ignored users.
                 for ignored_user in ignored_users:
@@ -340,7 +338,6 @@ async def get_threads_for_events(
 
                 results[event_id] = _ThreadAggregation(
                     latest_event=latest_thread_event,
-                    latest_edit=edit,
                     count=thread_count,
                     # If there's a thread summary it must also exist in the
                     # participated dictionary.
@@ -359,8 +356,13 @@ async def get_bundled_aggregations(
             user_id: The user requesting the bundled aggregations.
 
         Returns:
-            A map of event ID to the bundled aggregation for the event. Not all
-            events may have bundled aggregations in the results.
+            A map of event ID to the bundled aggregations for the event.
+
+            Not all requested events may exist in the results (if they don't have
+            bundled aggregations).
+
+            The results may include additional events which are related to the
+            requested events.
         """
         # De-duplicate events by ID to handle the same event requested multiple times.
         #
@@ -369,22 +371,59 @@ async def get_bundled_aggregations(
             event.event_id: event for event in events if not event.is_state()
         }
 
+        # A map of event ID to the relation in that event, if there is one.
+        relations_by_id: Dict[str, str] = {}
+        for event_id, event in events_by_id.items():
+            relates_to = event.content.get("m.relates_to")
+            if isinstance(relates_to, collections.abc.Mapping):
+                relation_type = relates_to.get("rel_type")
+                if isinstance(relation_type, str):
+                    relations_by_id[event_id] = relation_type
+
         # event ID -> bundled aggregation in non-serialized form.
         results: Dict[str, BundledAggregations] = {}
 
         # Fetch any ignored users of the requesting user.
         ignored_users = await self._main_store.ignored_users(user_id)
 
+        # Threads are special as the latest event of a thread might cause additional
+        # events to be fetched. Thus, we check those first!
+
+        # Fetch thread summaries (but only for the directly requested events).
+        threads = await self.get_threads_for_events(
+            # It is not valid to start a thread on an event which itself relates to another event.
+            [eid for eid in events_by_id.keys() if eid not in relations_by_id],
+            user_id,
+            ignored_users,
+        )
+        for event_id, thread in threads.items():
+            results.setdefault(event_id, BundledAggregations()).thread = thread
+
+            # If the latest event in a thread is not already being fetched,
+            # add it. This ensures that the bundled aggregations for the
+            # latest thread event is correct.
+            latest_thread_event = thread.latest_event
+            if latest_thread_event and latest_thread_event.event_id not in events_by_id:
+                events_by_id[latest_thread_event.event_id] = latest_thread_event
+                # Keep relations_by_id in sync with events_by_id:
+                #
+                # We know that the latest event in a thread has a thread relation
+                # (as that is what makes it part of the thread).
+                relations_by_id[latest_thread_event.event_id] = RelationTypes.THREAD
+
         # Fetch other relations per event.
         for event in events_by_id.values():
-            # Do not bundle aggregations for an event which represents an edit or an
-            # annotation. It does not make sense for them to have related events.
-            relates_to = event.content.get("m.relates_to")
-            if isinstance(relates_to, collections.abc.Mapping):
-                relation_type = relates_to.get("rel_type")
-                if relation_type in (RelationTypes.ANNOTATION, RelationTypes.REPLACE):
-                    continue
-
+            # An event which is a replacement (ie edit) or annotation (ie, reaction)
+            # may not have any other event related to it.
+            #
+            # XXX This is buggy, see https://github.com/matrix-org/synapse/issues/12566
+            if relations_by_id.get(event.event_id) in (
+                RelationTypes.ANNOTATION,
+                RelationTypes.REPLACE,
+            ):
+                continue
+
+            # Fetch any annotations (ie, reactions) to bundle with this event.
             annotations = await self.get_annotations_for_event(
                 event.event_id, event.room_id, ignored_users=ignored_users
             )
@@ -393,6 +432,7 @@ async def get_bundled_aggregations(
                     event.event_id, BundledAggregations()
                 ).annotations = {"chunk": annotations}
 
+            # Fetch any references to bundle with this event.
             references, next_token = await self.get_relations_for_event(
                 event.event_id,
                 event,
@@ -425,10 +465,4 @@ async def get_bundled_aggregations(
         for event_id, edit in edits.items():
             results.setdefault(event_id, BundledAggregations()).replace = edit
 
-        threads = await self.get_threads_for_events(
-            events_by_id.keys(), user_id, ignored_users
-        )
-        for event_id, thread in threads.items():
-            results.setdefault(event_id, BundledAggregations()).thread = thread
-
         return results
diff --git a/synapse/storage/databases/main/relations.py b/synapse/storage/databases/main/relations.py
--- a/synapse/storage/databases/main/relations.py
+++ b/synapse/storage/databases/main/relations.py
@@ -445,8 +445,8 @@ def get_thread_summary(self, event_id: str) -> Optional[Tuple[int, EventBase]]:
     @cachedList(cached_method_name="get_thread_summary", list_name="event_ids")
     async def get_thread_summaries(
         self, event_ids: Collection[str]
-    ) -> Dict[str, Optional[Tuple[int, EventBase, Optional[EventBase]]]]:
-        """Get the number of threaded replies, the latest reply (if any), and the latest edit for that reply for the given event.
+    ) -> Dict[str, Optional[Tuple[int, EventBase]]]:
+        """Get the number of threaded replies and the latest reply (if any) for the given events.
 
         Args:
             event_ids: Summarize the thread related to this event ID.
@@ -458,7 +458,6 @@ async def get_thread_summaries(
             Each summary is a tuple of:
                 The number of events in the thread.
                 The most recent event in the thread.
-                The most recent edit to the most recent event in the thread, if applicable.
         """
 
         def _get_thread_summaries_txn(
@@ -544,9 +543,6 @@ def _get_thread_summaries_txn(
 
         latest_events = await self.get_events(latest_event_ids.values())  # type: ignore[attr-defined]
 
-        # Check to see if any of those events are edited.
-        latest_edits = await self.get_applicable_edits(latest_event_ids.values())
-
         # Map to the event IDs to the thread summary.
         #
         # There might not be a summary due to there not being a thread or
@@ -557,8 +553,7 @@ def _get_thread_summaries_txn(
 
             summary = None
             if latest_event:
-                latest_edit = latest_edits.get(latest_event_id)
-                summary = (counts[parent_event_id], latest_event, latest_edit)
+                summary = (counts[parent_event_id], latest_event)
             summaries[parent_event_id] = summary
 
         return summaries


Test patch:
diff --git a/tests/rest/client/test_relations.py b/tests/rest/client/test_relations.py
--- a/tests/rest/client/test_relations.py
+++ b/tests/rest/client/test_relations.py
@@ -1029,7 +1029,106 @@ def assert_thread(bundled_aggregations: JsonDict) -> None:
                 bundled_aggregations.get("latest_event"),
             )
 
-        self._test_bundled_aggregations(RelationTypes.THREAD, assert_thread, 9)
+        self._test_bundled_aggregations(RelationTypes.THREAD, assert_thread, 10)
+
+    def test_thread_with_bundled_aggregations_for_latest(self) -> None:
+        """
+        Bundled aggregations should get applied to the latest thread event.
+        """
+        self._send_relation(RelationTypes.THREAD, "m.room.test")
+        channel = self._send_relation(RelationTypes.THREAD, "m.room.test")
+        thread_2 = channel.json_body["event_id"]
+
+        self._send_relation(
+            RelationTypes.ANNOTATION, "m.reaction", "a", parent_id=thread_2
+        )
+
+        def assert_thread(bundled_aggregations: JsonDict) -> None:
+            self.assertEqual(2, bundled_aggregations.get("count"))
+            self.assertTrue(bundled_aggregations.get("current_user_participated"))
+            # The latest thread event has some fields that don't matter.
+            self.assert_dict(
+                {
+                    "content": {
+                        "m.relates_to": {
+                            "event_id": self.parent_id,
+                            "rel_type": RelationTypes.THREAD,
+                        }
+                    },
+                    "event_id": thread_2,
+                    "sender": self.user_id,
+                    "type": "m.room.test",
+                },
+                bundled_aggregations.get("latest_event"),
+            )
+            # Check the unsigned field on the latest event.
+            self.assert_dict(
+                {
+                    "m.relations": {
+                        RelationTypes.ANNOTATION: {
+                            "chunk": [
+                                {"type": "m.reaction", "key": "a", "count": 1},
+                            ]
+                        },
+                    }
+                },
+                bundled_aggregations["latest_event"].get("unsigned"),
+            )
+
+        self._test_bundled_aggregations(RelationTypes.THREAD, assert_thread, 10)
+
+    def test_nested_thread(self) -> None:
+        """
+        Ensure that a nested thread gets ignored by bundled aggregations, as
+        those are forbidden.
+        """
+
+        # Start a thread.
+        channel = self._send_relation(RelationTypes.THREAD, "m.room.test")
+        reply_event_id = channel.json_body["event_id"]
+
+        # Disable the validation to pretend this came over federation, since it is
+        # not an event the Client-Server API will allow..
+        with patch(
+            "synapse.handlers.message.EventCreationHandler._validate_event_relation",
+            new=lambda self, event: make_awaitable(None),
+        ):
+            # Create a sub-thread off the thread, which is not allowed.
+            self._send_relation(
+                RelationTypes.THREAD, "m.room.test", parent_id=reply_event_id
+            )
+
+        # Fetch the thread root, to get the bundled aggregation for the thread.
+        relations_from_event = self._get_bundled_aggregations()
+
+        # Ensure that requesting the room messages also does not return the sub-thread.
+        channel = self.make_request(
+            "GET",
+            f"/rooms/{self.room}/messages?dir=b",
+            access_token=self.user_token,
+        )
+        self.assertEqual(200, channel.code, channel.json_body)
+        event = self._find_event_in_chunk(channel.json_body["chunk"])
+        relations_from_messages = event["unsigned"]["m.relations"]
+
+        # Check the bundled aggregations from each point.
+        for aggregations, desc in (
+            (relations_from_event, "/event"),
+            (relations_from_messages, "/messages"),
+        ):
+            # The latest event should have bundled aggregations.
+            self.assertIn(RelationTypes.THREAD, aggregations, desc)
+            thread_summary = aggregations[RelationTypes.THREAD]
+            self.assertIn("latest_event", thread_summary, desc)
+            self.assertEqual(
+                thread_summary["latest_event"]["event_id"], reply_event_id, desc
+            )
+
+            # The latest event should not have any bundled aggregations (since the
+            # only relation to it is another thread, which is invalid).
+            self.assertNotIn(
+                "m.relations", thread_summary["latest_event"]["unsigned"], desc
+            )
 
     def test_thread_edit_latest_event(self) -> None:
         """Test that editing the latest event in a thread works."""
@@ -1049,6 +1148,7 @@ def test_thread_edit_latest_event(self) -> None:
             content={"msgtype": "m.text", "body": "foo", "m.new_content": new_body},
             parent_id=threaded_event_id,
         )
+        edit_event_id = channel.json_body["event_id"]
 
         # Fetch the thread root, to get the bundled aggregation for the thread.
         relations_dict = self._get_bundled_aggregations()
@@ -1061,6 +1161,12 @@ def test_thread_edit_latest_event(self) -> None:
         self.assertIn("latest_event", thread_summary)
         latest_event_in_thread = thread_summary["latest_event"]
         self.assertEqual(latest_event_in_thread["content"]["body"], "I've been edited!")
+        # The latest event in the thread should have the edit appear under the
+        # bundled aggregations.
+        self.assertDictContainsSubset(
+            {"event_id": edit_event_id, "sender": "@alice:test"},
+            latest_event_in_thread["unsigned"]["m.relations"][RelationTypes.REPLACE],
+        )
 
     def test_aggregation_get_event_for_annotation(self) -> None:
         """Test that annotations do not get bundled aggregations included