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 <noreply@anthropic.com>
This commit is contained in:
Kegan DougalandClaude Fable 5.1 committed 2026-10-15 09:22:38 +02:00
1 parent 45e26a2afb
commit 45cfb3bc93
12 files changed
+390 -4

No files matched your search

@@ -145,6 +145,7 @@ experimental_features:
msc4222_enabled: true
# State DAGs
msc4242_enabled: true
msc4428_enabled: true
server_notices:
system_mxid_localpart: _server
+1
View File
@@ -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,
+3
View File
@@ -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)
+16
View File
@@ -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<Self> {
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
+4
View File
@@ -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,
+3
View File
@@ -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."""
+4
View File
@@ -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)
+3 -2
View File
@@ -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(
+7
View File
@@ -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,
+67 -2
View File
@@ -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:
+270
View File
@@ -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:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
"""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", {}))
+11
View File
@@ -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