Skip to content

Send a world-readable realm's events to one shared Matrix room - #6906

Closed
lukemelia wants to merge 8 commits into
mainfrom
cs-14191-every-event-in-a-world-readable-realm-is-sent-to-all-170
Closed

lukemelia wants to merge 8 commits into
mainfrom
cs-14191-every-event-in-a-world-readable-realm-is-sent-to-all-170

Conversation

@lukemelia

@lukemelia lukemelia commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

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.ts ensureRealmEventRoom and packages/runtime-common/matrix-client.ts createRealmEventRoom: 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-rooms with { 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.ts prepareRoomSends: 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.ts joinRealmEventRoom: realm-server registers with message-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

  • The room. It has the custom type app.boxel.realm-events. It uses the public_chat preset (anyone may join without an invite), is left out of the room directory, and has history_visibility: shared and 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.
  • One room per realm, across processes. realm_event_rooms stores the room (realm URL is the primary key). The insert is ON 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.
  • No room yet means no event. A world-readable realm whose room hasn't been created has no host listening for its events, so prepareRoomSends returns no sends.
  • Sliding sync. The AI room list (is_dm: false) would pick these rooms up, so it excludes them with not_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, because processDecryptedEvent checks that membership.
  • Rollout. Nothing sends to both places during the transition. A tab loaded before the deploy misses public-realm events until it reloads.
  • A realm that stops being world-readable goes back to sending to session rooms. Hosts stay members of its room, but nothing is sent there.
  • A realm that becomes world-readable. A PATCH /_permissions that changes the '*' grant sends realms-list-updated to every user the realm names. Their hosts ask /_realm-event-rooms again about every realm they have subscribed to, and join the new room.
  • Endpoint bounds and failures. A request names at most 100 realms, by URL or registered prefix, and the server works through them four at a time, since a lookup can mount a realm and create its room. When a lookup fails, the answer is a 503 carrying the rooms it did find. The host joins those and asks again about the rest with backoff.
  • One realm server. The host asks its home realm server about every realm, as the rest of the host does while assertOwnRealmServer holds. A TODO cites ADR 004 §1.7–1.9 for asking each server about its own realms once federation ships.
  • Schema snapshot. realm_event_rooms is server-only, so schema-dump.sh excludes it. The host snapshot is renamed to the new migration's timestamp, and its contents are unchanged.
  • Tests.
    • The realm-server test helper setupMatrixRoom also joins the test realm's event room, and getMessagesSince reads both rooms. Tests that wait for realm events on a world-readable realm therefore exercise the new path.
    • The host's realm-server mock answers /_realm-event-rooms with no rooms unless a test sets one with setRealmEventRoom.
  • The load harness joins the realm's event room as well as the rooms it's invited to.

Follow-ups

  • CS-14250 — members of a realm event room can list each other and find each other in the user directory. Decide whether that's acceptable, and hide membership if not.
  • CS-14251 — hosts never leave realm event rooms, so a realm's fan-out drifts toward everyone who ever opened it. Add a leave policy.
  • CS-14252 — ask each realm server about its own realms once the host federates (the TODO(ADR 004 §1.7–1.9) in joinRealmEventRoomsTask).

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-test and session-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:
    • the same room comes back on every request;
    • private and unknown realms are left out;
    • a request with no session gets 401, and a bad body gets 400;
    • a user who wasn't invited can join, but gets 403 when sending a realm event.
  • The rest of the realm-server suite in CI, including the tests that wait for realm events through setupMatrixRoom.
  • permissions-test (local, 16/16): changing the '*' grant sends realms-list-updated to a user the realm already named.
  • Host Integration | message-service | realm event rooms (local, with the matrix-service modules, 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 honours room_types/not_room_types.
  • Host and Matrix Playwright suites in CI.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files      1 suites   1h 55m 6s ⏱️
5 844 tests 5 832 ✅ 11 💤 0 ❌ 1 🔥
5 859 runs  5 846 ✅ 11 💤 1 ❌ 1 🔥

Results for commit 37768d3.

For more details on these errors, see this check.

Realm Server Test Results

    1 files    362 suites   2h 11m 20s ⏱️
6 019 tests 6 019 ✅ 0 💤 0 ❌
6 082 runs  6 082 ✅ 0 💤 0 ❌

Results for commit 3686324.

lukemelia and others added 2 commits October 21, 2026 22:28
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
@lukemelia
lukemelia force-pushed the cs-14191-every-event-in-a-world-readable-realm-is-sent-to-all-170 branch from a2fe1c1 to 035f235 Compare October 22, 2026 03:02
…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
habdelra changed the base branch from main to cs-14189-broadcastrealmevent-is-fire-and-forget-with-no-queue-so-a October 22, 2026 12:31

@habdelra habdelra left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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.

  1. Blocking: insertRealmEventRoom returns no row under a cross-process race. Use ON CONFLICT DO UPDATE ... RETURNING. See the thread on session-room-queries.ts.
  2. 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.
  3. 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.
  4. The event-room sender has no stale-room recovery, unlike the session-room sender beside it. See onFailure in node-realm.ts.
  5. Cancel the join task in resetState. See realm-server.ts.
  6. The host mock can't pin the not_room_types exclusion, or delivery through the shared room. See message-service-test.ts.
  7. Prefix-form identifiers are dropped (ADR 002 §1.3, proposed). See handle-realm-event-rooms.ts.
  8. Follow-up: room memberships only grow; reword the range comment. See matrix-constants.ts.

Comment on lines +142 to +156
'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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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_id

Separately, 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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).

Comment on lines +500 to +518
// 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 [];
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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.

Comment on lines +77 to +80
// 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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_REQUEST closer 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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.

Comment on lines +533 to +539
onFailure: async (e: unknown) => {
realmEventsLog.error(
`Unable to send event in realm event room ${roomId} for ${realmUrl}: ${
e instanceof Error ? e.message : String(e)
}`,
);
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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.

Comment on lines +192 to +193
this.realmEventRoomsPending.clear();
this.realmEventRoomsRequested.clear();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Claude Code 🤖] Fixed in 6153ab6. resetState cancels joinRealmEventRoomsTask and the new retry task before clearing the sets, and resets the backoff.

Comment on lines +120 to +151
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',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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.

Comment on lines +310 to +315
// 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,
];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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>
@habdelra
habdelra requested a review from a team October 22, 2026 13:01
@habdelra
habdelra marked this pull request as ready for review October 22, 2026 13:02
@habdelra
habdelra requested review from a team and removed request for a team October 22, 2026 13:02
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-22T13:07:17.520583Z 6153ab6 Draft marked ready
ℹ️ 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" or "@codex security review".

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
@habdelra
habdelra changed the base branch from cs-14189-broadcastrealmevent-is-fire-and-forget-with-no-queue-so-a to main October 22, 2026 13:03

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +770 to +771
let response = await this.network.fetch(
`${this.url.href}_realm-event-rooms`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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.

Comment on lines +331 to +333
type: 'm.room.history_visibility',
state_key: '',
content: { history_visibility: 'joined' },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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.

habdelra and others added 2 commits October 22, 2026 09:08
…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 richardhjtan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[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.

  1. 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) in joinRealmEventRoomsTask is 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.

@habdelra

Copy link
Copy Markdown
Contributor

[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.

@habdelra

Copy link
Copy Markdown
Contributor

[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.

  • CS-14250: members of a realm event room can list each other and find each other in the user directory. Written as a decision to make on purpose, with options to hide membership (a Synapse module, directory settings that exclude these rooms, or server-side delivery) and an acceptance line in case the exposure isn't acceptable.
  • CS-14251: hosts never leave realm event rooms. The ticket proposes a leave policy (leave some time after the last unsubscribe, or prune at sign-in), so membership tracks the hosts that use the realm. That keeps the acceptance line true over time. Leaving and rejoining loses nothing, because history is shared and the host ignores events from before it started.
  • CS-14252: ask each realm server about its own realms once the host federates. This is the TODO(ADR 004 §1.7–1.9) in joinRealmEventRoomsTask.

You're right that the realm-server suite hadn't run at that head. Locally, against a real Synapse, realm-event-rooms-test and permissions-test pass 21/21, and node-realm-test and session-room-queries-test pass 33/33. CI is re-running at the current head. One host shard is red on the extended message-service test, and I'm debugging it locally now.

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>
@lukemelia

Copy link
Copy Markdown
Contributor Author

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:

  • Synapse returns a room's member list to any member, and no setting changes that.
  • Every join is an event in the room's timeline, which each member receives through sync and /messages, so blocking the member endpoints at a proxy doesn't help.
  • The public join rule from the public_chat preset also puts every member into Synapse's user directory results for every user on the server.

Ways to cut the fan-out without a shared room:

  • To-device messages. One sendToDevice request can address many users (* for all their devices). Synapse stores a delivery row per device but saves no room event, so this skips the event-saving path that overloads it. The host already handles to-device messages. The realm server would need to know who is subscribed, from a host registering interest rather than joining a room, which also limits sends to users who have the realm open.
  • Per-user session rooms, sent only to subscribers. Same rooms as today, but only for users with the realm open, rather than every user with an account.
  • A realm-server channel (SSE or WebSocket) for world-readable realms, which takes this load off Synapse entirely.

The dispatcher's rate limit, back-pressure pause and transaction-id retries from this PR carry over to any of these.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants