Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
---
status: proposed
date: 2026-10-22
scope:
- packages/realm-server/**
- packages/runtime-common/matrix-client.ts
- packages/runtime-common/db-queries/realm-permission-queries.ts
- packages/host/app/services/matrix-service.ts
- packages/host/app/services/message-service.ts
- packages/host/app/services/realm-server.ts
- packages/host/app/services/ai-assistant-panel-service.ts
- packages/host/app/tools/**
- packages/host/app/components/matrix/register-user.gts
- packages/ai-bot/**
- packages/bot-runner/**
- packages/matrix/support/synapse/**
---

# 011. Reveal a user's Matrix id only to users who share a realm with them

A user's Matrix user id is the name they sign in with. Boxel does not show it
to other users, except to the users who have been granted access to the same
realm. Matrix shows every member of a room to every other member, and Synapse
has no setting that hides them. So the rule is kept by controlling who shares a
room, and by turning off the homeserver features that let users find each
other. A design that delivers one event to many users therefore can't put them
in one room.

## 1. Decision

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD",
"SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this
document are to be interpreted as described in BCP 14 (RFC 2119, RFC 8174) when,
and only when, they appear in all capitals, as shown here.

In this ADR, an end user is a person's Matrix account. The realm server's
users, the AI bot and the other bots are service users, not end users. A
Matrix id includes its localpart, so these rules cover the username alone too.

### 1.1. Matrix ids are private by default

Boxel MUST NOT reveal an end user's Matrix id to another end user, except as
§1.2 and §1.4 allow. This covers every channel, including room membership,
room state and timelines, user directory search, presence, realm events and
realm-server API responses.

### 1.2. Users who share a realm may see each other

When two end users each hold a realm permission grant that names them in the

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.

"a realm permission grant that names them" is ambiguous with ADR 006 §1.3: a RealmPolicy grant can also name a user (for example an actor() predicate). It isn't clear whether a user who gets access only through a policy grant "shares a realm" under §1.2. Say explicitly whether policy grants count, since the invite-check module and the Remediation work will need one answer.


Generated by Claude Code

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.

[Claude Code 🤖] Decided in bfc2651: policy grants don't count. §1.2 now says a RealmPolicy grant (ADR 006 §1.3) doesn't count, even one whose predicate matches a single user. Only a permission row whose subject is the user names them. The Rationale gives the reason. The homeserver's invite check can't evaluate a predicate, and a predicate can match as many unrelated users as users does. This also matches #6927, whose room holds only the grantees the permission rows name.

same realm, Boxel MAY reveal each one's Matrix id to the other. This includes
the realm owner. The `'*'` and `users` grants name no one, so they do not
count. A `RealmPolicy` grant (ADR 006 §1.3) does not count either, even one
whose predicate matches a single user: only a permission row whose subject is
the user names them.

### 1.3. Reading an open realm reveals no one

An open realm is one readable through a `'*'` or `users` grant. Reading an
open realm, or subscribing to its events, MUST NOT reveal the reader's Matrix
id to another end user. It MUST NOT reveal another end
user's Matrix id to the reader, except as §1.2 and §1.4 allow.

### 1.4. A realm's URL may name its owner

A realm's URL MAY contain the Matrix username of the user who owns it. This
covers the realm-server path `/<username>/<realm>/` and the published-space
host `<username>.<domain>`.

Boxel MUST NOT offer a public or queryable list of realm URLs, except as this
section allows. An endpoint MAY list the realms a user holds a grant on that
names them. An endpoint MAY list world-readable realms that are not published
realms, with their URLs and names: an owner who makes a realm world-readable
agrees to have it found. A list MUST NOT include a published realm. Outside
these lists, a user learns a realm's URL only when someone gives it to them.

### 1.5. End users share a room only when they may see each other

Two end users MUST NOT both be joined to, or invited to, the same Matrix room
unless §1.2 lets each see the other's Matrix id. A room MAY hold one end user
and any number of service users.

### 1.6. The homeserver doesn't let users find each other

The homeserver MUST NOT return one end user to another in user directory

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.

§1.6 leaves out Synapse's unauthenticated GET /_matrix/client/v3/register/available?username=. Anyone can use it to confirm that a guessed Matrix id exists, which is the oracle the Rationale (L117-119) says the profile settings close. The host depends on it (isUsernameAvailable in packages/host/app/services/matrix-service.ts:2817, called from packages/host/app/components/matrix/register-user.gts:486), so it isn't listed under Remediation, and those files are outside scope. Either add a requirement (rate-limit or gate it) and a Remediation entry, or say why it's accepted.


Generated by Claude Code

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.

[Claude Code 🤖] Addressed in bfc2651 by accepting it explicitly. §1.6 now says the homeserver MAY tell a person registering whether a username is taken, and MUST rate-limit those answers. The Rationale explains why it can't be closed: registration has to refuse a taken username, so hiding it from /register/available (for example with inhibit_user_in_use_error) only moves the answer to the final registration request. At v1.163.0, Synapse already rate-limits /register/available by client address (UsernameAvailabilityRestServlet, sleep_limit=1, reject_limit=1), so no Remediation entry is needed. register-user.gts is now in scope.

search. It MUST either disable the user directory or filter it, for example
with a module that implements `check_username_for_spam`. Presence MUST stay
disabled.

The homeserver MUST answer a profile request only when it is authenticated
and the requester shares a room with the user
(`require_auth_for_profile_requests` and
`limit_profile_requests_to_users_who_share_rooms`). It MUST NOT answer profile
requests over federation (`allow_profile_lookup_over_federation: false`).

The homeserver MUST reject an invite from one end user to another unless §1.2
lets each see the other's Matrix id, for example with a module that implements
`user_may_invite`. It MUST NOT put the inviter's profile into an invite
(`include_profile_data_on_invite: false`).

The homeserver MAY tell a person who is registering whether a username is
taken (`/register/available`), and MUST rate-limit those answers.

These rules apply to every homeserver Boxel runs, including the configuration

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.

This doesn't reconcile with ADR 004 (federated realm servers, each with its own homeserver), which shares packages/matrix/support/synapse/** and realm-server/** in scope. Under federation, the homeserver's user_may_invite check (L90-92) has to evaluate §1.2 against grants held on other realm servers, which it can't see. Also, allow_profile_lookup_over_federation: false blocks the cross-server profile lookups that federation needs. And homeservers that Boxel doesn't run aren't bound by §1.6 at all. At least cite ADR 004 and state how §1.5/§1.6 apply to cross-homeserver users, or list that as an open question.


Generated by Claude Code

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.

[Claude Code 🤖] Addressed in bfc2651. The Open questions section is reserved for retroactive ADRs (ADR 001 §1.3), so this went into the Decision instead. A new §1.7 cites ADR 004. A design that puts end users of different homeservers in one room, or lets one invite another, MUST first change this ADR to say how the invite check learns grants held on another realm server. The Rationale notes that a homeserver Boxel doesn't run can't be held to §1.6. It also notes that allow_profile_lookup_over_federation: false doesn't block ADR 004's identity check, which uses /_matrix/federation/v1/openid/userinfo rather than profile lookups.

kept outside this repository.

### 1.7. Users of other homeservers

ADR 004 lets users of one homeserver hold grants on realms another homeserver
serves, but federation isn't built yet. A design that puts end users of
different homeservers in one room, or lets one invite another, MUST first
change this ADR to say how the invite check of §1.6 learns grants held on
another realm server. A homeserver can see only its own realm server's
grants.

## 2. Rationale

The team decided that Boxel must not expose one user's Matrix username to
another without a reason the user agreed to. Sharing a realm is that reason:
the owner chose whom to grant access to, and collaborators in a realm already
see each other's activity in it.

Matrix gives no way to hide a room's members from each other. A member can
list the room's members, and every join is an event in the room's timeline,
which each member receives through sync. Synapse has no setting that changes
this, as of v1.163.0, the version Boxel runs. Blocking the member-list
endpoints at a proxy leaves the timeline. So the rule has to be kept by
deciding who shares a room (§1.5), not by hiding members after the fact.

A realm readable through a `users` grant has the same shape as a
world-readable one: many readers who chose the realm, not each other. On one
homeserver, `users` is close to everyone. Policy grants are left out because
the homeserver's invite check can't evaluate a predicate, and a predicate can
match as many unrelated users as `users` does.

Synapse's user directory also returns the members of any room with a `public`
join rule to every user on the server, whether or not they share the room.
One public room would make its members searchable by everyone, so §1.6 turns
the directory off as a backstop. Filtering it instead is possible: Synapse
passes `check_username_for_spam` the id of the user who is searching, so a
module can apply §1.2 to each result.

Room membership isn't the only way to learn a Matrix id. By default, Synapse
answers a profile request for any user, without authentication and over
federation, so anyone can confirm that a guessed Matrix id exists. It also
lets any user invite any other, and the invite shows the inviter's id and
profile. Any client can call these APIs directly, so the host app's own
behavior can't keep the rule. The homeserver has to.

Registration is the exception. It has to refuse a username that is taken, so
it always reveals that the username exists. Hiding it from
`/register/available` only moves the answer to the registration request
itself. Synapse rate-limits `/register/available` by client address, which
slows guessing, and a realm URL may name its owner anyway (§1.4).

Federation (ADR 004) is deferred by §1.7 rather than designed here. A
homeserver Boxel doesn't run can't be held to §1.6, and the invite check has
no view of grants on another realm server. Identity checks don't need profile
lookups over federation: ADR 004 §1.3 verifies a user through the OpenID
userinfo endpoint, which §1.6 leaves open.

## 3. Considered options

- **Reveal ids only within a shared realm (chosen).** It allows what Boxel
does today in private realms, and it gives a test a reviewer can apply.
- **Never reveal one user's id to another.** Collaborators in a realm already
see each other's activity, and realm owners manage grants by Matrix id.
- **Reveal ids when a user accepts an invite from another user.** Boxel has no
invite flow between end users yet. When it does, its consent rule belongs in
a change to this ADR.
- **Let users share rooms, and hide the members.** Synapse can't hide members
from each other, and the timeline still carries every join.
- **Fork or reimplement Synapse to control what it reveals.** A homeserver of
our own could stop listing a room's members and leave other users' joins out
of each member's timeline, so end users could share a room without seeing
each other. That is a large, ongoing undertaking, and it isn't planned now.
If Boxel takes it on, a change to this ADR can relax §1.5.

## 4. Consequences

A design that delivers one event to many end users who don't share a realm,
such as the events of a world-readable realm, can't use a room they all join.
It can send to each user's own room, send to-device messages, or deliver
through a realm-server channel. A shared room for a world-readable realm's
events, as proposed in #6906, breaks §1.3 and §1.5.

## 5. Remediation

- `packages/host/app/tools/invite-user-to-room.ts` (`InviteUserToRoomTool`)
breaks §1.5. It invites any Matrix id to a room, with no check that the
invitee shares a realm with the room's other end users.
- `packages/matrix/support/synapse/dev/homeserver.yaml`,
`packages/matrix/support/synapse/test/homeserver.yaml` and
`packages/matrix/support/synapse/test-without-registration-token/homeserver.yaml`
break §1.6. They leave the user directory, unauthenticated profile requests,
profile lookup over federation, and profile data in invites at their
defaults, which are all on. `limit_profile_requests_to_users_who_share_rooms`
has to be turned on together with `require_auth_for_profile_requests`:
Synapse refuses to start with the first and not the second.
- `packages/runtime-common/db-queries/realm-permission-queries.ts`
(`fetchEffectiveRealmPermissions`) and
`packages/realm-server/middleware/multi-realm-authorization.ts`
(`hasMatrixProfile`) depend on what §1.6 forbids. Both send an
unauthenticated profile request, through `fetchMatrixProfile`, to learn
whether the account exists. Under §1.6 that request fails, and a failed
lookup withholds the user's `users` grant. In
`multi-realm-authorization.ts` the user is the signed-in principal, whose
account the realm server verified when it issued their session, so the
lookup may not be needed there. `fetchEffectiveRealmPermissions` runs in worker
processes that hold no Matrix client, so it needs another way to check an
account. Both have to change before the homeserver applies §1.6.
- `packages/matrix/support/synapse/dev/homeserver.yaml`,
`packages/matrix/support/synapse/test/homeserver.yaml` and
`packages/matrix/support/synapse/test-without-registration-token/homeserver.yaml`
also break §1.6 because they load no module (`modules:`) that implements
`user_may_invite` to reject invites between end users who don't share a
realm.
- The homeserver configurations for staging and production are kept outside
this repository, so no entry here can name them. They break §1.6 in the same
ways as the files above until each one sets the profile and invite settings
and loads the invite module. `.github/workflows/sync-synapse-assets.yml`
copies `packages/matrix/support/synapse/modules/` to those homeservers, but
each configuration still has to list the module under `modules:`.
4 changes: 2 additions & 2 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ or backfill one.
| [001](001-record-architecture-decisions.md) | Record architecture decisions as short, normative ADRs | proposed |
| [002](002-keep-identifiers-canonical-resolve-at-network-boundary.md) | Keep identifiers as canonical RRIs, and resolve them to URLs only at the network boundary | proposed |
| [003](003-reach-cloud-services-through-adapters.md) | Reach proprietary cloud services only through adapters | proposed |
| [004](004-federate-realm-servers-each-paired-with-its-own-homeserver.md) | Federate realm servers, each paired with its own Matrix homeserver | proposed |
| [004](004-federate-realm-servers-each-paired-with-its-own-homeserver.md) | Federate realm servers, each paired with its own Matrix homeserver | proposed |
| [005](005-authorize-key-changes-only-with-a-session-card-code-cant-reach.md) | Authorize changes to a user's keys only with a session that card code can't reach | proposed |
| [006](006-grant-realm-permissions-per-realm-and-widen-them-only-with-a-policy.md) | Grant realm permissions per realm, and widen them only with a realm policy | proposed |
| [007](007-keep-motion-a-reactive-layer-that-state-never-waits-on.md) | Keep motion a reactive layer that application state never waits on | proposed |
| [008](008-build-the-secure-origins-pages-as-their-own-ember-app.md) | Build the secure origin's pages as their own Ember app | proposed |
| [009](009-build-new-ui-with-pret-ui-not-boxel-ui.md) | Build new UI with Pret UI, and deprecate boxel-ui | proposed |
| [010](010-give-loaded-modules-native-esm-semantics.md) | Give the modules the Loader loads native ES module semantics | proposed |

| [011](011-reveal-a-users-matrix-id-only-to-users-who-share-a-realm-with-them.md) | Reveal a user's Matrix id only to users who share a realm with them | proposed |
Loading