Skip to content
Open
Changes from 9 commits
Commits
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
221 changes: 221 additions & 0 deletions proposals/4308-sliding-sync-ext-thread-subscriptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
MSC4308: Thread Subscriptions extension to Sliding Sync
===

## Background and Summary

Threads were introduced in [version 1.4 of the Matrix Specification](https://spec.matrix.org/v1.13/changelog/v1.4/) as a way to isolate conversations in a room, making it easier for users to track specific conversations that they care about (and ignore those that they do not).

Threads Subscriptions are proposed in [MSC4306](https://github.com/matrix-org/matrix-spec-proposals/blob/rei/msc_thread_subscriptions/proposals/4306-thread-subscriptions.md) as a way for users to efficiently indicate which threads they care about, for the purposes of receiving updates.

Sliding Sync is proposed in [MSC4186](https://github.com/matrix-org/matrix-spec-proposals/blob/erikj/sss/proposals/4186-simplified-sliding-sync.md) as a paginated replacement to `/_matrix/client/v3/sync` with smaller response bodies and lower latency.
The `/_matrix/client/v3/sync` endpoint is notorious in real-world applications of Matrix for producing large response bodies (to the amplified detriment of mobile clients) and the high latency caused by waiting for the server to calculate that full payload.

This MSC proposes an 'extension' to Sliding Sync that allows clients to opt-in to receiving real-time updates to the user's thread subscriptions.

To handle the case in which there have been many updates to the user's thread subscriptions and there are too many to return in
Sliding Sync, a new companion endpoint is proposed to allow backpaginating thread subscriptions on the client's terms.

## Proposal

### Sliding Sync extension

The Sliding Sync request format is extended to include the `thread_subscriptions` extension as follows:

```jsonc
{
// ...

"extensions": {
// ...

// Used to opt-in to receiving changes to thread subscriptions.
"thread_subscriptions": {
// Maximum number of thread subscription changes to receive
// in the response.
// Defaults to 100.
// Servers may impose a smaller limit than what is requested here.
"limit": 100,
Copy link
Member

Choose a reason for hiding this comment

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

  • it'd be nice to add the enabled: boolean here too, for consistency with other extensions (and because the synapse impl requires it, and I was confused as to why my request wasn't taken into account xD)
  • also i see in the impl that limit is required, but I wonder if it should? I didn't put it first, assuming the server would give me a nice default value.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added the enabled flag, sorry for forgetting it!

The limit is now opened as a bug in Synapse.

}
}
}
```

The response format is then extended to compensate:

```jsonc
{
// ...

"extensions": {
// ...

// Returns a limited window of changes to thread subscriptions
// Only the latest changes are returned in this window.
"thread_subscriptions": {
// Threads that have been subscribed, or had their subscription
// changed.
//
// Optional. If omitted, has the same semantics as an empty map.
"subscribed": {
"!roomId1:example.org": {
// New subscription
"$threadId1": {
"automatic": true,
"bump_stamp": 4000
},
// New subscription
// or subscription changed to manual
"$threadId2": {
"automatic": false,
"bump_stamp": 4210
}
},
"$roomId2:example.org": {
// ...
}
},
// Threads that have been unsubscribed.
//
// Optional. If omitted, has the same semantics as an empty map.
"unsubscribed": {
"!roomId3:example.org": {
// Represents a removed subscription
"$threadId3": {
"bump_stamp": 4242
}
},
// ...
},

// A token that can be used to backpaginate other thread subscription
// changes that occurred since the last sync, but that were not
// included in this response.
//
// The token is to be used with the `/thread_subscriptions` endpoint
// as `from`, with `dir`=`b`.
// The `pos` parameter in the **request** would be used for the `to`
// parameter.
//
// Optional. Only present when some thread subscriptions have been
// missed out from the response because there are too many of them.
"prev_batch": "OPAQUE_TOKEN"
Comment on lines +101 to +103
Copy link
Member

Choose a reason for hiding this comment

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

Should we add a client behavior section defining that ideally the thread subscriptions would all be persisted on the client's side, to avoid having to call into the GET endpoint for individual thread subscriptions when computing notifications locally (notably for e2ee rooms)? The "ideally" in my sentence suggests this could be worded as a "MAY".

Then I guess, to implement this optimization correctly, a client would have to remember if it's synchronized all threads subscriptions yet, with the following assumptions:

  • when a SSS connection has been reset (via a new pos), a client should assume it doesn't know about all the thread subscriptions yet (set an hypothetical boolean known_thread_subscriptions to false)
  • after receiving a response and paginating all the previous unknown thread subscriptions, the client should assume it does know with certainty about all the thread subscriptions (set known_thread_subscriptions to true)
  • when getting a thread subscription: if !known_thread_subscriptions, a GET request should be sent to figure whether a thread has a subscription or not; otherwise, the state synchronized locally can be used.

}
}
}
```

If two changes occur to the same subscription, only the latter change ever needs
to be sent to the client. \
Copy link
Contributor

Choose a reason for hiding this comment

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

Are these on purpose? (applies elsewhere)

Suggested change
to be sent to the client. \
to be sent to the client.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the most sane of the two ways of adding a line break in the more standard variants of Markdown.
(GFM is I think the only notable dialect that doesn't need this)

Servers do not need to store intermediate subscription states.

### Companion endpoint for backpaginating thread subscription changes

```
GET /_matrix/client/v1/thread_subscriptions
```

URL parameters:
Copy link
Contributor

@MadLittleMods MadLittleMods Aug 11, 2025

Choose a reason for hiding this comment

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

Per my other comment,

We probably do want to include pagination tokens for both sides of the response (prev/next) so clients can fill in the gap from both directions as desired.

This would also require a dir direction parameter. I'm not sure it's necessary to refactor to a from/to pattern like /messages but maybe more obvious if they behave the same.

We should also clarify the token bounds. Synapse reference for other places:


- `dir` (string, required): always `b` (backward), to mirror other pagination
endpoints. The forward direction is not yet specified to be implemented.

- `from` (string, optional): a token used to continue backpaginating \
The token is either acquired from a previous `/thread_subscriptions` response,
or the `prev_batch` in a Sliding Sync response. \
The token is opaque and has no client-discernible meaning. \
If this token is not provided, then backpagination starts from the 'end'.

- `to` (string, optional): a token used to limit the backpagination \
The token can be acquired from a Sliding Sync response.
Copy link
Member

Choose a reason for hiding this comment

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

Hmm, the wording is confusing here as it's talking about the sliding sync response. We might want to precise here again that it's the pos value from the latest request?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Tried (and maybe failed) to improve with 4d88858


- `limit` (int, optional; default `100`): a maximum number of thread subscriptions to fetch
in one response. \
Must be greater than zero. Servers may impose a smaller limit than requested.


Response body:

```jsonc
{
"subscribed": {
"!roomId1:example.org": {
// New subscription
"$threadId1": {
"automatic": true,
"bump_stamp": 4000
},
// New subscription
// or subscription changed to manual
"$threadId2": {
"automatic": false,
"bump_stamp": 4210
}
},
"$roomId2:example.org": {
// ...
}
},
"unsubscribed": {
"!roomId3:example.org": {
// Represents a removed subscription
"$threadId3": {
"bump_stamp": 4242
}
},
// ...
},
// If there are still more thread subscriptions to fetch,
// a new `from` token the client can use to walk further
// backwards. (The `to` token, if used, should be reused.)
"end": "OPAQUE_TOKEN"
}
```

If two changes occur to the same subscription, only the latter change ever needs
to be sent to the client. \
Servers MUST not send intermediate subscription states to clients.

The pagination structure of this endpoint matches that of the `/messages` endpoint, but fixed
to the backward direction (`dir=b`).
For simplicity, the `start` response field is removed as it is entirely redundant.

### Use of `bump_stamp`

The `bump_stamp`s within each thread subscription can be used for determining which
state is latest, for example when a concurrent `/thread_subscriptions` backpagination request
and `/sync` request both return information about the same thread subscription.
Comment on lines +188 to +190
Copy link
Member

Choose a reason for hiding this comment

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

For what it's worth, my implementation will likely now store all the bumpstamps in the database (so as to be able to order the latest sub vs unsub, notably). This creates a small discrepancy, when using the endpoints introduced from MSC4306:

  • when we use the PUT or DELETE endpoints, we don't receive a bump_stamp. In this case, it's expected that the bumpstamp will come with the remote echo from the next sync. If we want to implement "local echo" behvaior when using the PUT or DELETE endpoints, then we'd need to store the latest status (subscribed/unsubscribed) in the database, and we wouldn't have a new bumpstamp. I think that in this case, it might be sufficient to not update the bumpstamp.
  • When using the GET endpoint, we don't receive a bumpstamp, but that's fine: it's supposed to be the latest version anyways. Ideally, we'd cache the result in database too, here. And maybe we can perform the same hack as indicated above (keep the previous bumpstamp stored in DB, and only update the subscription status, if it's changed).

TL;DR: adding bump_stamp to the MSC4306 endpoints might be better for API consistency, but not sure it's achieving anything useful, considering the hack described above.


The semantics of the `bump_stamp` are that for two updates about the same thread,
the update with the higher `bump_stamp` is later and renders the update with the lower
`bump_stamp` obsolete.

Clients MUST NOT interpret any other semantics of the `bump_stamp`; other than the semantics
above they do not have any special meaning.
Notably, `bump_stamp`s MUST NOT be compared between different threads, because servers MAY
treat `bump_stamp`s as per-thread.

## Potential issues


## Alternatives


## Limitations


## Security considerations

- No particular security issues anticipated.

## Unstable prefix

Whilst this proposal is unstable, a few unstable prefixes must be observed by experimental implementations:

- the Sliding Sync extension is called `io.element.msc4308.thread_subscriptions` instead of `thread_subscriptions`
- the companion endpoint is called `/_matrix/client/unstable/io.element.msc4308/thread_subscriptions` instead of `/_matrix/client/v1/thread_subscriptions`


## Dependencies

- [MSC4186 Sliding Sync](https://github.com/matrix-org/matrix-spec-proposals/blob/erikj/sss/proposals/4186-simplified-sliding-sync.md)
- [MSC4306 Threads Subscriptions](https://github.com/matrix-org/matrix-spec-proposals/blob/rei/msc_thread_subscriptions/proposals/4306-thread-subscriptions.md)