Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
bdfeb90
Client side API's should still use stripped events with MSC4311
MadLittleMods Apr 23, 2026
43a11f5
Add changelog
MadLittleMods Apr 23, 2026
7f25120
Merge branch 'develop' into madlittlemods/remove-flawed-msc4311-parti…
MadLittleMods May 1, 2026
6026aaa
Non-working: Use full PDU's for `invite_room_state` in federation
MadLittleMods May 2, 2026
e0eb224
Iteration that uses current state
MadLittleMods May 2, 2026
3464ec8
Use `get_stripped_room_state_ids_from_event_context`
MadLittleMods May 2, 2026
f96c008
Invite event should be stripped when included in `invite_state`
MadLittleMods May 4, 2026
a088aa8
Sanitize `invite_room_state` received over federation
MadLittleMods May 4, 2026
336b686
Fix test lints
MadLittleMods May 4, 2026
22f4f20
Updated comment
MadLittleMods May 4, 2026
cce5dc7
Merge branch 'develop' into madlittlemods/remove-flawed-msc4311-parti…
MadLittleMods May 21, 2026
7e379e7
Make the TODO more obvious
MadLittleMods May 21, 2026
92d0d8b
Placeholder arg docstring
MadLittleMods May 21, 2026
76b4905
Better logic/reasoning
MadLittleMods May 22, 2026
5103f1b
Better comment flow
MadLittleMods May 22, 2026
5c1f4ca
Use `FIXME(MSC4311)` prefix
MadLittleMods May 22, 2026
374c4c5
Mark down create event after we verify it's valid
MadLittleMods May 22, 2026
ffe5c4b
Small comment to explain (we also explain above)
MadLittleMods May 22, 2026
2b900b4
Better `invite_room_state` scrutiny
MadLittleMods May 22, 2026
5e12cb8
Fix `events` typo
MadLittleMods May 22, 2026
ff533df
Event ID context
MadLittleMods May 22, 2026
de78d9e
Simplify logic to just use `hs.config.api.room_prejoin_state` instead…
MadLittleMods May 22, 2026
4598a43
`_parse_stripped_room_state`
MadLittleMods May 23, 2026
fbff685
`_minimal_parse_stripped_room_state`
MadLittleMods May 23, 2026
75a53ef
Strip the knock itself we add down `/sync`
MadLittleMods May 23, 2026
178dd89
Merge branch 'develop' into madlittlemods/remove-flawed-msc4311-parti…
MadLittleMods May 26, 2026
1926534
Account for previous Matrix versions where `invite_room_state` is opt…
MadLittleMods May 26, 2026
17c3763
Better `msc4311_stripped_state` description
MadLittleMods May 26, 2026
75d1935
The PDU's over federation can be full PDU's now
MadLittleMods May 26, 2026
3888387
Raise 500 when federation returns 400 `M_MISSING_PARAM`
MadLittleMods May 26, 2026
259f151
Add note about future Complement tests
MadLittleMods May 26, 2026
c289e93
Kick CI after Sytest fixes
MadLittleMods May 27, 2026
ec572c6
Merge branch 'develop' into madlittlemods/remove-flawed-msc4311-parti…
MadLittleMods May 27, 2026
4e7f3fc
Merge branch 'develop' into madlittlemods/remove-flawed-msc4311-parti…
MadLittleMods Jul 9, 2026
bf9ab2f
Refactor to include `inviter_user_id` as part of stripped state
MadLittleMods Jul 9, 2026
c54c93b
Just pass in the event
MadLittleMods Jul 9, 2026
17a4ef6
Remove `_calculate_stripped_state_filter`
MadLittleMods Jul 9, 2026
1cf8282
Update date that Synapse is compliant
MadLittleMods Jul 9, 2026
ce17c93
Cross-link #19943
MadLittleMods Jul 9, 2026
5cc8753
Fix `compatibility` typo
MadLittleMods Jul 9, 2026
53a68d7
Use correct `knock_room_state` reference from response
MadLittleMods Jul 9, 2026
7488a24
Use full PDU's for v1 of federation invite API
MadLittleMods Jul 9, 2026
3870034
Fix typo causing extra nesting
MadLittleMods Jul 10, 2026
40d1316
Kick CI after deleting sytest branch with same name
MadLittleMods Jul 10, 2026
3fc94ba
Fix arg ordering typo with `SynapseError`
MadLittleMods Jul 10, 2026
26bf739
Cross-link MSC4319
MadLittleMods Jul 22, 2026
2822f3c
Move parse/serialize as methods of `StrippedStateEvent`
MadLittleMods Jul 28, 2026
3ce6900
Rename `parse` to `from_json_dict`
MadLittleMods Jul 28, 2026
19d8a7a
Rename `serialize` to `as_json_dict`
MadLittleMods Jul 28, 2026
ecab104
Fix `builtins.TypeError: unsupported operand type(s) for |: 'str' and…
MadLittleMods Jul 28, 2026
eed3071
MSC4319 is now merged
MadLittleMods Jul 28, 2026
f751b5f
Merge branch 'develop' into madlittlemods/remove-flawed-msc4311-parti…
MadLittleMods Jul 28, 2026
e06b311
Merge branch 'develop' into madlittlemods/remove-flawed-msc4311-parti…
MadLittleMods Jul 29, 2026
e301cdd
Start error off more plainly: `The other user's server did not unders…
MadLittleMods Jul 29, 2026
bc73bb0
Merge branch 'develop' into madlittlemods/remove-flawed-msc4311-parti…
MadLittleMods Aug 3, 2026
0baa67b
Merge branch 'develop' into madlittlemods/remove-flawed-msc4311-parti…
MadLittleMods Aug 3, 2026
4f70688
Iterate on error
MadLittleMods Aug 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/19723.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Remove flawed [MSC4311](https://github.com/matrix-org/matrix-spec-proposals/pull/4311) partial implementation: Client-side API's like `/sync` should still use stripped events.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Complement tests are expected to fail ❌ as we removed the flawed partial implementation in this PR.

The Complement tests have been updated in matrix-org/complement#796 and pass locally. We will merge both PRs at the same time.

Comment thread
MadLittleMods marked this conversation as resolved.
19 changes: 19 additions & 0 deletions rust/src/room_versions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,23 @@ pub struct RoomVersion {
/// This is similar to how doubly-linked lists can potentially not refer to previous items correctly
/// without verifying the list's integrity, but doing it on every insert is too expensive.
pub msc4242_state_dags: bool,
/// Determines whether a room version *SHOULD* rather than *MAY* reject invites/knocks
/// with invalid stripped state events.
///
/// According to MSC4311:
/// > If any of the [stripped state] events are not a PDU, not for the room ID specified, or fail
/// > signature checks, or the `m.room.create` event is missing, the receiving
/// > server MAY respond to invites with a `400 M_MISSING_PARAM` standard Matrix
/// > error (new to the endpoint). For invites to room version 12+ rooms, servers
/// > SHOULD rather than MAY respond to such requests with `400 M_MISSING_PARAM`.
///
/// Regardless of room version (we should always do these things):
/// 1. The `m.room.create` event *MUST* be included in
/// `invite_room_state`/`knock_room_state` when sending invites/knocks over the
/// federation API's.
/// 2. Use full PDU's in the `invite_room_state`/`knock_room_state` in the federation
/// API. The client API still uses stripped state.
msc4311_stripped_state: bool,
}

impl RoomVersion {
Expand All @@ -187,6 +204,7 @@ impl RoomVersion {
msc4291_room_ids_as_hashes: false,
strict_event_byte_limits_room_versions: false,
msc4242_state_dags: false,
msc4311_stripped_state: false,
};

pub const V2: RoomVersion = RoomVersion {
Expand Down Expand Up @@ -304,6 +322,7 @@ impl RoomVersion {
state_res: StateResolutionVersions::V2_1,
msc4289_creator_power_enabled: true,
msc4291_room_ids_as_hashes: true,
msc4311_stripped_state: true,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this not break invites across new and old Synapses for v12 rooms?

@MadLittleMods MadLittleMods Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How so? We don't strictly validate anything (just warn when it fails validation in v12 rooms).

See "What does this PR do?" in the PR description.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, my bad. Feels very odd to have an option that merely causes things to log. Especially given that initially this will log for almost every invite in v12. I wonder if its worth it for now?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's house-keeping for the room versions. We use it today and will use it in the future.

..Self::V11
};

Expand Down
40 changes: 40 additions & 0 deletions synapse/events/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,46 @@ class StrippedStateEvent:
sender: str
content: dict[str, Any]

def as_json_dict(self) -> JsonDict:
"""
Serialize to the JSON representation of 'Stripped State' according to the Matrix
spec
"""

return {
"type": self.type,
"state_key": self.state_key,
"sender": self.sender,
"content": self.content,
}

@staticmethod
def from_json_dict(raw_stripped_event: JsonDict) -> "StrippedStateEvent | None":
"""
Given a raw value from an event's `unsigned` field, attempt to parse it into a
`StrippedStateEvent`.
"""
if isinstance(raw_stripped_event, dict):
# All of these fields are required
type = raw_stripped_event.get("type")
state_key = raw_stripped_event.get("state_key")
sender = raw_stripped_event.get("sender")
content = raw_stripped_event.get("content")
if (
isinstance(type, str)
and isinstance(state_key, str)
and isinstance(sender, str)
and isinstance(content, dict)
):
return StrippedStateEvent(
type=type,
state_key=state_key,
sender=sender,
content=content,
)

return None


@attr.s(slots=True, frozen=True, auto_attribs=True)
class EventMetadata:
Expand Down
39 changes: 1 addition & 38 deletions synapse/events/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@
CANONICALJSON_MAX_INT,
CANONICALJSON_MIN_INT,
MAX_PDU_SIZE,
EventTypes,
)
from synapse.api.errors import Codes, SynapseError
from synapse.logging.opentracing import SynapseTags, set_tag, trace
Expand All @@ -55,7 +54,7 @@
from synapse.synapse_rust.types import Requester
from synapse.types import JsonDict

from . import EventBase, StrippedStateEvent
from . import EventBase

# These are imported only to re-export them (callers import them from this
# module); listing them in __all__ stops the unused-import lint flagging them
Expand Down Expand Up @@ -549,45 +548,9 @@ def strip_event(event: EventBase) -> JsonDict:
Stripped state events can only have the `sender`, `type`, `state_key` and `content`
properties present.
"""
# MSC4311: Ensure the create event is available on invites and knocks.
# TODO: Implement the rest of MSC4311
if (
event.room_version.msc4291_room_ids_as_hashes
and event.type == EventTypes.Create
and event.get_state_key() == ""
):
return event.get_pdu_json()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing the flawed MSC4311 partial implementation

return {
"type": event.type,
"state_key": event.state_key,
"content": dict(event.content),
"sender": event.sender,
}


def parse_stripped_state_event(raw_stripped_event: Any) -> StrippedStateEvent | None:
"""
Given a raw value from an event's `unsigned` field, attempt to parse it into a
`StrippedStateEvent`.
"""
if isinstance(raw_stripped_event, dict):
# All of these fields are required
type = raw_stripped_event.get("type")
state_key = raw_stripped_event.get("state_key")
sender = raw_stripped_event.get("sender")
content = raw_stripped_event.get("content")
if (
isinstance(type, str)
and isinstance(state_key, str)
and isinstance(sender, str)
and isinstance(content, dict)
):
return StrippedStateEvent(
type=type,
state_key=state_key,
sender=sender,
content=content,
)

return None
112 changes: 101 additions & 11 deletions synapse/federation/federation_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

import itertools
import logging
from http import HTTPStatus
from typing import (
TYPE_CHECKING,
AbstractSet,
Expand Down Expand Up @@ -59,6 +60,7 @@
RoomVersions,
)
from synapse.events import EventBase, builder, make_event_from_dict
from synapse.events.snapshot import EventContext
from synapse.federation.federation_base import (
FederationBase,
InvalidEventSignatureError,
Expand All @@ -70,7 +72,12 @@
from synapse.http.types import QueryParams
from synapse.logging.opentracing import SynapseTags, log_kv, set_tag, tag_args, trace
from synapse.metrics import SERVER_NAME_LABEL
from synapse.types import JsonDict, StrCollection, UserID, get_domain_from_id
from synapse.types import (
JsonDict,
StrCollection,
UserID,
get_domain_from_id,
)
from synapse.util.async_helpers import concurrently_execute
from synapse.util.caches.expiringcache import ExpiringCache
from synapse.util.duration import Duration
Expand Down Expand Up @@ -1303,12 +1310,12 @@ async def send_invite(
self,
destination: str,
room_id: str,
event_id: str,
pdu: EventBase,
context: EventContext,
) -> EventBase:
room_version = await self.store.get_room_version(room_id)

content = await self._do_send_invite(destination, pdu, room_version)
content = await self._do_send_invite(destination, pdu, context, room_version)

pdu_dict = content["event"]

Expand All @@ -1329,11 +1336,21 @@ async def send_invite(
return pdu

async def _do_send_invite(
self, destination: str, pdu: EventBase, room_version: RoomVersion
self,
destination: str,
pdu: EventBase,
context: EventContext,
room_version: RoomVersion,
) -> JsonDict:
"""Actually sends the invite, first trying v2 API and falling back to
v1 API if necessary.

Args:
destination:
pdu: Invite event
context:
room_version:

Returns:
The event as a dict as returned by the remote server

Expand All @@ -1344,6 +1361,19 @@ async def _do_send_invite(
"""
time_now = self._clock.time_msec()

# MSC4311: For the federation API, format events in `invite_room_state` as full
# PDU's
#
# Find the full events based on the state at the time of the invite
state_ids = await self.store.get_stripped_room_state_ids_from_event_context(
pdu,
context,
)
state_events = await self.store.get_events(state_ids)
assert set(state_ids) == set(state_events.keys()), (
"We should have all events available that were set as stripped state."
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, this is technically a behaviour change where we are recalculating the stripped state here rather than using the stripped state we calculated at event creation.

Generally, I'm wondering if we should change the format of what we store as invite_room_state in the event json in the DB, rather than having this somewhat weird thing going where we have to recalculate it. Not really sure how tractable it is, but you can imagine a world where we store in a separate table the stripped state event IDs for locally generated events.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤷 Future plans


try:
return await self.transport_layer.send_invite_v2(
destination=destination,
Expand All @@ -1352,7 +1382,11 @@ async def _do_send_invite(
content={
"event": pdu.get_pdu_json(time_now),
"room_version": room_version.identifier,
"invite_room_state": pdu.unsigned.get("invite_room_state", []),
"invite_room_state": [
# Use full PDU's according to MSC4311
state_event.get_pdu_json(time_now)
for state_event in state_events.values()
],
},
)
except HttpResponseException as e:
Expand All @@ -1367,18 +1401,74 @@ async def _do_send_invite(
"User's homeserver does not support this room version",
Codes.UNSUPPORTED_ROOM_VERSION,
)
# MSC4311: The 400 `M_MISSING_PARAM` error SHOULD be translated to a 5xx
# error by the sending server over the Client-Server API. This is done
# because there's nothing the client can materially do differently to make
# the request succeed.
elif (
err.code == HTTPStatus.BAD_REQUEST
and err.errcode == Codes.MISSING_PARAM
):
raise SynapseError(
500,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're not doing a 4xx response then we shouldn't use 500 as it's not an internal server error. I'm not really sure what the closest HTTP code for "this server can't perform the action requested", it's half a 4xx and half a 5xx.

TBH it feels quite similar to the 400 above, "User's homeserver does not support this room version".

@MadLittleMods MadLittleMods Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As the comment above states, the MSC tells us this should be a 5xx error.

I chose 500 as the other server is saying our homeserver is doing something wrong.

And the description sounds perfect:

The HTTP 500 Internal Server Error server error response status code indicates that the server encountered an unexpected condition that prevented it from fulfilling the request.

-- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/500

In other words, our homeserver implementation is preventing the invite from going through. Nothing the user can do.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, bleurgh. The problem is that in practice returning a 500 is surprising behaviour for almost everyone: they expect a 500 to indicate there is a bug/misconfiguration in the server. Clients/users will retry when they hit a 500 as they assume it may be a transient problem.

Generally, regardless of what the web specs imply, clients view 5xx as possibly transient server issues, while 4xx as a final failure. Most 4xx are actually things the user can't do anything about, e.g. 403 or 404.

Concretely: returning a 500 will cause confusion for server operators (and us) as that should be reserved for bugs, and clients will keep retrying this endpoint. Intermediate proxies do sometimes infer uptime via 5xx errors (e.g. returning 502 often causes this).

Have taken this up wit the Spec Core Team.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the lack of screaming on that MSC, and the fact the SCT seems fine with it, I propose we return a 400 with M_UNKNOWN. This is fine as returning a 5xx is a SHOULD rather than a MUST.

f"Invite was rejected by the recipient's server.\n\n"
f"The remote homeserver ({destination}) returned {HTTPStatus.BAD_REQUEST} {Codes.MISSING_PARAM} "
"which indicates a compatibility problem between your homeserver and the "
"homeserver you're trying to send the invite to (either one could be at fault).",
Codes.UNKNOWN,
additional_fields={
"cause": err.msg,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we have to worry about leaking anything by passing this error message along. Calling this out so the reviewer can sanity check this assumption

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Err, there's little point returning this to clients as nothing will read it. It can also be confusing if devs interpret it as the what this server is complaining about to the client

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See #19723 (comment) in regards to providing enough info.

If you can think of a better way to present it, that would be good.

"destination_server": destination,
},
)
else:
raise err

# Didn't work, try v1 API.
# Note the v1 API returns a tuple of `(200, content)`

_, content = await self.transport_layer.send_invite_v1(
destination=destination,
room_id=pdu.room_id,
event_id=pdu.event_id,
content=pdu.get_pdu_json(time_now),
)
try:
# Use full PDU's for `invite_room_state` according to MSC4311
#
# With the v1 invite API, `invite_room_state` is carried inside the event
# instead of a separate field like in v2 so we must munge it in ourselves
event_json = pdu.get_pdu_json(time_now)
event_json.setdefault("unsigned", {})["invite_room_state"] = [
# Use full PDU's according to MSC4311
state_event.get_pdu_json(time_now)
for state_event in state_events.values()
]

_, content = await self.transport_layer.send_invite_v1(
destination=destination,
room_id=pdu.room_id,
event_id=pdu.event_id,
content=event_json,
)
except HttpResponseException as e:
# MSC4311: The 400 `M_MISSING_PARAM` error SHOULD be translated to a 5xx
# error by the sending server over the Client-Server API. This is done
# because there's nothing the client can materially do differently to make
# the request succeed.
err = e.to_synapse_error()
if (
err.code == HTTPStatus.BAD_REQUEST
and err.errcode == Codes.MISSING_PARAM
):
raise SynapseError(
500,
f"Invite was rejected by the recipient's server.\n\n"
f"The remote homeserver ({destination}) returned {HTTPStatus.BAD_REQUEST} {Codes.MISSING_PARAM} "
"which indicates a compatibility problem between your homeserver and the "
"homeserver you're trying to send the invite to (either one could be at fault).",
Codes.UNKNOWN,
additional_fields={
"cause": err.msg,
"destination_server": destination,
},
)
else:
raise err
return content

async def send_leave(self, destinations: Iterable[str], pdu: EventBase) -> None:
Expand Down
Loading
Loading