From 60ee5d314b7a3e819e1dd1adad79d1503daf6725 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Sun, 18 Oct 2026 23:38:47 -0400 Subject: [PATCH 1/5] Add ADR 004: federate realm servers, each paired with its own homeserver Records, as a retroactive proposed ADR, that Boxel is a federation of independent realm servers, each paired with exactly one Matrix homeserver, and the rules that keep new code compatible with that design before cross-server identity is built. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSuMQ23CEVDjQ9xMzoFRgU --- ...ers-each-paired-with-its-own-homeserver.md | 197 ++++++++++++++++++ docs/adr/README.md | 1 + 2 files changed, 198 insertions(+) create mode 100644 docs/adr/004-federate-realm-servers-each-paired-with-its-own-homeserver.md diff --git a/docs/adr/004-federate-realm-servers-each-paired-with-its-own-homeserver.md b/docs/adr/004-federate-realm-servers-each-paired-with-its-own-homeserver.md new file mode 100644 index 00000000000..758cadc6643 --- /dev/null +++ b/docs/adr/004-federate-realm-servers-each-paired-with-its-own-homeserver.md @@ -0,0 +1,197 @@ +--- +status: proposed +date: 2026-01-08 +recorded: 2026-10-18 +retroactive: true +scope: + - packages/realm-server/** + - packages/runtime-common/realm-auth-client.ts + - packages/runtime-common/matrix-backend-authentication.ts + - packages/runtime-common/matrix-client.ts + - packages/runtime-common/realm-permission-checker.ts + - packages/host/app/services/realm-server.ts + - packages/host/app/services/matrix-service.ts + - packages/host/app/services/realm.ts + - packages/boxel-cli/src/lib/profile-manager.ts + - packages/matrix/support/synapse/** +--- + +# 004. Federate realm servers, each paired with its own Matrix homeserver + +Boxel is designed as a federation of independent realm servers. Each realm +server is paired with exactly one Matrix homeserver, which provides identity +for that server. A user of one homeserver can be granted access to realms on +another realm server, and that realm server verifies the user against the +user's own homeserver through Matrix federation. Federation is not built yet, +and the host still allows only one realm server. This ADR records the target +design and the rules that keep new code compatible with it. + +## 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. + +### 1.1. One homeserver per realm server + +Each realm server MUST be paired with exactly one Matrix homeserver. The realm +server's own Matrix users MUST be registered on that homeserver. A host app +MUST use the homeserver paired with the realm server that serves it. + +### 1.2. Users are identified by full Matrix ids + +Code MUST identify a user by the full Matrix user id (`@localpart:server`). +Code MUST NOT key, store, or compare users by localpart alone. Code MUST NOT +derive a Matrix server name from a hostname. It MUST use the configured server +name (`MATRIX_SERVER_NAME`). + +### 1.3. Identity is verified by the user's own homeserver + +A realm server MUST verify a user's OpenID token with the homeserver that +issued it, named by the token's `matrix_server_name`, through +`/_matrix/federation/v1/openid/userinfo`. Each realm server MUST issue and +verify its own session tokens. A realm server MUST NOT accept another realm +server's session token as proof of identity. + +### 1.4. Realm permissions decide access + +A realm server SHOULD accept users from any homeserver. Realm permissions and +policies MUST decide what each user can do. A realm MAY limit access by +homeserver in its policy, by matching the `:server` suffix of the user id. + +A realm's `users` permission row MUST grant access only to users of the +paired homeserver. A user of another homeserver MUST be granted access by full +id or by a policy rule. + +### 1.5. Realm paths for users of other homeservers + +A realm created by a user of the paired homeserver MAY keep the path +`///`. A realm created by a user of another homeserver +MUST get a path that includes that user's server name, so that two users with +the same localpart cannot claim the same path. This ADR does not set the path +format. + +### 1.6. Each realm server serves its own infrastructure realms + +Each realm server MUST serve its own copy of the infrastructure realms (base, +catalog, and skills), addressed by their `@cardstack//` prefixes +(ADR 002 §1.4). + +### 1.7. Each realm server answers only for its own realms + +A realm server endpoint MUST answer only for the realms that server serves. +The client SHOULD split a query over realms on several realm servers into one +request to each server, made with the client's session on that server. The +client SHOULD merge the results. It SHOULD report a server it cannot reach +instead of failing the whole query. + +### 1.8. The host has one home realm server + +The host SHOULD treat the realm server that serves it as its home realm +server. It SHOULD reach each realm server, the home one included, through a +per-server object that holds that server's session token and makes that +server's auth and search calls. + +### 1.9. No new single-server assumptions + +New code MUST NOT add an `assertOwnRealmServer` call site, or another +assumption that only one realm server exists, unless a TODO at that site cites +this ADR. + +## 2. Rationale + +Federation lets organizations and individuals run their own realm server and +homeserver, and keep their data and identity under their own control. As with +email and Matrix, no single operator controls the network [10]. + +The pairing dates from the first realm authentication, which proved a user's +identity through Matrix [1]. In 2026 it was stated as an established rule: +"each realm server has a matrix server that is providing identity for their +respective realm server" [2]. Matrix is the root identity authority: minting +any Boxel token requires proving control of a Matrix account [4]. Verifying a +user with their own homeserver keeps that true across realm servers. Trust +between realm servers would be a second identity system that competes with +Matrix [10]. + +A localpart is unique only on one homeserver, so only the full id names a +user. A server name cannot be derived from a hostname, because Matrix +delegation lets the two differ: the server name `stack.cards` is served by a +host on a subdomain [5]. Policies already treat the `:server` suffix as an +authorization boundary [8], so realm permissions decide access rather than a +server-level allowlist [10]. A realm's `users` row keeps meaning users with an +account on the paired homeserver, so opening a realm to the whole federation +takes an explicit rule [10]. Users of other homeservers get server-qualified +realm paths because localparts collide [10]. + +Each server serves its own infrastructure realms because that is how prefixes +already resolve (ADR 002 §1.4) [9, 10]. + +A cross-server query fans out from the client because each server applies its +own permissions to its own realms, with the caller's own session on that +server. A proxy would need the home server to act as the user on other +servers, and no delegation model for that exists. A client that fans out can +still show the results it has when one server is unreachable [10], as boot +already does for a user's trusted realm servers [7]. The home-realm-server +split was proposed because the single `realm-server` service "is going to +continue to attract federation-unfriendly code and callers" [2]. + +The single-server checks are a deliberate stopgap: "`assertOwnRealmServer()` +preserves the single-server invariant" [6]. Each existing call site carries a +TODO to remove it once federated identity exists. §1.9 keeps new ones as easy +to find [10]. + +## 3. Considered options + +- **Federated realm servers, each paired with its own homeserver (chosen).** +- **One central realm server and homeserver.** This gives up self-hosting + and an open network [10]. +- **Realm servers trust each other's session tokens**, for example through + published signing keys. This makes a second identity authority beside + Matrix [10]. +- **A per-server allowlist of homeservers.** Realm permissions and policies + already express this per realm [10]. +- **One shared deployment of the infrastructure realms.** Each server serves + its own copy, as prefixes already assume [10]. +- **The home realm server proxies cross-server queries.** This needs + delegated identity, which does not exist [10]. + +## 4. Consequences + +Before a realm server can serve users of another homeserver, this code must +change: + +- `verifyOpenIdToken` in `packages/runtime-common/matrix-client.ts` already + uses the federation endpoint, but asks only the paired homeserver and + ignores `matrix_server_name` (§1.3) [3]. +- The host's `assertOwnRealmServer` call sites (§1.8, §1.9). +- Realm paths, the delete-ownership check, and published-realm domains are + keyed by localpart (`create-realm.ts`, `handle-delete-realm.ts`, + `handle-publish-realm.ts`) (§1.5). +- `userIdFromUsername` derives a server name from the homeserver hostname + (§1.2). +- `realm-permission-checker.ts` resolves the `users` row with a profile + lookup on the paired homeserver, which can also find remote users (§1.4). +- `assume-user`, prerender tokens, and account-data writes through the + Synapse admin API reach only the local server's realms and users. +- Every test realm server shares one homeserver. + +## 5. Sources + +1. #967 (`1a296c8936`, `0bc38061ed`), which added realm authentication over + Matrix. +2. #3804, merged 2026-01-08: Hassan Abdel-Rahman's statement of the 1:1 + pairing, and Luke Melia's review proposing per-server fan-out and a + `home-realm-server` service. +3. #3672, which moved realm-server authentication to OpenID tokens; + `verifyOpenIdToken` in `packages/runtime-common/matrix-client.ts`. +4. `.claude/skills/realm-auth/SKILL.md`: "Matrix is the root authority." +5. #4253, which added `MATRIX_SERVER_NAME`; #1002, which first separated the + server name from the homeserver hostname. +6. #5285, which assembles a user's realms from their trusted realm servers. +7. #5381, which degrades gracefully when a trusted realm server is + unreachable. +8. `skills/realm-policy-authoring/SKILL.md` in cardstack/boxel-skills, on + `actor() | endswith(":school.example")`. +9. ADR 002 §1.4. +10. Luke Melia, answering this ADR's questions, 2026-10-18. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1211c3aa3ed..e60bf566d17 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -16,6 +16,7 @@ or backfill one. | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -------- | | [001](001-record-architecture-decisions.md) | Record architecture decisions as short, normative ADRs | 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 | | [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 | From dc551cf3fe704d647181bd69ec0acc4d64704ddb Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 19 Oct 2026 15:28:10 -0400 Subject: [PATCH 2/5] Defer the cross-server meaning of infrastructure prefixes in ADR 004 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §1.6 required each realm server to serve its own copy of base, catalog and skills, addressed by prefix. Prefixes resolve per VirtualNetwork and the host serves base from its own bundle, so within a federation the same prefix names a different realm depending on whose client resolves it. Make §1.6 a SHOULD, drop the claim that ADR 002 implies it, and record the cross-server meaning of a prefix as an open question with the hybrid (base as the reader's copy, catalog and skills from the origin server) as the leading candidate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSuMQ23CEVDjQ9xMzoFRgU --- ...ers-each-paired-with-its-own-homeserver.md | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/docs/adr/004-federate-realm-servers-each-paired-with-its-own-homeserver.md b/docs/adr/004-federate-realm-servers-each-paired-with-its-own-homeserver.md index 758cadc6643..14f37cf2a9e 100644 --- a/docs/adr/004-federate-realm-servers-each-paired-with-its-own-homeserver.md +++ b/docs/adr/004-federate-realm-servers-each-paired-with-its-own-homeserver.md @@ -74,9 +74,9 @@ format. ### 1.6. Each realm server serves its own infrastructure realms -Each realm server MUST serve its own copy of the infrastructure realms (base, -catalog, and skills), addressed by their `@cardstack//` prefixes -(ADR 002 §1.4). +Each realm server SHOULD serve its own copy of the infrastructure realms +(base, catalog, and skills). What a prefix means in content read from another +realm server is an open question (§5). ### 1.7. Each realm server answers only for its own realms @@ -124,8 +124,10 @@ account on the paired homeserver, so opening a realm to the whole federation takes an explicit rule [10]. Users of other homeservers get server-qualified realm paths because localparts collide [10]. -Each server serves its own infrastructure realms because that is how prefixes -already resolve (ADR 002 §1.4) [9, 10]. +Each server serves its own infrastructure realms [10]. Each VirtualNetwork +maps the `@cardstack//` prefixes to the realms its own environment +names (ADR 002 §1.4) [9]. That makes card JSON portable between environments, +but not between realm servers (§5). A cross-server query fans out from the client because each server applies its own permissions to its own realms, with the caller's own session on that @@ -152,7 +154,7 @@ to find [10]. - **A per-server allowlist of homeservers.** Realm permissions and policies already express this per realm [10]. - **One shared deployment of the infrastructure realms.** Each server serves - its own copy, as prefixes already assume [10]. + its own copy instead [10]. - **The home realm server proxies cross-server queries.** This needs delegated identity, which does not exist [10]. @@ -176,7 +178,20 @@ change: Synapse admin API reach only the local server's realms and users. - Every test realm server shares one homeserver. -## 5. Sources +## 5. Open questions + +- **What does a prefix mean in content read from another realm server?** The + host maps each prefix to its own server's realm and serves base from its own + bundle [11, 12]. So a host paired with server A resolves a + `@cardstack/catalog/` link in a card from server B to A's catalog, and runs + B's card definitions against A's base. The leading candidate treats base as + part of the platform: the reader's copy, under a compatibility rule that + servers in a federation follow. Catalog and skills would then resolve + against the server the content came from, as URLs, which needs module + imports resolved by the importing module's server. Ask Luke Melia, and + `@backspace`, who designed prefix resolution [13]. + +## 6. Sources 1. #967 (`1a296c8936`, `0bc38061ed`), which added realm authentication over Matrix. @@ -193,5 +208,10 @@ change: unreachable. 8. `skills/realm-policy-authoring/SKILL.md` in cardstack/boxel-skills, on `actor() | endswith(":school.example")`. -9. ADR 002 §1.4. +9. ADR 002 §1.4 and §2. 10. Luke Melia, answering this ADR's questions, 2026-10-18. +11. `packages/host/app/services/network.ts`, which registers each prefix + against the realm the host's own config names. +12. `shimBundledBase` in `packages/host/app/lib/bundled-base.ts`, which serves + `@cardstack/base/*` from the host bundle. +13. `docs/realm-resource-identifiers.md`, added in #5720. From 5faf7ebd40bef68c9dbd35d1b438222c55e0d92f Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 20 Oct 2026 23:54:00 -0400 Subject: [PATCH 3/5] Add a Remediation section to ADR 004 List each known gap between the code and ADR 004's MUST requirements as one file, symbol, and broken section, following the Remediation rules for proposed ADRs. Move the gaps out of Consequences, which keeps only the federation work that no MUST requires, and widen the scope so every listed file resolves to this ADR. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSuMQ23CEVDjQ9xMzoFRgU --- ...ers-each-paired-with-its-own-homeserver.md | 52 ++++++++++++++----- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/docs/adr/004-federate-realm-servers-each-paired-with-its-own-homeserver.md b/docs/adr/004-federate-realm-servers-each-paired-with-its-own-homeserver.md index 14f37cf2a9e..2c966281018 100644 --- a/docs/adr/004-federate-realm-servers-each-paired-with-its-own-homeserver.md +++ b/docs/adr/004-federate-realm-servers-each-paired-with-its-own-homeserver.md @@ -9,10 +9,15 @@ scope: - packages/runtime-common/matrix-backend-authentication.ts - packages/runtime-common/matrix-client.ts - packages/runtime-common/realm-permission-checker.ts + - packages/runtime-common/db-queries/realm-permission-queries.ts + - packages/runtime-common/realm.ts - packages/host/app/services/realm-server.ts - packages/host/app/services/matrix-service.ts - packages/host/app/services/realm.ts + - packages/host/app/services/store.ts + - packages/host/app/services/host-mode-service.ts - packages/boxel-cli/src/lib/profile-manager.ts + - packages/boxel-cli/src/lib/seed-auth.ts - packages/matrix/support/synapse/** --- @@ -160,20 +165,10 @@ to find [10]. ## 4. Consequences -Before a realm server can serve users of another homeserver, this code must -change: - -- `verifyOpenIdToken` in `packages/runtime-common/matrix-client.ts` already - uses the federation endpoint, but asks only the paired homeserver and - ignores `matrix_server_name` (§1.3) [3]. -- The host's `assertOwnRealmServer` call sites (§1.8, §1.9). -- Realm paths, the delete-ownership check, and published-realm domains are - keyed by localpart (`create-realm.ts`, `handle-delete-realm.ts`, - `handle-publish-realm.ts`) (§1.5). -- `userIdFromUsername` derives a server name from the homeserver hostname - (§1.2). -- `realm-permission-checker.ts` resolves the `users` row with a profile - lookup on the paired homeserver, which can also find remote users (§1.4). +Beyond the gaps under Remediation, federation needs this work: + +- The host's `assertOwnRealmServer` call sites go away once the host reaches + each realm server through its own per-server object (§1.8). - `assume-user`, prerender tokens, and account-data writes through the Synapse admin API reach only the local server's realms and users. - Every test realm server shares one homeserver. @@ -215,3 +210,32 @@ change: 12. `shimBundledBase` in `packages/host/app/lib/bundled-base.ts`, which serves `@cardstack/base/*` from the host bundle. 13. `docs/realm-resource-identifiers.md`, added in #5720. + +## 7. Remediation + +- `packages/runtime-common/matrix-client.ts` (`verifyOpenIdToken`) breaks + §1.3. It asks only the paired homeserver and ignores `matrix_server_name`. +- `packages/runtime-common/matrix-client.ts` (`userIdFromUsername`, + `ensureFullMatrixUserId`) breaks §1.2. They derive the server name from the + homeserver hostname. +- `packages/realm-server/scripts/claude-prerender-token.ts` + (`deriveMatrixDomain`) breaks §1.2. It derives the server name from the + realm hostname. +- `packages/boxel-cli/src/lib/seed-auth.ts` (`deriveHostFromRealmUrl`) breaks + §1.2. It derives the server name from the realm hostname. +- `packages/boxel-cli/src/lib/profile-manager.ts` (`migrateFromEnv`) breaks + §1.2. It picks the server name from the Matrix URL. +- `packages/runtime-common/db-queries/realm-permission-queries.ts` + (`fetchAllRealmsWithOwners`, `fetchRealmOwnerUsername`, `isBotAccount`) + breaks §1.2. They reduce realm owners to localparts and recognize service + accounts by localpart. +- `packages/runtime-common/realm-permission-checker.ts` + (`effectiveRealmPermissions`) breaks §1.4. It applies the `users` row to any + user the paired homeserver finds a profile for, which can include users of + other homeservers. +- `packages/realm-server/handlers/handle-delete-realm.ts` breaks §1.2. It + matches the caller's localpart against the realm's namespace. +- `packages/realm-server/handlers/handle-publish-realm.ts` breaks §1.2. It + matches the caller's localpart against the owner's published-space domain. +- `packages/realm-server/handlers/create-realm.ts` breaks §1.5. It builds + every realm path from the owner's localpart. From 01ebda4523f9d60db5d89f259e945005aa489085 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Wed, 21 Oct 2026 00:00:28 -0400 Subject: [PATCH 4/5] Name the ADR 004 rules decided at recording time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ADR's date is when the pairing was stated as an established rule. The users-row rule in §1.4, and §1.5, §1.6 and §1.9, cite only the answers given when the ADR was recorded, so say so in the summary. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSuMQ23CEVDjQ9xMzoFRgU --- ...erate-realm-servers-each-paired-with-its-own-homeserver.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/adr/004-federate-realm-servers-each-paired-with-its-own-homeserver.md b/docs/adr/004-federate-realm-servers-each-paired-with-its-own-homeserver.md index 2c966281018..168f3be8d70 100644 --- a/docs/adr/004-federate-realm-servers-each-paired-with-its-own-homeserver.md +++ b/docs/adr/004-federate-realm-servers-each-paired-with-its-own-homeserver.md @@ -29,7 +29,9 @@ for that server. A user of one homeserver can be granted access to realms on another realm server, and that realm server verifies the user against the user's own homeserver through Matrix federation. Federation is not built yet, and the host still allows only one realm server. This ADR records the target -design and the rules that keep new code compatible with it. +design and the rules that keep new code compatible with it. The `users`-row +rule in §1.4, and §1.5, §1.6, and §1.9, were decided when this ADR was +recorded, not on its `date`. ## 1. Decision From b0071e63f0dccaacf274d60b2b27081d7280cc61 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Wed, 21 Oct 2026 01:13:45 -0400 Subject: [PATCH 5/5] Drop the closed prerender-token gap from ADR 004's Remediation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seed-minting prerender token script is gone from main, replaced by the diagnostic render token endpoint, which takes the realm owner's full user id from the database. The gap it listed is closed, so its entry goes (ADR 001 §1.6). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CSuMQ23CEVDjQ9xMzoFRgU --- ...derate-realm-servers-each-paired-with-its-own-homeserver.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/adr/004-federate-realm-servers-each-paired-with-its-own-homeserver.md b/docs/adr/004-federate-realm-servers-each-paired-with-its-own-homeserver.md index 168f3be8d70..4fb61c68296 100644 --- a/docs/adr/004-federate-realm-servers-each-paired-with-its-own-homeserver.md +++ b/docs/adr/004-federate-realm-servers-each-paired-with-its-own-homeserver.md @@ -220,9 +220,6 @@ Beyond the gaps under Remediation, federation needs this work: - `packages/runtime-common/matrix-client.ts` (`userIdFromUsername`, `ensureFullMatrixUserId`) breaks §1.2. They derive the server name from the homeserver hostname. -- `packages/realm-server/scripts/claude-prerender-token.ts` - (`deriveMatrixDomain`) breaks §1.2. It derives the server name from the - realm hostname. - `packages/boxel-cli/src/lib/seed-auth.ts` (`deriveHostFromRealmUrl`) breaks §1.2. It derives the server name from the realm hostname. - `packages/boxel-cli/src/lib/profile-manager.ts` (`migrateFromEnv`) breaks