From 45cfb3bc939d5fa361ab1767e107158f5cdc786d Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:44:33 +0200 Subject: [PATCH] A1: Advertise MSC4428 and add `unsigned.org.matrix.msc4428.stable_id` Add the `experimental_features.msc4428_enabled` flag. When set, `/versions` advertises `org.matrix.msc4428` and the Rust event serializer adds `unsigned.org.matrix.msc4428.stable_id` (the sender's user ID) to every event served to clients, including the events embedded in `unsigned` (`redacted_because`, bundled edits, thread summaries). The field is subject to the client's field allowlist like any other unsigned key. `SerializeEventConfig.__init__` gains a required `msc4428_enabled` argument (not module API). Co-Authored-By: Claude Fable 5.1 --- .../conf/workers-shared-extra.yaml.j2 | 1 + rust/src/config/mod.rs | 1 + rust/src/events/constants.rs | 3 + rust/src/events/serialize.rs | 16 ++ rust/src/handlers/versions.rs | 4 + synapse/api/constants.py | 3 + synapse/config/experimental.py | 4 + synapse/events/utils.py | 5 +- synapse/synapse_rust/events.pyi | 7 + tests/events/test_utils.py | 69 ++++- tests/rest/client/test_msc4428.py | 270 ++++++++++++++++++ tests/rest/client/test_versions.py | 11 + 12 files changed, 390 insertions(+), 4 deletions(-) create mode 100644 tests/rest/client/test_msc4428.py diff --git a/docker/complement/conf/workers-shared-extra.yaml.j2 b/docker/complement/conf/workers-shared-extra.yaml.j2 index 1af3a34ec9..470ba595eb 100644 --- a/docker/complement/conf/workers-shared-extra.yaml.j2 +++ b/docker/complement/conf/workers-shared-extra.yaml.j2 @@ -145,6 +145,7 @@ experimental_features: msc4222_enabled: true # State DAGs msc4242_enabled: true + msc4428_enabled: true server_notices: system_mxid_localpart: _server diff --git a/rust/src/config/mod.rs b/rust/src/config/mod.rs index aba29046db..9b3194d3a9 100644 --- a/rust/src/config/mod.rs +++ b/rust/src/config/mod.rs @@ -72,6 +72,7 @@ pub struct ExperimentalConfig { pub msc4169_enabled: bool, pub msc4354_enabled: bool, pub msc4222_enabled: bool, + pub msc4428_enabled: bool, pub msc4491_enabled: bool, pub msc4143_enabled: bool, pub msc4446_enabled: bool, diff --git a/rust/src/events/constants.rs b/rust/src/events/constants.rs index 51f3b572f9..784d6918ea 100644 --- a/rust/src/events/constants.rs +++ b/rust/src/events/constants.rs @@ -84,6 +84,9 @@ pub mod unsigned_field { pub const MEMBERSHIP: &str = "membership"; /// Unsigned field: msc4354_sticky_duration_ttl_ms (MSC4354) pub const STICKY_TTL: &str = "msc4354_sticky_duration_ttl_ms"; + /// Unsigned field: org.matrix.msc4428.stable_id (MSC4428), the stable + /// identifier of the room member who sent the event. + pub const MSC4428_STABLE_ID: &str = "org.matrix.msc4428.stable_id"; /// Unsigned field: io.element.synapse.soft_failed (admin metadata) pub const SOFT_FAILED: &str = "io.element.synapse.soft_failed"; /// Unsigned field: io.element.synapse.policy_server_spammy (admin metadata) diff --git a/rust/src/events/serialize.rs b/rust/src/events/serialize.rs index bb3eb4df06..100bc8383c 100644 --- a/rust/src/events/serialize.rs +++ b/rust/src/events/serialize.rs @@ -128,6 +128,10 @@ pub struct SerializeEventConfig { /// Whether MSC4354 (sticky events) is enabled. When `true`, the remaining /// stickiness TTL is computed and added to `unsigned`. msc4354_enabled: bool, + /// Whether MSC4428 (stable identifiers for room members) is enabled. When + /// `true`, the sender is repeated in `unsigned` as + /// `org.matrix.msc4428.stable_id`. + msc4428_enabled: bool, } #[pymethods] @@ -142,6 +146,7 @@ impl SerializeEventConfig { include_stripped_room_state: bool, include_admin_metadata: bool, msc4354_enabled: bool, + msc4428_enabled: bool, ) -> PyResult { let requester = requester.map(|r| r.get().clone()); @@ -153,6 +158,7 @@ impl SerializeEventConfig { include_stripped_room_state, include_admin_metadata, msc4354_enabled, + msc4428_enabled, }) } } @@ -518,6 +524,16 @@ fn serialize_event_value( } } + if config.msc4428_enabled { + // MSC4428: clients must use this, not `sender`, to identify the room + // member who sent the event: a later room version may drop `sender`. + // Today the stable ID is the sender's user ID. + unsigned.insert( + unsigned_field::MSC4428_STABLE_ID.to_owned(), + Value::String(event.sender().to_owned()), + ); + } + if config.msc4354_enabled { if let Some(sticky_duration) = event.sticky_duration() { // min() ensures the origin server can't claim a time in the future diff --git a/rust/src/handlers/versions.rs b/rust/src/handlers/versions.rs index e3419d5020..e1bfe854dd 100644 --- a/rust/src/handlers/versions.rs +++ b/rust/src/handlers/versions.rs @@ -261,6 +261,9 @@ pub struct UnstableFeatureMap { /// MSC4354: Sticky events #[serde(rename = "org.matrix.msc4354")] msc4354: bool, + /// MSC4428: Stable identifiers for room members. + #[serde(rename = "org.matrix.msc4428")] + msc4428: bool, /// MSC4380: Invite blocking #[serde(rename = "org.matrix.msc4380.stable")] msc4380: bool, @@ -329,6 +332,7 @@ pub fn synapse_config_to_global_unstable_feature_map( msc4169: config.experimental.msc4169_enabled, msc4262: config.server.include_profile_updates_in_sync, msc4354: config.experimental.msc4354_enabled, + msc4428: config.experimental.msc4428_enabled, msc4380: true, msc4429: config.server.include_profile_updates_in_sync, msc4445_initial_sync_timeline_topological_ordering: true, diff --git a/synapse/api/constants.py b/synapse/api/constants.py index bfdb3ef926..c50120831d 100644 --- a/synapse/api/constants.py +++ b/synapse/api/constants.py @@ -328,6 +328,9 @@ class EventUnsignedContentFields: STICKY_TTL: Final = "msc4354_sticky_duration_ttl_ms" + # MSC4428: the stable identifier of the room member who sent the event. + MSC4428_STABLE_ID: Final = "org.matrix.msc4428.stable_id" + class MTextFields: """Fields found inside m.text content blocks.""" diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py index c80b2740c3..7f19c2239e 100644 --- a/synapse/config/experimental.py +++ b/synapse/config/experimental.py @@ -171,6 +171,10 @@ class ExperimentalConfig(Config): # Enable the room version KNOWN_ROOM_VERSIONS.add_room_version(RoomVersions.MSC4242v12) + # MSC4428: Stable identifiers for room members. Adds + # `unsigned.org.matrix.msc4428.stable_id` to every event served to clients. + self.msc4428_enabled: bool = experimental.get("msc4428_enabled", False) + # MSC3391: Removing account data. self.msc3391_enabled = experimental.get("msc3391_enabled", False) diff --git a/synapse/events/utils.py b/synapse/events/utils.py index c3bc6fa9da..5668ba2294 100644 --- a/synapse/events/utils.py +++ b/synapse/events/utils.py @@ -182,8 +182,8 @@ class EventClientSerializer: Helper method that sets the `include_admin_metadata` field based on whether the requester is a server admin if it is not explicitly - provided. Also sets the `msc4354_enabled` field based on the homeserver - config. + provided. Also sets the `msc4354_enabled` and `msc4428_enabled` fields + based on the homeserver config. Args: as_client_event: Whether to serialize the events as client events. @@ -218,6 +218,7 @@ class EventClientSerializer: include_stripped_room_state=include_stripped_room_state, include_admin_metadata=include_admin_metadata, msc4354_enabled=self._config.experimental.msc4354_enabled, + msc4428_enabled=self._config.experimental.msc4428_enabled, ) async def serialize_event( diff --git a/synapse/synapse_rust/events.pyi b/synapse/synapse_rust/events.pyi index eb6876b0f1..2633adf6f7 100644 --- a/synapse/synapse_rust/events.pyi +++ b/synapse/synapse_rust/events.pyi @@ -371,6 +371,7 @@ class SerializeEventConfig: include_stripped_room_state: bool, include_admin_metadata: bool, msc4354_enabled: bool, + msc4428_enabled: bool, ) -> None: ... @property def as_client_event(self) -> bool: @@ -411,6 +412,12 @@ class SerializeEventConfig: """Whether MSC4354 (sticky events) is enabled. When ``True``, the remaining stickiness TTL is computed and added to ``unsigned``.""" + @property + def msc4428_enabled(self) -> bool: + """Whether MSC4428 (stable identifiers for room members) is enabled. + When ``True``, the sender is repeated in ``unsigned`` as + ``org.matrix.msc4428.stable_id``.""" + def serialize_events( events: list[tuple[Event, str | None]], time_now_ms: int, diff --git a/tests/events/test_utils.py b/tests/events/test_utils.py index d018568798..d6cc0b74ab 100644 --- a/tests/events/test_utils.py +++ b/tests/events/test_utils.py @@ -22,7 +22,7 @@ import unittest as stdlib_unittest from typing import TYPE_CHECKING, Any, Mapping -from synapse.api.constants import EventContentFields +from synapse.api.constants import EventContentFields, EventUnsignedContentFields from synapse.api.room_versions import RoomVersions from synapse.events import EventBase, StrippedStateEvent from synapse.events.utils import ( @@ -41,7 +41,7 @@ from synapse.types import JsonDict from synapse.util.frozenutils import freeze from tests.test_utils.event_builders import make_test_event -from tests.unittest import HomeserverTestCase +from tests.unittest import HomeserverTestCase, override_config if TYPE_CHECKING: from synapse.server import HomeServer @@ -927,6 +927,71 @@ class SerializeEventTestCase(HomeserverTestCase): }, ) + def test_msc4428_stable_id_absent_by_default(self) -> None: + serialized = self.serialize( + make_test_event(sender="@alice:localhost", room_id="!foo:bar"), None + ) + self.assertNotIn( + EventUnsignedContentFields.MSC4428_STABLE_ID, serialized["unsigned"] + ) + + @override_config({"experimental_features": {"msc4428_enabled": True}}) + def test_msc4428_stable_id(self) -> None: + """With MSC4428 enabled, every serialized event carries the sender as + `unsigned.org.matrix.msc4428.stable_id`, including the embedded + `redacted_because` event.""" + redaction_id = "$redaction_event_id" + + event = make_test_event( + type="foo", + event_id="test", + sender="@alice:localhost", + room_id="!foo:bar", + content={"foo": "bar"}, + ) + event.internal_metadata.redacted_by = redaction_id + + redaction_event = make_test_event( + type="m.room.redaction", + event_id=redaction_id, + sender="@mod:localhost", + content={"redacts": "test"}, + ) + + serialized = self.serialize( + event, None, redaction_map={redaction_id: redaction_event} + ) + self.assertEqual( + serialized["unsigned"][EventUnsignedContentFields.MSC4428_STABLE_ID], + "@alice:localhost", + ) + self.assertEqual( + serialized["unsigned"]["redacted_because"]["unsigned"][ + EventUnsignedContentFields.MSC4428_STABLE_ID + ], + "@mod:localhost", + ) + + # The field is subject to the client's field allowlist like any other + # (the dots in its name need escaping in the allowlist). + self.assertEqual( + self.serialize(event, ["content.foo"]), {"content": {"foo": "bar"}} + ) + self.assertEqual( + self.serialize( + event, + [ + "unsigned." + + EventUnsignedContentFields.MSC4428_STABLE_ID.replace(".", r"\.") + ], + ), + { + "unsigned": { + EventUnsignedContentFields.MSC4428_STABLE_ID: "@alice:localhost" + } + }, + ) + class CopyPowerLevelsContentTestCase(stdlib_unittest.TestCase): def setUp(self) -> None: diff --git a/tests/rest/client/test_msc4428.py b/tests/rest/client/test_msc4428.py new file mode 100644 index 0000000000..79deb8aa9b --- /dev/null +++ b/tests/rest/client/test_msc4428.py @@ -0,0 +1,270 @@ +# +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2026 Element Creations Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# See the GNU Affero General Public License for more details: +# . +# +"""Tests for MSC4428 (stable identifiers for room members): the +`unsigned.org.matrix.msc4428.stable_id` field on events served to clients. + +This stands in for the Complement test "stable_id on every event" (COMPLEMENT.md +ยง2.2) until that suite exists: every event-returning client endpoint, including +the events embedded in `unsigned` (`redacted_because`, bundled edits and thread +summaries), carries the field. +""" + +import json +from typing import Iterable + +from twisted.internet.testing import MemoryReactor + +from synapse.api.constants import EventTypes, EventUnsignedContentFields, RelationTypes +from synapse.rest import admin +from synapse.rest.client import login, relations, room, sync +from synapse.server import HomeServer +from synapse.types import JsonDict +from synapse.util.clock import Clock + +from tests import unittest + +STABLE_ID = EventUnsignedContentFields.MSC4428_STABLE_ID + + +class StableIdTestCase(unittest.HomeserverTestCase): + servlets = [ + admin.register_servlets, + login.register_servlets, + room.register_servlets, + relations.register_servlets, + sync.register_servlets, + ] + + def default_config(self) -> JsonDict: + config = super().default_config() + config["experimental_features"] = {"msc4428_enabled": True} + return config + + def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: + self.alice = self.register_user("alice", "pass") + self.alice_tok = self.login("alice", "pass") + self.bob = self.register_user("bob", "pass") + self.bob_tok = self.login("bob", "pass") + + # A legacy room: MSC4428 applies to every room version. + self.room_id = self.helper.create_room_as(self.alice, tok=self.alice_tok) + self.helper.join(self.room_id, self.bob, tok=self.bob_tok) + + # A message with an edit and a thread reply, so that bundled + # aggregations embed events sent by someone else. + self.parent_id = self.helper.send(self.room_id, body="Hi!", tok=self.alice_tok)[ + "event_id" + ] + self.edit_id = self.helper.send_event( + self.room_id, + EventTypes.Message, + { + "msgtype": "m.text", + "body": "* Hello!", + "m.new_content": {"msgtype": "m.text", "body": "Hello!"}, + "m.relates_to": { + "rel_type": RelationTypes.REPLACE, + "event_id": self.parent_id, + }, + }, + tok=self.alice_tok, + )["event_id"] + self.reply_id = self.helper.send_event( + self.room_id, + EventTypes.Message, + { + "msgtype": "m.text", + "body": "reply", + "m.relates_to": { + "rel_type": RelationTypes.THREAD, + "event_id": self.parent_id, + }, + }, + tok=self.bob_tok, + )["event_id"] + + # A message redacted by someone other than its sender, so that the + # embedded `redacted_because` has a different stable ID to its parent. + self.redacted_id = self.helper.send( + self.room_id, body="oops", tok=self.bob_tok + )["event_id"] + channel = self.make_request( + "POST", + f"/rooms/{self.room_id}/redact/{self.redacted_id}", + {}, + access_token=self.alice_tok, + ) + self.assertEqual(channel.code, 200, channel.json_body) + self.redaction_id = channel.json_body["event_id"] + + def assert_stable_ids(self, events: Iterable[JsonDict]) -> list[str]: + """Assert every event, and every event embedded in its `unsigned`, + carries `stable_id == sender`. Returns the event IDs checked.""" + checked = [] + for event in events: + checked.extend(self._assert_stable_id(event)) + return checked + + def _assert_stable_id(self, event: JsonDict) -> list[str]: + self.assertEqual( + event.get("unsigned", {}).get(STABLE_ID), + event["sender"], + f"stable_id missing or wrong on {event.get('event_id')}: {event}", + ) + checked = [event["event_id"]] + + unsigned = event.get("unsigned", {}) + if "redacted_because" in unsigned: + checked.extend(self._assert_stable_id(unsigned["redacted_because"])) + bundled = unsigned.get("m.relations", {}) + if RelationTypes.REPLACE in bundled: + checked.extend(self._assert_stable_id(bundled[RelationTypes.REPLACE])) + if RelationTypes.THREAD in bundled: + checked.extend( + self._assert_stable_id(bundled[RelationTypes.THREAD]["latest_event"]) + ) + return checked + + def test_sync(self) -> None: + # Limit the timeline so that the state block is populated too. + sync_filter = json.dumps({"room": {"timeline": {"limit": 3}}}) + channel = self.make_request( + "GET", f"/sync?filter={sync_filter}", access_token=self.alice_tok + ) + self.assertEqual(channel.code, 200, channel.json_body) + room = channel.json_body["rooms"]["join"][self.room_id] + + self.assertGreater(len(room["state"]["events"]), 0) + self.assert_stable_ids(room["state"]["events"]) + checked = self.assert_stable_ids(room["timeline"]["events"]) + # The redacted message, with its embedded redaction, is in the timeline. + self.assertIn(self.redacted_id, checked) + self.assertIn(self.redaction_id, checked) + + def test_messages(self) -> None: + channel = self.make_request( + "GET", + f"/rooms/{self.room_id}/messages?dir=b", + access_token=self.alice_tok, + ) + self.assertEqual(channel.code, 200, channel.json_body) + checked = self.assert_stable_ids(channel.json_body["chunk"]) + # The whole room, with the embedded redaction and edit. + for event_id in ( + self.parent_id, + self.edit_id, + self.reply_id, + self.redacted_id, + self.redaction_id, + ): + self.assertIn(event_id, checked) + + def test_context(self) -> None: + channel = self.make_request( + "GET", + f"/rooms/{self.room_id}/context/{self.parent_id}", + access_token=self.alice_tok, + ) + self.assertEqual(channel.code, 200, channel.json_body) + body = channel.json_body + self.assert_stable_ids([body["event"]]) + self.assert_stable_ids(body["events_before"]) + self.assert_stable_ids(body["events_after"]) + self.assertGreater(len(body["state"]), 0) + self.assert_stable_ids(body["state"]) + + def test_event(self) -> None: + for event_id in (self.parent_id, self.redacted_id): + channel = self.make_request( + "GET", + f"/rooms/{self.room_id}/event/{event_id}", + access_token=self.alice_tok, + ) + self.assertEqual(channel.code, 200, channel.json_body) + self.assert_stable_ids([channel.json_body]) + + # The bundled edit and thread summary on the parent are events sent by + # alice and bob respectively; both carry their own sender. + channel = self.make_request( + "GET", + f"/rooms/{self.room_id}/event/{self.parent_id}", + access_token=self.alice_tok, + ) + bundled = channel.json_body["unsigned"]["m.relations"] + self.assertEqual( + bundled[RelationTypes.REPLACE]["unsigned"][STABLE_ID], self.alice + ) + self.assertEqual( + bundled[RelationTypes.THREAD]["latest_event"]["unsigned"][STABLE_ID], + self.bob, + ) + + def test_state(self) -> None: + channel = self.make_request( + "GET", f"/rooms/{self.room_id}/state", access_token=self.alice_tok + ) + self.assertEqual(channel.code, 200, channel.text_body) + self.assertGreater(len(channel.json_list), 0) + self.assert_stable_ids(channel.json_list) + + def test_relations(self) -> None: + channel = self.make_request( + "GET", + f"/_matrix/client/v1/rooms/{self.room_id}/relations/{self.parent_id}", + access_token=self.alice_tok, + ) + self.assertEqual(channel.code, 200, channel.json_body) + checked = self.assert_stable_ids(channel.json_body["chunk"]) + self.assertIn(self.edit_id, checked) + self.assertIn(self.reply_id, checked) + + def test_threads(self) -> None: + channel = self.make_request( + "GET", + f"/_matrix/client/v1/rooms/{self.room_id}/threads", + access_token=self.alice_tok, + ) + self.assertEqual(channel.code, 200, channel.json_body) + checked = self.assert_stable_ids(channel.json_body["chunk"]) + self.assertIn(self.parent_id, checked) + # The thread summary embeds bob's reply. + self.assertIn(self.reply_id, checked) + + +class StableIdDisabledTestCase(unittest.HomeserverTestCase): + """Without the flag nothing changes on the wire.""" + + servlets = [ + admin.register_servlets, + login.register_servlets, + room.register_servlets, + sync.register_servlets, + ] + + def test_no_stable_id(self) -> None: + alice = self.register_user("alice", "pass") + alice_tok = self.login("alice", "pass") + room_id = self.helper.create_room_as(alice, tok=alice_tok) + event_id = self.helper.send(room_id, body="Hi!", tok=alice_tok)["event_id"] + + channel = self.make_request( + "GET", f"/rooms/{room_id}/event/{event_id}", access_token=alice_tok + ) + self.assertEqual(channel.code, 200, channel.json_body) + self.assertNotIn(STABLE_ID, channel.json_body["unsigned"]) + + channel = self.make_request("GET", "/sync", access_token=alice_tok) + self.assertEqual(channel.code, 200, channel.json_body) + for event in channel.json_body["rooms"]["join"][room_id]["timeline"]["events"]: + self.assertNotIn(STABLE_ID, event.get("unsigned", {})) diff --git a/tests/rest/client/test_versions.py b/tests/rest/client/test_versions.py index 6ecff9de6f..c43527caea 100644 --- a/tests/rest/client/test_versions.py +++ b/tests/rest/client/test_versions.py @@ -145,6 +145,17 @@ class VersionsTestCase(unittest.HomeserverTestCase): self.assertEqual(channel.code, 200, channel.result) self.assertTrue(channel.json_body["unstable_features"]["io.element.msc4502"]) + def test_msc4428_false_by_default(self) -> None: + channel = self.make_request("GET", "/_matrix/client/versions") + self.assertEqual(channel.code, 200, channel.result) + self.assertFalse(channel.json_body["unstable_features"]["org.matrix.msc4428"]) + + @unittest.override_config({"experimental_features": {"msc4428_enabled": True}}) + def test_msc4428_true_if_enabled(self) -> None: + channel = self.make_request("GET", "/_matrix/client/versions") + self.assertEqual(channel.code, 200, channel.result) + self.assertTrue(channel.json_body["unstable_features"]["org.matrix.msc4428"]) + def _sanity_check_versions_response(self, versions_response: JsonDict) -> None: """ Make sure this looks like a `/_matrix/client/versions` response