Repository navigation
Conversation
Preview deploymentsHost Test Results 1 files 1 suites 1h 55m 6s ⏱️ Results for commit 37768d3. For more details on these errors, see this check. Realm Server Test Results 1 files 362 suites 2h 11m 20s ⏱️ Results for commit 3686324. |
A world-readable realm sent each realm event to the session room of every user with a session room, so a write to a public realm cost Synapse a persisted event per account. It now sends the event once, into a room of its own that hosts join when they subscribe to the realm. - realm_event_rooms table maps a world-readable realm to its room - Realm#ensureRealmEventRoom creates the room on first request: anyone may join, only the realm's matrix user may send or change state - POST /_realm-event-rooms answers a signed-in host with the rooms of the world-readable realms it names - broadcastRealmEvent sends to that room; fetchRealmSessionRooms returns only users with their own grant - the host joins the room on its first subscription to a realm, and sliding sync carries these rooms in a list of their own, out of the AI room list Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SGrCsG5woc6fYqHu7BBZdY
…rver Message-service injected realm-server to join a realm's event room, so any subscription created realm-server, whose constructor fetches `_catalog-realms`. A test with no realm-server mock then failed on that fetch. Realm-server now registers with message-service for each realm's first subscription instead. The join no longer checks getJoinedRooms first: a repeat join changes nothing, and the SDK answers it locally once sync has the room. The host test creates the room as the realm's user and waits for the test user's membership, and the sliding-sync test expects the AI list's not_room_types filter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SGrCsG5woc6fYqHu7BBZdY
a2fe1c1 to
035f235
Compare
…fire-and-forget-with-no-queue-so-a' into cs-14191-every-event-in-a-world-readable-realm-is-sent-to-all-170 # Conflicts: # packages/realm-server/node-realm.ts # packages/realm-server/tests/node-realm-test.ts
habdelra
left a comment
There was a problem hiding this comment.
[Claude Code 🤖] This review covered the shared room's lifecycle on the server (the creation race, power levels, the endpoint's cost and bounds), how hosts join it, sliding-sync routing, permission flips, and logout. It didn't cover the dispatcher this change builds on, and I ran no realm-server or host suites.
Bottom line: two blocking issues, each with a small fix. A cross-process creation race can silently leave a host without a realm's events for its whole session. A realm made world-readable while hosts have it open stops reaching those hosts. Everything else is non-blocking.
- Blocking:
insertRealmEventRoomreturns no row under a cross-process race. UseON CONFLICT DO UPDATE ... RETURNING. See the thread on session-room-queries.ts. - Blocking: making a realm world-readable while hosts have it open cuts those hosts off. See the thread on the world-readable branch in node-realm.ts.
- The endpoint can cold-mount and create rooms for up to 200 realms per request, all at once, and the "realm's own matrix user" comment doesn't hold in production. See handle-realm-event-rooms.ts.
- The event-room sender has no stale-room recovery, unlike the session-room sender beside it. See
onFailurein node-realm.ts. - Cancel the join task in
resetState. See realm-server.ts. - The host mock can't pin the
not_room_typesexclusion, or delivery through the shared room. See message-service-test.ts. - Prefix-form identifiers are dropped (ADR 002 §1.3, proposed). See handle-realm-event-rooms.ts.
- Follow-up: room memberships only grow; reword the range comment. See matrix-constants.ts.
| 'WITH inserted AS (', | ||
| ' INSERT INTO realm_event_rooms (realm_url, room_id) VALUES (', | ||
| param(realmURL), | ||
| ',', | ||
| param(roomId), | ||
| ' ) ON CONFLICT (realm_url) DO NOTHING', | ||
| ' RETURNING room_id', | ||
| ')', | ||
| 'SELECT room_id FROM inserted', | ||
| 'UNION ALL', | ||
| 'SELECT room_id FROM realm_event_rooms WHERE realm_url =', | ||
| param(realmURL), | ||
| 'LIMIT 1', | ||
| ]); | ||
| return rows[0].room_id as string; |
There was a problem hiding this comment.
[Claude Code 🤖] When two processes race, this statement can return no rows, and then rows[0].room_id throws. Every part of a statement with a data-modifying CTE reads from one snapshot, taken when the statement starts. If the other process's insert commits after that snapshot but before this INSERT checks for a conflict, ON CONFLICT DO NOTHING sees the conflict and inserts nothing. The SELECT ... FROM realm_event_rooms arm still reads the old snapshot, so it can't see the winning row either. I reproduced this on Postgres 18 with this exact statement: transaction A inserts and holds its commit, B runs this statement, A commits, and B gets (0 rows).
The failure is silent all the way to the host. ensureRealmEventRoom rejects. handleRealmEventRooms logs it and leaves the realm out of a 200 answer, which the host can't tell apart from "not world-readable". realmEventRoomsRequested already holds the realm, so that host gets none of the realm's events until it reloads.
A no-op DO UPDATE locks the row that won and returns it, whoever committed it. Under the same race it returned the winner's room:
INSERT INTO realm_event_rooms (realm_url, room_id) VALUES ($1, $2)
ON CONFLICT (realm_url) DO UPDATE SET room_id = realm_event_rooms.room_id
RETURNING room_idSeparately, it would help if the handler didn't fold a per-realm failure into the "not world-readable" answer. A 5xx, or a failed list, would let the host's existing re-queue in joinRealmEventRoomsTask run.
Regression, blocking. The cross-process guarantee doesn't hold as written, and the fix is one statement. The window is narrow, since B's statement has to start before A commits.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 6153ab6. insertRealmEventRoom is now INSERT ... ON CONFLICT (realm_url) DO UPDATE SET room_id = realm_event_rooms.room_id RETURNING room_id: a racing insert waits for the winner's row and returns its room. A new test in session-room-queries-test stores four rooms at once and checks that every caller gets the stored one back. The endpoint no longer reports a failed lookup as 'not world-readable'. It answers 503 with whatever rooms it did find, and the host joins those and retries the rest with backoff (2s, doubling to 60s).
| // A world-readable realm's events go once into its own room, which every | ||
| // host using the realm joins, instead of into the session room of every | ||
| // user who can read it. Until a host asks for that room it doesn't exist, | ||
| // and no one can be listening. | ||
| let realmEventRoom; | ||
| try { | ||
| realmEventRoom = await fetchRealmEventRoom(dbAdapter, realmUrl); | ||
| } catch (e) { | ||
| realmEventsLog.error('Error looking up the realm event room', e); | ||
| return []; | ||
| } | ||
| if (realmEventRoom.worldReadable) { | ||
| let roomId = realmEventRoom.roomId; | ||
| if (!roomId) { | ||
| realmEventsLog.debug( | ||
| `${realmUrl} has no realm event room yet, skipping broadcast`, | ||
| ); | ||
| return []; | ||
| } |
There was a problem hiding this comment.
[Claude Code 🤖] When a realm is made world-readable while hosts have it open, those hosts stop getting its events until they reload. That includes the owner who made the change.
PATCH /_permissions (patchRealmPermissions) can add '*': ['read'] to an existing realm. Every host with the realm open asked /_realm-event-rooms while it was private, got no room back, and recorded the realm in realmEventRoomsRequested. Only resetState clears that set, and only a first subscription calls joinRealmEventRoom, so those hosts never ask again.
From the next write on, this branch sends only to the shared room. If no host has asked since the flip, it sends nothing. "No one can be listening" isn't true in that case: the realm's grantees were listening on their session rooms a moment earlier. Their session rooms no longer get the event either.
The opposite flip, world-readable to private, is handled. This one needs one of:
- (recommended) When a permissions write makes the realm world-readable, have it ensure the realm's room exists and send a realm-server event to the grantees' session rooms that tells their hosts to join it. That reaches exactly the audience that was listening before the flip.
- Have the host remember only realms that came back with a room, and re-ask when it learns the realm's permissions changed.
Regression, blocking. The users it hits are the realm's own writers.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 6153ab6, using the realms-list-updated channel the permissions write already uses. When a PATCH changes the '*' grant, patchRealmPermissions sends realms-list-updated to every user the realm names, not just the patched keys. That's the audience that was listening in its session rooms before the flip. RealmServerService subscribes to that event, clears its 'already asked' set, and asks /_realm-event-rooms again about every realm that has a subscriber, so the asking creates the room and the host joins it. A new permissions-test case checks that an existing grantee's session room gets the event when '*' is granted.
| // Only the realm's own matrix user may send in its room, so the | ||
| // realm creates it. | ||
| let mounted = await reconciler.lookupOrMount(realmURL); | ||
| roomId = (await mounted?.ensureRealmEventRoom()) ?? null; |
There was a problem hiding this comment.
[Claude Code 🤖] Any signed-in user can make this endpoint cold-mount up to 200 world-readable realms at once and create a Matrix room for each. handle-realm-auth.ts deliberately avoids this cold mount for public realms. Here it runs for any URL a caller lists, inside an unbounded Promise.all.
The reason given for going through the realm doesn't hold in production. main.ts gives every reconciled realm the same matrixClient (REALM_SERVER_MATRIX_USERNAME), so "the realm's own matrix user" is the realm server's user. The same claim appears on createRealmEventRoom and in the PR description. Only the test helpers give each realm its own user.
Please do two things:
- Bound the cost. Run the per-realm work a few at a time, and set
MAX_REALMS_PER_REQUESTcloser to how many realms a host really has open. - Correct the comment, so the next reader doesn't build on per-realm senders as an invariant.
Creating rooms only for realms already in reconciler.mounted would avoid the mount entirely. However, a request that lands on another process would then get no room, and the host never re-asks.
Regression in a new endpoint, non-blocking.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 6153ab6. The handler works through a request's realms four at a time and caps a request at 100 realms. The comments on the handler and on createRealmEventRoom now say what holds: the room is created through the matrix client the realm sends its events with, and only that user may send in it. In production that's the realm server's shared user. I kept the cold mount, so a request that lands on another process still gets an answer.
| onFailure: async (e: unknown) => { | ||
| realmEventsLog.error( | ||
| `Unable to send event in realm event room ${roomId} for ${realmUrl}: ${ | ||
| e instanceof Error ? e.message : String(e) | ||
| }`, | ||
| ); | ||
| }, |
There was a problem hiding this comment.
[Claude Code 🤖] The session-room sender below recovers when the server user isn't in the room: on isRealmServerNotInRoomError it clears the row, and the next lookup heals. This sender only logs.
The server user can end up outside a stored realm event room, for example when a dev or staging Synapse is reset and Postgres keeps realm_event_rooms. Then every event for that realm fails, and hosts keep being handed a room they can join but will never hear from. Only deleting the row by hand fixes it.
Could onFailure delete the realm_event_rooms row on that same error, so the next host request creates a fresh room? Non-blocking; this brings it in line with its twin.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 6153ab6. When the realm event room's send fails with isRealmServerNotInRoomError, the row is deleted through the new clearRealmEventRoom, which deletes only if the stored room is still the one that failed. The next host request then creates a fresh room. Covered in node-realm-test and session-room-queries-test.
| this.realmEventRoomsPending.clear(); | ||
| this.realmEventRoomsRequested.clear(); |
There was a problem hiding this comment.
[Claude Code 🤖] resetState clears both sets, but a joinRealmEventRoomsTask run that is already in flight keeps going with the old session's client and realm list. If that run fails after logout, its catch puts those realms back into realmEventRoomsPending. The next account's sign-in then runs the task (authenticateToAllAccessibleRealms) and joins rooms for realms that user never opened. Adding this.joinRealmEventRoomsTask.cancelAll() here closes this. Non-blocking.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 6153ab6. resetState cancels joinRealmEventRoomsTask and the new retry task before clearing the sets, and resets the backoff.
| test('subscribing to a world-readable realm joins the room its events go to', async function (assert) { | ||
| let publicRealmURL = 'http://localhost:4205/public/'; | ||
| // The realm's own user creates the room; the host is not in it yet. | ||
| let roomId = mockMatrixUtils.createAndJoinRoom({ | ||
| sender: '@public_realm:localhost', | ||
| name: `Realm events: ${publicRealmURL}`, | ||
| id: '!public-realm-events:localhost', | ||
| }); | ||
| setRealmEventRoom(publicRealmURL, roomId); | ||
| let membership = () => { | ||
| try { | ||
| return mockMatrixUtils.getRoomState( | ||
| roomId, | ||
| 'm.room.member', | ||
| '@testuser:localhost', | ||
| ).membership; | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| }; | ||
| assert.strictEqual(membership(), undefined, 'the host starts outside it'); | ||
| let messageService = getService('message-service'); | ||
|
|
||
| let unsubscribe = messageService.subscribe(publicRealmURL, () => {}); | ||
| try { | ||
| await waitUntil(() => membership() === 'join', { | ||
| timeoutMessage: `the host did not join ${roomId}`, | ||
| }); | ||
| assert.strictEqual( | ||
| membership(), | ||
| 'join', | ||
| 'the host joined the realm event room', |
There was a problem hiding this comment.
[Claude Code 🤖] This test stops at membership. No host test checks that an app.boxel.realm-event arriving in the shared room reaches a messageService subscriber, or that the room stays out of aiRoomIds.
The mock's slidingSync (tests/helpers/mock-matrix/_client.ts) filters only on is_dm and ignores room_types and not_room_types. So in host tests, a room from createAndJoinRoom is still served in the AI list. The not_room_types exclusion is pinned only by the filter deep-equal in matrix-service-sliding-sync-test. If it broke, processDecryptedEvent would route these events to addRoomEvent, and they would be dropped.
Could you either:
- teach the mock both room-type filters and extend this test to deliver a realm event through the room, or
- point to a Playwright test against real Synapse that covers delivery through the shared room?
Test gap, non-blocking.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 6153ab6. The mock's sliding sync now applies room_types and not_room_types, reading the room's m.room.create type, and createAndJoinRoom takes a roomType. The message-service test creates the room as app.boxel.realm-events, waits for the join, sends an app.boxel.realm-event into it, and asserts the realm's subscriber hears it. That would fail if the room were routed as an AI room.
| let rooms: Record<string, string> = {}; | ||
| await Promise.all( | ||
| (realms as string[]).map(async (realm) => { | ||
| let realmURL = normalizeRealmURL(realm)?.href; |
There was a problem hiding this comment.
[Claude Code 🤖] ADR 002 §1.3 says realm-server endpoints that take an identifier MUST accept prefix form. normalizeRealmURL returns null for @cardstack/catalog/, so a prefix-form realm is silently left out of the answer. The host resolves identifiers before posting, so nothing breaks yet, but any other caller gets nothing back. Resolving through args.virtualNetwork before normalizing would match the ADR. Non-blocking, since the ADR is still proposed.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 6153ab6. A realm named by a registered prefix is resolved through virtualNetwork.toURL before normalizing, and the answer stays keyed by the spelling the request used.
| // Sliding sync reports changes only for rooms inside a list's range, so the | ||
| // realm-event-room window is wide enough for every world-readable realm a host | ||
| // has open at once. | ||
| export const SLIDING_SYNC_REALM_EVENT_ROOM_LIST_RANGE: [number, number] = [ | ||
| 0, 99, | ||
| ]; |
There was a problem hiding this comment.
[Claude Code 🤖] Nothing ever leaves these rooms, so a user's memberships grow with every world-readable realm they have ever opened, on any device. They don't track the realms "a host has open at once". Over time a realm's fan-out drifts back toward every user who ever opened it, and every tab syncs events for realms it isn't showing.
Sliding sync moves a room back into the window when it gets an event, so the 100-room range isn't a correctness problem. But this comment states a bound that nothing enforces; please reword it.
A leave policy is a follow-up, not this PR: for example, leave some time after the last unsubscribe, or prune at sign-in.
There was a problem hiding this comment.
[Claude Code 🤖] Reworded in 6153ab6. The comment now says a host stays in every realm event room it has joined, can hold more rooms than the window, and that a room moves back into the window when it gets an event. A leave policy is a follow-up, as you suggest.
- insertRealmEventRoom upserts with ON CONFLICT DO UPDATE ... RETURNING, so a racing insert waits for the winner and returns its room instead of returning no row - /_realm-event-rooms answers 503 (with the rooms it found) when a lookup fails, works through realms four at a time, caps a request at 100 realms and resolves prefix-form identifiers; the host retries failed realms with backoff and joins the rooms a 503 carried - a PATCH /_permissions that changes the '*' grant tells every user the realm names (realms-list-updated), and the host re-asks which room each subscribed realm uses when it hears that event - a realm event room the server user is no longer in is forgotten, so the next host request creates a fresh one - resetState cancels the in-flight join and retry tasks - the mock sliding sync honours room_types / not_room_types, and the host test delivers a realm event through the shared room - comments: the sender identity of a realm's room, and the room window Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
…in-a-world-readable-realm-is-sent-to-all-170
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6153ab6a2e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let response = await this.network.fetch( | ||
| `${this.url.href}_realm-event-rooms`, |
There was a problem hiding this comment.
Route room lookups through each realm's server
When a subscribed realm belongs to a trusted remote realm server, this always posts the entire batch to the host's home server using its home-server token. That server omits the unknown realm with a successful empty response, after which realmEventRoomsRequested prevents another lookup, so the host never joins the remote realm's event room. Group requests by the server that owns each realm and use that server's session, as required by ADR 004 §1.7–1.9, rather than adding another single-server path.
AGENTS.md reference: AGENTS.md:L299-L302
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
[Claude Code 🤖] The host still serves realms from its home realm server only: assertOwnRealmServer rejects any other server in app.boxel.realm-servers, so no subscribed realm can belong to a remote server yet. A remote realm's room would also live on that server's own homeserver, which the host's Matrix client can't join until federation exists. Per ADR 004 §1.9, 3c350a8 marks the assumption at the request with a TODO citing §1.7–1.9: ask each server about its own realms, with that server's session, once federation ships.
| type: 'm.room.history_visibility', | ||
| state_key: '', | ||
| content: { history_visibility: 'joined' }, |
There was a problem hiding this comment.
Preserve events emitted before the subscriber joins
When the first subscriber creates this room, its ID is stored before the endpoint response reaches the browser, so NodeAdapter can immediately start routing realm events there while the browser is still joining. With history_visibility: 'joined', any event emitted in that interval is permanently invisible to the subscriber; for resources that subscribe before their initial fetch specifically to close the read/subscription race, a write after that fetch but before the join can leave the UI stale indefinitely. The room activation or history policy needs to ensure that events sent during the join handshake remain visible.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
[Claude Code 🤖] Good catch, fixed in 3c350a8. The room is created with history_visibility: shared, so a host that joins after an event was sent gets it in the room's initial timeline. Replay isn't a concern: processDecryptedEventFromAuthRoom already drops realm events older than the client's start (startedAtTs), and the retention policy bounds the history. The events describe a realm anyone can read, so the visible history exposes nothing new.
…meanwhile
The room's history is visible to members ('shared' instead of 'joined'),
so an event sent between the room's creation and a host's join reaches
that host; the host already ignores events from before it started. The
room lookup's single-server assumption is marked with a TODO citing
ADR 004.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The host test mock reported every newly joined room in the AI room list, so a realm event room became an AI room and its events never reached the realm's subscribers. It now picks the list whose room_types admit the room, and leaves out of the AI list a room that list excludes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
richardhjtan
left a comment
There was a problem hiding this comment.
[Claude Code 🤖] This review covered the shared realm event room end to end: creation and storage, the dispatcher's world-readable branch, the /_realm-event-rooms endpoint, the host's join and re-ask flow, and the fixes to the earlier threads. It didn't cover sliding-sync behaviour against a real Synapse, and the realm-server suite hasn't run at this head because the CI run was cancelled.
One blocking issue, and it's about process, not code: the PR defers follow-ups without the Linear tickets AGENTS.md requires.
- Blocking: "Known gaps, for follow-up" defers two items with no ticket: room members can see each other's Matrix IDs, and hosts never leave realm event rooms. The
TODO(ADR 004 §1.7–1.9)injoinRealmEventRoomsTaskis a third. Each needs a Linear ticket linked from the PR description before merge. Decide the member-list gap on purpose. With Synapse's default user directory, users who share a room can find each other, and every account that opens the catalog now shares one room with every other. Leaving rooms is what keeps the issue's acceptance line true over time, where Synapse's work tracks the clients viewing a realm rather than everyone who ever opened it.
|
[Claude Code 🤖] Each deferral now has a Linear ticket, linked from a Follow-ups section in the description: CS-14250 (membership visibility and the user directory, written as a decision to make on purpose), CS-14251 (a leave policy, so membership tracks the hosts using the realm) and CS-14252 (per-server lookups once the host federates, the ADR 004 TODO). The test-plan item for the host message-service test is waiting on CI for 37768d3, which fixes the mock's room-list routing. |
|
[Claude Code 🤖] @richardhjtan, thanks. The blocking point is addressed: each deferral has a Linear ticket, linked from the new Follow-ups section of the description.
You're right that the realm-server suite hadn't run at that head. Locally, against a real Synapse, |
getRoomState answers with the state event's content, so the room type is its `type`, not `content.type`. Without it every typed room still went to the AI room list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Closing this one. Its shared room for a world-readable realm's events would show every subscriber's Matrix id to every other subscriber, and we've decided Boxel must not do that. ADR 011 (#6921) proposes the rule. Users named in a grant on the same realm may see each other's ids. Readers of a world-readable realm see no one, and no one sees them (§1.3). Two end users may share a room only when they may see each other (§1.5). Hiding the members of this room isn't possible:
Ways to cut the fan-out without a shared room:
The dispatcher's rate limit, back-pressure pause and transaction-id retries from this PR carry over to any of these. |
Background and Goal
A realm event for a world-readable realm went to the session room of every user who has one, whether or not that user had the realm open. A write to the catalog made Synapse persist one event per registered account. This PR makes a world-readable realm send each realm event once, into a Matrix room of its own. Every host that subscribes to the realm joins that room, so a write costs one persisted event, and its fan-out follows the clients that joined the room, not the number of accounts.
Realms that aren't world-readable work as they do today: each event goes to the session room of every user with their own grant.
Where to start
packages/runtime-common/realm.tsensureRealmEventRoomandpackages/runtime-common/matrix-client.tscreateRealmEventRoom: the realm creates the room, through the matrix client it sends its events with, the first time a host asks for it.packages/realm-server/handlers/handle-realm-event-rooms.ts:POST /_realm-event-roomswith{ realms: string[] }answers a signed-in host with{ rooms: { [realm]: roomId } }. It covers only the named realms that are world-readable and served here.packages/realm-server/node-realm.tsprepareRoomSends: the realm event dispatcher asks it for the sends one event needs. For a world-readable realm that's one send into the realm's room; otherwise it's one send per session room. Both kinds go through the dispatcher's rate limit, back-pressure pause and transaction-id retries.packages/host/app/services/realm-server.tsjoinRealmEventRoom: realm-server registers withmessage-service.onFirstSubscription, so each realm's first subscription queues it, including realms subscribed to before realm-server existed. Queued realms are looked up in one request, and the host joins each room it gets back. A repeat join changes nothing, and the SDK answers it locally once sync has the room.Key decisions and non-obvious mechanics
app.boxel.realm-events. It uses thepublic_chatpreset (anyone may join without an invite), is left out of the room directory, and hashistory_visibility: sharedand the realm room retention policy. A host that is still joining when an event is sent receives it from the history; the host already ignores realm events from before it started. Its power levels let only the user that created it send events or change state. That's the matrix client the realm sends its events with, which in production is the realm server's user, so a member can't forge a realm event.realm_event_roomsstores the room (realm URL is the primary key). The insert isON CONFLICT DO UPDATE ... RETURNING, so every racing insert gets back the room that was stored first, and two realm-server processes that race end up using the same room. A stored room the server user is no longer in (after a homeserver reset, say) is forgotten on the first failed send, and the next request creates a new one. Within one process, concurrent requests share one in-flight creation.prepareRoomSendsreturns no sends.is_dm: false) would pick these rooms up, so it excludes them withnot_room_types. A separate list (realm-event-room) carries them, with the auth room's timeline limit and the user's own membership as required state, becauseprocessDecryptedEventchecks that membership.PATCH /_permissionsthat changes the'*'grant sendsrealms-list-updatedto every user the realm names. Their hosts ask/_realm-event-roomsagain about every realm they have subscribed to, and join the new room.assertOwnRealmServerholds. A TODO cites ADR 004 §1.7–1.9 for asking each server about its own realms once federation ships.realm_event_roomsis server-only, soschema-dump.shexcludes it. The host snapshot is renamed to the new migration's timestamp, and its contents are unchanged.setupMatrixRoomalso joins the test realm's event room, andgetMessagesSincereads both rooms. Tests that wait for realm events on a world-readable realm therefore exercise the new path./_realm-event-roomswith no rooms unless a test sets one withsetRealmEventRoom.Follow-ups
TODO(ADR 004 §1.7–1.9)injoinRealmEventRoomsTask).Test plan
session-room-queries-test: 14/14. A'*'grant adds no users. Covers fetching and storing a realm's event room, including a second insert that leaves the first room in place.node-realm-test,realm-event-dispatcher-testandsession-room-queries-test, run locally. Covers concurrent stores of a realm's room, and forgetting a room the server user is no longer in. A world-readable realm sends once to its room and not to session rooms. With no room it sends nothing. Once it is no longer world-readable it sends to session rooms.realm-event-rooms-test(local, 5/5), against a real Synapse:setupMatrixRoom.permissions-test(local, 16/16): changing the'*'grant sends realms-list-updated to a user the realm already named.Integration | message-service | realm event rooms(local, with thematrix-servicemodules, 42/42): subscribing to a world-readable realm makes the test user a member of the room, and an event sent into the room reaches the realm's subscriber. The mock's sliding sync honoursroom_types/not_room_types.🤖 Generated with Claude Code