Matey #
Mooring AT Protocol identities to Matrix
- Status: Draft (experimental)
- Version: 2026-08-04
- Editor: Robin Berjon
Matey specifies how an AT Protocol (atproto) identity — a DID, typically
did:plc, with its handle, PDS, and OAuth-capable authorization server — and a
Matrix identity — an MXID on a homeserver — are linked to one another,
verified against one another, and used together. Concretely it defines:
- the PLC operations and DID document contents that point an AT identity at a Matrix identity (§3.2);
- how the DID is stored as part of the Matrix identity (§3.3);
- the bidirectional verification process that establishes that the two identities match (§4);
- the protocol interactions that let a user log into a Matrix server — one not known in advance — using their AT identity, including account creation and bring-your-own-server flows (§5);
- recommended approaches for keeping Matrix account metadata (name, bio, avatar…) synchronized
with the user's
app.bsky.actor.profilerecord (§6).
Each part is marked as [EXISTS] (uses deployed, specified machinery), [CONVENTION] (uses existing extension points but requires this spec's conventions), or [NEEDS DESIGN] (requires new protocol or implementation work). A consolidated inventory is in §7.
This repository also carries a reference implementation: the @matey/client library, the
matey-broker AT auth adapter, a Tuwunel-based deployment, and a shared-counter example app —
see Appendix A for the layout and
Appendix B for deployment topologies.
Appendix C sketches the intended use of
moorings for CRDT-based collaborative documents.
The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as described in RFC 2119.
1. Overview #
1.1 Goals #
- A single, user-owned identity: the atproto DID is the root; Matrix accounts hang off it.
- No prior knowledge of the homeserver: everything a client needs to log a user into Matrix is discoverable from the user's handle or DID.
- Mutual, third-party-checkable verification: anyone can confirm that
did:plc:…and@user:serverdesignate the same person, without trusting either side's claim alone. - Transparent onboarding: a user with no Matrix account gets one provisioned that is AT-OAuth native and bidirectionally linked from the start; a user with an existing Matrix account can retrofit it so that it can always be logged into with AT OAuth thereafter.
- Work with today's deployed systems (PLC directory, atproto OAuth, Matrix v1.15+ next-generation auth, MAS) with a minimal, clearly identified set of new components.
1.2 Non-goals #
- Bridging Matrix rooms/messages to Bluesky content (see mautrix-bluesky for DM bridging — orthogonal to identity).
- Portable Matrix identity itself (MSC1228/MSC2787/MSC4080 territory). Matey links identities; it does not make MXIDs portable.
- Replacing Matrix E2EE device verification. A mooring identifies an account, not a device.
1.3 Terminology #
- Mooring: the verified bidirectional link between one DID and one MXID, established per [§4].
- Matey client: a Matrix client implementing the flows in [§5].
- Matey homeserver: a homeserver deployment (homeserver + MAS + AT auth adapter + optionally a sync agent) implementing [§5]–[§6].
- AT auth adapter: the component that lets Matrix's auth stack authenticate users via atproto OAuth ([§5.2]).
- Linking ceremony: the client-driven PLC update that writes the Matrix pointer into the DID document ([§5.5]).
- Standard atproto terms (DID, handle, PDS, AppView, relay) per atproto.com/specs; standard Matrix terms (MXID, homeserver, C–S API, MAS) per spec.matrix.org.
1.4 Baseline requirements #
- The Matrix homeserver MUST implement the OAuth 2.0 API (Matrix v1.15+, MSC3861 family) and extended profile fields (Matrix v1.16+, MSC4133). In practice today this means Synapse + Matrix Authentication Service (MAS), or another next-gen-auth server (e.g. Tuwunel).
- The AT identity MUST be a
did:plcordid:webaccount whose PDS supports atproto OAuth and (fordid:plclinking via the PDS) thecom.atproto.identity.*PLC-operation endpoints.
2. Background (informative) #
atproto identity. An account is rooted in a DID (did:plc:… resolved via
https://plc.directory/{did}, or did:web:… resolved via /.well-known/did.json). The DID
document carries: the handle as an at:// URI in alsoKnownAs (first valid entry wins, verified
bidirectionally via DNS TXT _atproto. or /.well-known/atproto-did); the signing key as the
#atproto verification method; and the PDS as the #atproto_pds service. Third-party service
entries and extra alsoKnownAs URIs are explicitly permitted and preserved but ignored by atproto
itself (PLC spec: "The PLC server does not
cross-validate alsoKnownAs or service entries"). atproto OAuth
is a hardened OAuth 2.1 profile — not OIDC — with mandatory PAR, PKCE (S256), and DPoP with
server nonces; client_id is a URL to a client metadata document; the token's sub is the
account DID, trustworthy only after the client re-verifies that the issuing authorization server
is authoritative for that DID.
Matrix identity & auth. An MXID @localpart:server.name is immutable; localparts use
a-z 0-9 . _ = - / +; the whole MXID is at most 255 bytes. Server discovery goes
https://{server.name}/.well-known/matrix/client → C–S base_url. Since Matrix v1.15, auth is
OAuth: clients fetch GET /_matrix/client/v1/auth_metadata (RFC 8414 metadata; 404 + M_UNRECOGNIZED
means legacy auth only), register dynamically (RFC 7591), and run an authorization-code + PKCE
flow with rotating refresh tokens; prompt=create requests registration. MAS is the deployed
implementation (running matrix.org since April 2025) and supports statically configured upstream
OAuth 2.0/OIDC providers with Jinja2 claims mapping, account linking, and an admin API. Since
Matrix v1.16, profiles are extensible key–value stores (MSC4133): reverse-DNS custom fields, 64 KiB
total, readable without auth by default and over federation via GET /_matrix/federation/v1/query/profile.
3. The mooring data model #
3.1 Shape #
A mooring is a pair of pointers, one in each system, that reference each other:
DID document Matrix profile
──────────── ──────────────
alsoKnownAs: com.berjon.matey.did:
"matrix:u/robin:berjon.com" ◄──────► "did:plc:ewvi7nxzyoun6zhxrhs64oiz"
service (optional hint):
#matrix → https://matrix.berjon.com
A DID moors to at most one MXID; an MXID moors to at most one DID. Neither pointer is meaningful alone: only the pair, verified per [§4], constitutes an identity link.
3.2 AT → Matrix: the DID document #
3.2.1 The alsoKnownAs entry — [CONVENTION] #
The DID document MUST contain the moored MXID as a
Matrix URI in alsoKnownAs:
matrix:u/{localpart}:{server.name}
- The
utype designates a user; the id omits the@sigil (per MSC2312). - Localpart characters outside RFC 3986
pchar(notably/) MUST be percent-encoded. - The entry MUST NOT be first in
alsoKnownAs: index 0 is reserved for theat://handle (and the PDS enforces this on submission). Producers SHOULD append it after all existing entries. - Producers SHOULD include exactly one
matrix:entry. Consumers MUST use the first syntactically validmatrix:u/entry that verifies per [§4], ignoring others — mirroring atproto's own handle semantics. - PLC constraints (enforced by plc.directory): at most 10
alsoKnownAsentries, each ≤ 258 bytes. An MXID too long to fit cannot be moored.
3.2.2 The #matrix service entry — [CONVENTION], optional #
The DID document MAY additionally carry a discovery hint following the established atproto service
conventions (atproto_pds/AtprotoPersonalDataServer, bsky_fg/BskyFeedGenerator, …):
{
"id": "#matrix",
"type": "MatrixHomeserver",
"serviceEndpoint": "https://matrix.berjon.com"
}
serviceEndpoint is the homeserver's C–S API base URL (scheme + host + optional port). It is a
hint only: normative homeserver discovery is Matrix's own .well-known mechanism keyed on the
MXID's server name ([§5.3] step 4). The hint lets clients skip or survive a broken .well-known.
PLC constraints: service id ≤ 32 chars, type ≤ 256, endpoint ≤ 512 bytes, ≤ 10 service entries.
Resolved example document:
{
"id": "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
"alsoKnownAs": [
"at://robin.berjon.com",
"matrix:u/robin:berjon.com"
],
"verificationMethod": [
{
"id": "did:plc:ewvi7nxzyoun6zhxrhs64oiz#atproto",
"type": "Multikey",
"controller": "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
"publicKeyMultibase": "zQ3sh…"
}
],
"service": [
{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://pds.example.net"
},
{
"id": "#matrix",
"type": "MatrixHomeserver",
"serviceEndpoint": "https://matrix.berjon.com"
}
]
}
3.2.3 Writing it: PLC operations — [EXISTS] #
Two paths produce the required plc_operation:
(a) Self-managed rotation keys. Users holding a rotation key construct the operation directly:
take the current state (GET https://plc.directory/{did}/data), append the matrix: entry to
alsoKnownAs (and optionally the matrix service), set prev to the CID of the last operation
(GET /{did}/log/last), sign the DAG-CBOR encoding (ES256K/ES256, low-S, base64url), and
POST https://plc.directory/{did}.
(b) PDS-mediated (the common case). Users on a hosted PDS hold no rotation keys; the PDS signs on their behalf via four existing XRPC endpoints:
com.atproto.identity.requestPlcOperationSignature— PDS emails the user a one-time code.com.atproto.identity.signPlcOperation— input{token, alsoKnownAs, services}; each supplied field replaces the previous value wholesale (omitted fields carry over), so the client MUST submit the complete intended arrays, existing entries included. Returns the signed operation.com.atproto.identity.submitPlcOperation— the PDS validates its invariants (alsoKnownAs[0]=at://{current handle},#atproto_pdsintact, signing key intact) and forwards to the PLC directory.- (
com.atproto.identity.getRecommendedDidCredentialsexists for migration scenarios.)
Example signPlcOperation input adding a mooring:
{
"token": "12345-abcde",
"alsoKnownAs": [
"at://robin.berjon.com",
"matrix:u/robin:berjon.com"
],
"services": {
"atproto_pds": {
"type": "AtprotoPersonalDataServer",
"endpoint": "https://pds.example.net"
},
"matrix": {
"type": "MatrixHomeserver",
"endpoint": "https://matrix.berjon.com"
}
}
}
Authorization. Under atproto OAuth, requestPlcOperationSignature and signPlcOperation
require the granular identity:* scope (full DID-document control). Note carefully:
transition:genericdoes not include identity permissions;identity:*cannot be bundled into permission sets — clients must request it directly;- the email-token step applies even with
identity:*, which is a valuable second factor for DID-document changes.
This shapes the linking ceremony in [§5.5]: it is a distinct, explicitly consented, user-present step — not something a login flow does silently.
Rate limits: PLC enforces per-DID operation limits (10/hour, 30/day, 100/week) and a ~4000-byte operation size limit (the spec text says 7500; the deployed server enforces 4000 — assume the tighter bound). Moorings change rarely; this is ample.
3.2.4 did:web accounts — [EXISTS] #
did:web documents use identical conventions ([§3.2.1]–[§3.2.2] apply verbatim to the rendered
document). There are no PLC operations: the controller edits
https://{host}/.well-known/did.json directly. Everything else in this spec is method-agnostic —
consumers operate on the resolved DID document only.
3.3 Matrix → AT: the profile field #
3.3.1 The DID profile field — [CONVENTION] #
The DID is stored as an MSC4133 extended profile field on the Matrix account:
- Field name (unstable):
com.berjon.matey.did(reverse-DNS per the Common Namespaced Identifier Grammar; a future MSC should standardize this asm.did, following them.tz/MSC4175 precedent — see [§7]). - Value: the DID as a plain JSON string, e.g.
"did:plc:ewvi7nxzyoun6zhxrhs64oiz". Exactly one DID; no arrays.
PUT /_matrix/client/v3/profile/@robin:berjon.com/com.berjon.matey.did
{ "com.berjon.matey.did": "did:plc:ewvi7nxzyoun6zhxrhs64oiz" }
The field is public: readable via unauthenticated
GET /_matrix/client/v3/profile/{userId}/com.berjon.matey.did (servers MAY 403 per MSC4170 —
see [§4.3] for verifier fallbacks) and over federation via
GET /_matrix/federation/v1/query/profile?user_id=…&field=com.berjon.matey.did.
Deployments implementing the signed-mooring extension additionally publish a companion
com.berjon.matey.proof field carrying a JWS attestation from the DID's own keys ([§4.5]).
Who writes it. On a Matey homeserver, the field SHOULD be server-managed: the homeserver
advertises it in the m.profile_fields capability's disallowed list (so users cannot set it)
and writes it itself only when an atproto login/link is established through its own auth stack
([§5]). This makes the field an assertion by the homeserver ("this account authenticated as this
DID") rather than a user claim. On non-Matey homeservers, users MAY set the field themselves;
verification ([§4]) makes self-set fields safe for relying parties either way, but server-managed
is the stronger configuration.
3.3.2 Server-side link state — [EXISTS] #
Where MAS is in use, the authoritative login mapping is MAS's upstream OAuth link
(provider = the AT auth adapter, subject = the DID). This is what makes "log in with AT" resolve
to the right local account. It is created by first-login provisioning or explicit linking ([§5.4]),
and is manageable via the MAS admin API (upstream_oauth_links add/delete/list). One DID MUST
link to at most one local account per homeserver.
3.3.3 Private client state — [CONVENTION] #
Clients SHOULD keep private mooring state in global account data under the event type
com.berjon.matey.state:
{
"did": "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
"handle_at_link": "robin.berjon.com",
"linked_at": "2026-08-04T12:00:00Z",
"sync": { "name": true, "avatar": true, "bio": true }
}
Account data is private to the user and syncs across their clients; it never substitutes for the
public field in verification. The sync object carries the user's profile-sync preferences ([§6]).
4. Verification #
Verification establishes a mooring from either starting point. It requires no credentials in the common case and MUST be performed by relying parties before treating the identities as equivalent.
4.1 From a DID #
- Resolve the DID document (
GET https://plc.directory/{did}, ordid:webwell-known). A 404 or 410 (tombstoned) → no mooring. - Select the first syntactically valid
matrix:u/entry inalsoKnownAs→ candidate MXID@lp:server.name(percent-decode the localpart). None → no mooring. - Resolve the candidate's homeserver:
https://server.name/.well-known/matrix/client→base_url; on failure, fall back to the#matrixservice hint, then tohttps://server.name. - Read the profile field:
GET {base_url}/_matrix/client/v3/profile/{mxid}/com.berjon.matey.did(fallbacks: [§4.3]). - The mooring verifies iff the returned value is byte-for-byte equal to the DID being
checked. Any mismatch, absence, or error → no mooring (do not fall through to other
alsoKnownAsentries unless they are also syntactically validmatrix:URIs, in which case repeat from step 3 with the next one).
4.2 From an MXID #
- Read
com.berjon.matey.didfrom the user's profile (own homeserver via C–S; remote via [§4.3]). Absent → no mooring. - Resolve that DID's document. 404/410 → no mooring.
- The mooring verifies iff the document's selected
matrix:u/entry ([§4.1] step 2) percent-decodes to exactly this MXID (case-sensitive; localparts are lowercase by grammar).
4.3 Verifier access paths #
In descending order of preference for a verifier without special access:
- Unauthenticated C–S read — works on default-configured servers (spec: GET profile carries no auth requirement; servers MAY 403).
- Federation query —
GET /_matrix/federation/v1/query/profilewith signed (X-Matrix) requests; requires operating a (possibly minimal) homeserver identity. Servers MUST answer at least for users visible to the requester. - Any authenticated Matrix account on any federated server, reading via its own homeserver.
A homeserver that 403s all three paths has opted its users out of third-party verification; moorings for its users are then only checkable by parties it chooses.
4.4 Freshness, caching, revocation #
- Verifiers SHOULD cache positive results for at most 24 hours and MUST re-verify before security-relevant decisions (e.g. granting access based on the mooring). Login flows re-verify implicitly ([§5.3] step 10).
- Either side revokes unilaterally: removing the
alsoKnownAsentry (a PLC operation) or deleting the profile field (DELETE /_matrix/client/v3/profile/{userId}/com.berjon.matey.did) breaks the mooring at the next verification. Matey homeservers SHOULD also delete the MAS upstream link on explicit unlink requests. - A tombstoned DID (410) or deactivated Matrix account permanently breaks the mooring.
- PLC
#identityevents on the firehose signal DID-document changes and can drive proactive re-verification ([§6]).
4.5 Optional extension: signed moorings #
Baseline verification ([§4.1]–[§4.2]) proves that both systems currently assert the link — but the Matrix half of that assertion is only as trustworthy as the homeserver serving the profile field. A signed mooring removes that dependency: the profile field is accompanied by a proof signed with a key from the DID document, making the binding an attestation by the DID controller that the homeserver can withhold or delete, but neither forge nor alter. Implementations MAY support this extension; verifiers that do not understand it simply fall back to baseline verification, which remains valid.
4.5.1 The proof field — [CONVENTION] #
A companion profile field com.berjon.matey.proof holds a compact JWS with JWT claims:
-
Protected header:
{"typ": "matey-proof+jwt", "alg": "…", "kid": "…"}.typis REQUIRED (domain separation — this token must not be confusable with any other JWT or with repo commit signatures).kidMUST be a fully-qualified verification method id from the DID document (e.g.did:plc:ewvi7nxzyoun6zhxrhs64oiz#atproto).algfollows the referenced key type:ES256K(k256),ES256(p256), orEdDSA(ed25519). -
Payload:
{ "iss": "did:plc:ewvi7nxzyoun6zhxrhs64oiz", "sub": "@robin:berjon.com", "iat": 1754300000 }issis the DID;subis the full canonical MXID (sigil included);iatis the issue time.expMAY be included but proofs do not expire by default — revocation works through key rotation and unlinking ([§4.5.2]).
com.berjon.matey.did keeps its plain-string form ([§3.3.1]) so basic verifiers are unaffected.
A compact JWS of this shape is ~300–400 bytes — negligible against the 64 KiB profile budget.
4.5.2 Verification #
Signed verification augments baseline verification; it never replaces it (the alsoKnownAs
pointer still provides discovery and AT-side revocation):
- Run [§4.1]/[§4.2] in full. Both pointers MUST verify.
- Fetch
com.berjon.matey.proofand parse it as a compact JWS; requiretyp=matey-proof+jwt. - Resolve the current DID document; require
kidto name one of its verification methods; decode that Multikey per its multicodec and verify the signature under the statedalg. - Require
issequal to the DID (and tocom.berjon.matey.did),subequal to the MXID under examination, andiatnot in the future beyond reasonable clock skew. Verifiers MAY impose a maximum proof age as local policy.
A mooring passing all four steps is signed: the DID controller demonstrably endorsed this
specific MXID, and the homeserver could not have fabricated the binding. Because step 3 checks
against the current DID document, rotating or removing the referenced key invalidates every
previously issued proof — key rotation is the revocation mechanism, alongside ordinary unlinking
([§5.6]; a deleted alsoKnownAs entry already fails step 1, so a replayed old proof cannot
resurrect a revoked mooring).
4.5.3 Producing the proof #
Who can sign depends on who holds a suitable private key:
- The
#atprotosigning key — the strongest binding: the same key that signs the user's repo commits vouches for the MXID. Self-custodied accounts (users operating their own PDS or holding their own signing key) can produce this directly. On hosted PDSs the signing key is custodial, and no "sign this payload" endpoint exists today — one would need to be designed carefully ([NEEDS DESIGN]): it MUST sign only well-formedmatey-proof+jwtpayloads whoseissis the authenticated account's own DID — never caller-supplied bytes, since an unconstrained oracle over the repo signing key would allow forging commit signatures — and SHOULD be gated like other identity-affecting operations (identity:*). - A dedicated
#mateyverification method — deployable today: during the linking ceremony ([§5.5]) the client generates a keypair (ed25519 RECOMMENDED), adds it asverificationMethods["matey"]in the same PLC operation that writes thealsoKnownAsentry (PLC accepts any syntactically validdid:keyfor verification methods since v0.2; ≤ 10 per document), and signs the proof itself with the client-held key. Purpose-scoped, no PDS cooperation needed beyond the ceremony that is already running. RECOMMENDED default whenever the#atprotokey is custodial.
Both variants verify identically under [§4.5.2]: the proof's authority derives from the key's
presence in the DID document, not from which slot it occupies. did:web accounts add the
verification method by editing their document directly, as usual ([§3.2.4]).
5. Logging into Matrix with an AT identity #
5.1 Actors #
- User with atproto identity (handle → DID → PDS → authorization server).
- Matey client — a Matrix client that also acts as an atproto OAuth client (public or confidential) for identity resolution and the linking ceremony.
- Homeserver auth stack — MAS (or equivalent) plus the AT auth adapter ([§5.2]).
- PLC directory / PDS / atproto authorization server — standard atproto infrastructure.
5.2 The AT auth adapter — [NEEDS DESIGN] #
Matrix's auth stack must be able to authenticate a user via atproto OAuth. This is the central piece of new machinery, because of a verified impedance mismatch:
- atproto OAuth mandates PAR and DPoP and requires per-user discovery of the authorization server (every user may be on a different PDS/authserver);
- MAS upstream providers are statically configured (YAML, synced at startup; admin API is read-only for providers), support generic OAuth2/OIDC, but implement neither PAR nor DPoP as an upstream client (the PAR PR was closed unmerged in July 2026), and cannot select an issuer dynamically per login.
The adapter contract is auth-stack-agnostic: any homeserver auth stack that can delegate authentication to an external identity provider can host it. Appendix B gives concrete profiles for Synapse + MAS and for Tuwunel's native OIDC server (which delegates to upstream providers directly, with no MAS involved). Two implementation strategies satisfy the same normative behavior:
Strategy A — sidecar broker (deployable now, RECOMMENDED initially). A small service that presents a standard OIDC provider interface to MAS and speaks atproto OAuth outward. Prior art proves the shape: ATLogin (hosted OIDC IdP for atproto) and graze-social/aip (OAuth/OIDC proxy with native atproto resolution, PAR, DPoP; MIT-licensed). The broker:
- exposes OIDC discovery, authorization, token, and JWKS endpoints to MAS (configured as a
normal
upstream_oauth2.providersentry withdiscovery_mode: oidc,pkce_method: always); - accepts a
login_hint(handle or DID) forwarded from MAS; - performs full atproto resolution and OAuth: handle→DID (DNS TXT/
.well-known, bidirectional handle check), DID→PDS→authserver metadata, then PAR + PKCE + DPoP (with nonces) as a confidential client (private_key_jwt, hosted client metadata document,dpop_bound_access_tokens: true), requesting only theatprotoscope (identity-only — login needs no data access); - on callback, checks
stateandiss, exchanges the code, and — mandatory — re-verifies issuer authority: re-resolve the token'ssubDID → PDS → authserver, and confirm it matches the issuer that authenticated the user (this is atproto's core anti-spoofing rule); - issues MAS an
id_tokenwithsub= the DID, plus best-effort claimspreferred_username= current handle,name= profile displayName,picture= avatar URL (fetched via unauthenticatedcom.atproto.repo.getRecord; enables provisioning and login-time profile sync); - MAY discard its atproto tokens immediately after (5); it needs no ongoing session.
The broker MUST be operated by (or be a trusted delegate of) the homeserver operator: whoever runs it can assert arbitrary DIDs to MAS. One broker per homeserver is RECOMMENDED; a shared multi-tenant broker concentrates that trust and SHOULD be avoided for public deployments.
Sample MAS configuration:
upstream_oauth2:
providers:
- id: 01JMATEY0000000000000000AT
human_name: "AT Protocol"
issuer: "https://matey-auth.berjon.com"
discovery_mode: oidc
client_id: "mas"
token_endpoint_auth_method: client_secret_basic
client_secret: "…"
pkce_method: always
scope: "openid"
additional_authorization_parameters:
login_hint: "{{ params.login_hint }}" # forward the client's hint to the broker
claims_imports:
subject:
template: "{{ user.sub }}" # the DID
localpart:
action: force
template: "{{ user.sub | replace(':', '.') }}" # see §5.4.1
on_conflict: fail # never auto-link by localpart match
displayname:
action: suggest
template: "{{ user.name }}"
Strategy B — native MAS upstream type (preferred end state). Implement an atproto provider
type in MAS itself: an atproto OAuth client with PAR/DPoP support, per-login issuer discovery
from login_hint, and sub-authority verification built in. Functionally identical to A with
one fewer moving part and no OIDC translation layer. This is upstream work in MAS
(tracked interest exists: MAS PR #4847 [PAR, closed], issue #24 [DPoP]).
In both strategies the subject recorded in the MAS upstream link is the DID — stable across handle changes, PDS migrations, and authserver moves.
5.3 Flow A — the DID document names the homeserver #
Precondition: the user's DID document contains a mooring pointer ([§3.2]). All steps use existing specified machinery except where marked.
- Input. User enters a handle or DID in the Matey client.
- atproto resolution — [EXISTS]. Handle → DID via DNS TXT
_atproto.{handle}and/orhttps://{handle}/.well-known/atproto-did(DNS preferred on conflict); DID → DID document; confirm the handle bidirectionally (at://inalsoKnownAs). - Mooring pointer — [CONVENTION]. Extract the candidate MXID per [§4.1] step 2 (and the
optional
#matrixhint). If none → Flow B ([§5.4]). - Matrix discovery — [EXISTS].
https://{server.name}/.well-known/matrix/client→base_url(fallbacks per [§4.1] step 3). - Auth capability — [EXISTS].
GET {base_url}/_matrix/client/v1/auth_metadata. A 404 withM_UNRECOGNIZEDmeans the server cannot do OAuth: inform the user the mooring points at a server without next-gen auth and stop (or offer Flow B against a different server). - Client registration — [EXISTS]. RFC 7591 dynamic registration at
registration_endpoint. - Authorization request — [EXISTS + one convention]. Standard authorization-code + PKCE
(S256) request with scopes
urn:matrix:client:api:*andurn:matrix:client:device:{device_id}, pluslogin_hintset to the user's DID. MAS SHOULD use the hint to preselect the AT upstream provider and MUST forward it to the adapter ([NEEDS DESIGN]: today this forwarding uses theadditional_authorization_parameterstemplate above; provider preselection from a hint is a small MAS feature request). - atproto authentication — [EXISTS at the atproto end; adapter per §5.2]. The adapter runs
the atproto OAuth flow against the user's own authorization server (
login_hintprefills the account; the authserver SHOULD restrict authentication to that account). The user consents to theatprotoscope. - Mapping — [EXISTS]. The adapter asserts
sub= DID to MAS; MAS finds the upstream link for that DID and logs the user into the linked local account, issuing the authorization code; the client exchanges it for Matrix access + refresh tokens. - Post-login check — [CONVENTION]. The client calls
GET /_matrix/client/v3/account/whoamiand verifies the MXID equals the one from step 3, and that the mooring verifies per [§4]. On mismatch (e.g. the DID document points at an MXID this homeserver has no link for), the client MUST NOT treat the session as moored and SHOULD offer the repair path: re-run the linking ceremony ([§5.5]) or re-link ([§5.4.2] steps 4+).
If MAS has no upstream link for the DID at step 9, it will fall into first-login provisioning — which is exactly right when the pointer was written before the account existed (crash recovery, migration), and produces the mismatch-repair path of step 10 otherwise.
5.4 Flow B — no Matrix pointer in the DID document #
The client MUST offer the user two options, then converge on the same end state (moored account + AT OAuth login + future logins via Flow A):
5.4.1 Create an account on a Matey homeserver #
- Server choice. The client offers one or more Matey homeservers (a default, a curated directory, or manual entry — deployment policy, out of scope for this spec).
- Registration-flavored login — [EXISTS]. Steps 4–8 of Flow A against the chosen server,
with
prompt=createin the authorization request. - Provisioning — [EXISTS, configured per this spec]. On first upstream login MAS creates the
local account from the adapter's claims:
- Localpart: RECOMMENDED default is DID-derived —
{{ user.sub | replace(':', '.') }}, e.g.did.plc.ewvi7nxzyoun6zhxrhs64oiz(fits the localpart grammar; stable across handle changes; collision-free). Servers MAY offer handle-derived vanity localparts (e.g.robin.berjon.com) but MUST then weigh the risks: MXIDs are immutable while handles are not, so the localpart can go stale, and a handle later released and re-registered by someone else leaves a misleading MXID behind. Human-readability belongs in the display name. - Display name: suggested from the profile claims.
- The account is AT-OAuth native from birth: no password exists (MAS
passwords.enabled: false, or simply never set).
- Localpart: RECOMMENDED default is DID-derived —
- Server-side mooring — [CONVENTION]. The homeserver writes
com.berjon.matey.did([§3.3.1], server-managed) and records the upstream link (automatic in MAS). - Linking ceremony — [§5.5]. The client walks the user through writing the DID-document pointer. Until this completes, the account works but is not discoverable from the AT side; clients SHOULD surface "mooring incomplete" state and re-prompt.
- Initial profile sync per [§6] (the adapter's claims already seeded name; avatar/bio follow the chosen sync option).
5.4.2 Bring your own homeserver / existing account #
- Input. User enters their MXID or homeserver name.
- Capability check — [EXISTS]. Flow A steps 4–6. If the server lacks next-gen auth, it cannot participate; the client MUST say so plainly (and MAY offer [§5.4.1] instead).
- Existing-credential login — [EXISTS]. The client runs the standard OAuth authorization flow; the user authenticates however that server currently supports (password, existing SSO, …). The client now holds a Matrix session for the existing account.
- Link the AT upstream — [EXISTS mechanics, NEEDS DESIGN for the deep link]. The client
sends the user to the server's account management UI (
account_management_urifrom the auth metadata, in-spec since Matrix v1.18) to link the AT provider: the user completes an atproto OAuth flow via the adapter, and MAS records the upstream link (provider = AT adapter, subject = DID) on the logged-in account. MAS supports upstream linking from the account UI today; what's missing is a standardized account-management action to deep-link straight to "link this provider" — this spec reservescom.berjon.matey.action.linkas the unstable action name pending an MSC. If the DID is already linked to a different local account, the link MUST fail. (Programmatic alternative for managed deployments: the MAS admin APIupstream_oauth_linksadd — only after the server has itself verified the user's control of the DID.) - Server-side mooring — [CONVENTION]. As [§5.4.1] step 4. On servers where the profile field is not server-managed, the user's client MAY write it instead.
- Linking ceremony — [§5.5].
- From now on the account can always be logged into via AT OAuth (Flow A). The user MAY keep
other login methods; clients SHOULD note that per-user password disablement is not currently
available in MAS (global
passwords.enabledonly — a gap worth an upstream request), so "AT-only" hardening of a retrofitted account is presently an operator-level choice.
5.5 The linking ceremony (writing the AT-side pointer) #
Client-driven, shared by both flows; uses only existing atproto machinery ([§3.2.3]).
- The Matey client obtains an atproto OAuth session as itself (its own client metadata
document) with scopes
atproto identity:*. This is deliberately separate from the login in [§5.3] step 8 — the homeserver's adapter never holds identity-mutation power, andidentity:*cannot be smuggled in via permission sets. The user may decline; the mooring then stays half-complete (Matrix→AT only) and clients MUST NOT claim it verifies. - Fetch current state:
GET https://plc.directory/{did}/data. - Compute the new arrays:
alsoKnownAs= current entries, minus any stalematrix:entries, plusmatrix:u/{localpart}:{server.name}appended (index 0 staysat://{handle});services= current map, optionally plus/replacingmatrix([§3.2.2]). com.atproto.identity.requestPlcOperationSignature→ the PDS emails the user a code.- User enters the code; client calls
signPlcOperationwith{token, alsoKnownAs, services}. - Client calls
submitPlcOperationwith the signed operation. - Poll
GET https://plc.directory/{did}until the entry appears (typically immediate), then run full verification ([§4]) and recordcom.berjon.matey.state([§3.3.3]).
Clients implementing signed moorings ([§4.5]) SHOULD fold the extension into this same ceremony:
generate the #matey keypair before step 3, include it under verificationMethods in the same
operation (steps 3–5), and write com.berjon.matey.proof once step 7 verifies — one PLC
operation, one email code, no extra user friction.
For did:web, replace steps 2–7 with instructions to edit /.well-known/did.json (the client
SHOULD display the exact entries to add, then poll and verify identically).
Consent language. Before step 4, clients MUST tell the user that the link will be public and permanent in the PLC audit log even if later removed ([§8]).
5.6 Unlinking #
Reverse the ceremony (submit a PLC operation without the matrix: entries), delete the profile
field, delete the MAS upstream link (account management UI or admin API), and clear
com.berjon.matey.state. Any one of the first three alone already breaks verification ([§4.4]);
a clean unlink does all of them. Note that if AT OAuth was the account's only login method,
unlinking orphans the Matrix account — clients MUST warn and homeservers SHOULD require an
alternative auth method to exist first.
6. Profile synchronization #
Goal: when the user's app.bsky.actor.profile record changes (displayName, description, avatar,
pronouns…), the moored Matrix account follows. Three approaches, in decreasing order of freshness
and increasing order of simplicity; they compose.
6.1 Field mapping — [CONVENTION] #
app.bsky.actor.profile |
Matrix | Mechanism |
|---|---|---|
displayName (≤ 64 graphemes) |
displayname |
standard profile API; fans out to m.room.member in every joined room |
avatar (blob, ≤ 1 MB, png/jpeg) |
avatar_url |
fetch via com.atproto.sync.getBlob?did=…&cid=… from the PDS → POST /_matrix/media/v3/upload → mxc:// URI; fans out like displayname |
description (≤ 256 graphemes) |
com.berjon.matey.bio profile field |
MSC4133 custom field (no room fan-out). Migrate to m.biography if/when MSC4440 merges |
pronouns |
com.berjon.matey.pronouns |
MSC4133 custom field |
website |
com.berjon.matey.website |
MSC4133 custom field |
Sync is one-way (AT → Matrix) for synced fields, per-field opt-in/out via the sync object in
com.berjon.matey.state ([§3.3.3]). When sync is enabled for a field, the atproto side is the
source of truth and Matrix-side edits will be overwritten; clients SHOULD say so in settings UI.
Record deletion (profile record removed) SHOULD clear the synced fields.
6.2 Option A — homeserver sync agent (RECOMMENDED for Matey homeservers) — [NEEDS DESIGN (component), EXISTS (all mechanisms)] #
A service operated alongside the homeserver:
- Ingest: subscribe to Jetstream
(
wss://jetstream1.us-east.bsky.network/subscribe?wantedCollections=app.bsky.actor.profile&wantedDids=…). Commits arrive as JSON with the full new record (collection: app.bsky.actor.profile,rkey: self);wantedDidstakes up to 10,000 DIDs and is mutable live viaoptions_update; reconnect withcursor= lasttime_usminus a few seconds (idempotent overlap). All subscribers also receiveidentityevents (handle changes → refresh cached handle) andaccountevents (takedown/deactivation → operator policy MAY lock the Matrix account). Deployments beyond 10k moored users, or wanting signed/verified events and automatic backfill, SHOULD run Tap (self-hosted, per-repo tracking with dynamicrepos/add, at-least-once webhooks/WebSocket, full MST/signature verification) instead. Jetstream is unverified JSON — you trust the operator; running your own Jetstream or Tap removes that trust dependency. - Apply: write to the user's profile as the user, via an application service registered
with a broad non-exclusive user namespace (
@.*:example\.org,rate_limited: false,url: null) usinguser_id-masquerade — the portable, spec-blessed write path (identity assertion covers all C–S endpoints except account management; this is exactly the mautrix double-puppeting pattern, and appserviceas_tokenauth is unaffected by MAS). Synapse-only deployments MAY usePUT /_synapse/admin/v2/users/{user_id}instead, but the appservice path is the one that works across implementations. - Behave: debounce per-user (e.g. coalesce changes over 30–60 s) — each displayname/avatar
change emits an
m.room.memberstate event into every room the user occupies and federates everywhere (MSC4218/MSC4466 are working on this cost; until then, coalescing is the mitigation). Verify the mooring ([§4]) before every write; stop syncing on verification failure.
6.3 Option B — login-time sync (baseline, comes almost free) — [EXISTS, one caveat] #
The AT auth adapter already fetches profile claims at each login ([§5.2] step 5). MAS
claims_imports.displayname with action: force re-applies the upstream display name; this
mirrors Synapse's long-standing sso.update_profile_information behavior. Freshness is bounded
by login frequency; avatar/bio require the adapter to expose them and MAS to import them (avatar
import via claims is not currently a MAS feature — small upstream request; the adapter or client
can compensate). Every Matey homeserver gets Option B by construction; it is the floor, not the
ceiling.
6.4 Option C — client-side sync (no server cooperation) — [EXISTS] #
A Matey client holding both sessions (it logged the user in via AT OAuth and can read the profile
record) updates the user's own Matrix profile whenever it observes a profile-record change
(poll com.atproto.sync.getLatestCommit cheaply, or com.atproto.repo.getRecord and compare
cid, on app start / periodically). This is the polyfill for bring-your-own homeservers without
a sync agent. Same field mapping, same opt-in rules; requires no privileges beyond the user's own.
Recommendation. Deploy A on Matey homeservers (freshest, covers all clients), rely on B as the guaranteed baseline everywhere, and let Matey clients implement C to cover retrofitted accounts on servers that run neither. All three respect the same per-field opt-outs, so they can safely coexist (A and C converge on identical writes; debouncing absorbs the overlap).
7. What exists and what needs to be built #
| # | Piece | Status | Where |
|---|---|---|---|
| 1 | Extra alsoKnownAs/service entries in PLC documents |
EXISTS (explicitly permitted, size-limited) | [§3.2] |
| 2 | matrix:u/… alsoKnownAs + MatrixHomeserver service conventions |
CONVENTION (this spec; no registry exists to bless service types — publish and use) | [§3.2] |
| 3 | PDS-mediated PLC updates (com.atproto.identity.*, email token, identity:* scope) |
EXISTS | [§3.2.3] |
| 4 | Matrix extended profile fields (MSC4133, v1.16) | EXISTS | [§3.3.1] |
| 5 | com.berjon.matey.did field (→ future m.did MSC) |
CONVENTION + future MSC | [§3.3.1] |
| 6 | Bidirectional verification algorithm | CONVENTION (this spec) | [§4] |
| 7 | Signed moorings (com.berjon.matey.proof JWS) |
CONVENTION, optional (deployable now via the dedicated #matey key; a PDS signing endpoint for the custodial #atproto key NEEDS DESIGN) |
[§4.5] |
| 8 | Matrix OAuth login, dynamic registration, prompt=create (v1.15) |
EXISTS | [§5.3] |
| 9 | MAS upstream providers, claims mapping, account linking, admin API | EXISTS | [§5.2], [§5.4] |
| 10 | AT auth adapter (atproto OAuth ⇄ MAS): PAR+DPoP client, per-user issuer discovery, sub-authority verification |
NEEDS DESIGN/BUILD (Strategy A buildable now on AIP/ATLogin prior art; Strategy B = upstream MAS work) | [§5.2] |
| 11 | login_hint forwarding + upstream preselection |
PARTIAL — MAS: template forwarding exists, preselection is a small feature; Tuwunel: no upstream forwarding at all (the broker compensates with a handle form) | [§5.3] step 7 |
| 12 | Account-management action to deep-link "link AT identity" | NEEDS DESIGN (unstable com.berjon.matey.action.link; MSC later) |
[§5.4.2] |
| 13 | Linking-ceremony UX in clients | NEEDS BUILD (all endpoints exist) | [§5.5] |
| 14 | Per-user password disablement in MAS | GAP (upstream request) | [§5.4.2] |
| 15 | Sync agent (Jetstream/Tap consumer + appservice masquerade) | NEEDS BUILD (all mechanisms exist) | [§6.2] |
| 16 | Avatar import through MAS claims at login | GAP (small upstream request) | [§6.3] |
| 17 | Matey homeserver directory for account creation | OUT OF SCOPE (deployment policy) | [§5.4.1] |
| 18 | Homeserver-side (server-managed) write of the DID profile field at provisioning | GAP — needs a provisioning hook in Tuwunel / avatar+field import in MAS; the reference stack writes it client-side meanwhile | [§3.3.1], [App. B] |
8. Security & privacy considerations #
- Neither pointer is trustworthy alone. PLC does not validate service/alsoKnownAs claims; Matrix profile fields are (by default) user-writable. Only the verified pair ([§4]) means anything. Relying parties MUST verify both directions and MUST NOT accept one-sided claims.
- Issuer-authority verification is load-bearing. The AT auth adapter MUST re-resolve
sub→ PDS → authorization server and match it against the token issuer; without this, any malicious authserver can mint logins for arbitrary DIDs. This is atproto's rule; Matey inherits it at the exact point where atproto identity enters Matrix. - No auto-linking by localpart. MAS's
on_conflictlocalpart matching MUST befailfor the AT provider; linking an existing account happens only through an authenticated linking flow ([§5.4.2]) — otherwise registeringdid.plc.x-shaped or handle-shaped localparts becomes an account-takeover vector. - Permanence of PLC history.
alsoKnownAsentries are forever visible in the PLC audit log, even after removal or tombstoning. Linking is a public, historically permanent act; consent screens MUST say so ([§5.5]). - Handle mutability vs MXID immutability. Handles change; MXIDs don't. The mooring binds the
DID, so it survives handle changes — but handle-derived localparts and any cached
handle strings go stale, and released handles can be re-registered by others. Prefer DID-derived
localparts; treat handles as display data; refresh them from
identityevents or at verification time. - Homeserver trust. The homeserver fully controls its accounts: it can use a moored Matrix account within Matrix regardless of the AT side. The mooring proves account correspondence to third parties; it does not reduce the trust a user places in their homeserver. The signed-mooring extension ([§4.5]) narrows this materially: with a proof signed by a DID-document key, the homeserver can withhold or delete the binding but can no longer forge or alter it — the residual homeserver powers are omission and staleness, both of which baseline verification bounds (a revoked AT-side pointer fails [§4.5.2] step 1 regardless of any replayed proof).
- Email token as second factor. PLC mutation via a PDS requires an emailed code on top of
identity:*; Matey deliberately keeps identity mutation in the client-driven ceremony rather than the login path, so the homeserver's adapter never needs — and never holds — that power. - SSRF. Every resolution step (handle DNS/well-known, DID docs,
.well-known/matrix/*, authserver metadata) fetches attacker-influenceable URLs. Implementations MUST use hardened fetchers (timeouts, size caps, private-IP blocking) per the atproto OAuth spec's requirements. - Availability coupling. Flow A requires PLC directory, the user's PDS/authserver, and the homeserver to be up. Clients SHOULD cache the resolved homeserver per account so an already-logged-in session never depends on atproto infrastructure.
- Rate limits. PLC per-DID op limits (10/h, 30/d, 100/w) are ample for moorings. Profile fan-out is the expensive edge: debounce ([§6.2]). Bluesky-side reads sit under the global 3000 req/5 min/IP ceiling — batch and cache blob fetches.
9. References #
AT Protocol — DID-PLC spec · atproto DIDs · Handles · OAuth profile · Permissions/scopes · identity lexicons · app.bsky.actor.profile · Jetstream · Tap · PLC directory org announcement
Matrix —
Client–Server API (profiles, OAuth API, account data) ·
Appendices (MXID grammar, matrix: URI) ·
Application Service API ·
Federation profile query ·
MSC3861 and sub-MSCs 2964/2965/2966/2967 ·
MSC4133 extended profiles ·
MSC4175 (m.tz) ·
MSC4440 (m.biography, open) ·
MSC4191 account management ·
MAS documentation ·
Synapse admin API
Prior art — graze-social/aip · ATLogin · mautrix double puppeting · mautrix-bluesky · Keytrace · MSC1781 (DIDs in Matrix, stalled 2019)
Appendix A: Reference implementation #
This repository is a pnpm monorepo carrying a working (draft-quality) implementation of the spec:
| Path | What it is |
|---|---|
client/ |
@matey/client — TypeScript library implementing identity resolution, mooring discovery and bidirectional verification (§4) including signed-mooring JWS checks (§4.5), the Matrix next-gen-auth login flows (§5.3–§5.4: metadata discovery, dynamic registration, PKCE, token exchange, post-login checks), and the linking ceremony (§5.5) against any XRPC transport. No runtime dependency on matrix-js-sdk or the atproto SDKs; crypto via @noble/*. |
broker/ |
matey-broker — the AT auth adapter (§5.2, Strategy A): a minimal single-RP OIDC provider (code flow, PKCE, ES256 id_tokens) that authenticates users via atproto OAuth using the official @atproto/oauth-client-node (PAR + DPoP), performs the independent issuer-authority re-verification, shapes preferred_username for localpart derivation (DID- or handle-style), serves its atproto client metadata, and drops its atproto session immediately after asserting identity. Ships a Dockerfile. |
counter/ |
The shared-counter example app: log in with a handle (Flow A/B including account creation and BYO), create counter rooms, invite by handle via mooring verification (⚓ badges, signed moorings flagged), send +1/−1 ops, and run the linking ceremony (with a #matey signed mooring) from the browser. The counter itself is a PN-counter folded from the room's op log — deliberately the smallest instance of Appendix C's pattern. |
deploy/ |
Docker Compose for a complete Matey homeserver: Tuwunel (native next-gen auth, broker as its identity provider) + broker + Caddy TLS, with a walkthrough and the topology's known limitations. |
docs/adr/ |
Architecture decision records (TypeScript-first reference, hand-rolled minimal OIDC surface, rooms-as-op-logs). |
Status: compiles and passes its test suites (mooring/JWS/ceremony/PKCE logic, full OIDC code-flow round trip, counter fold semantics); the atproto leg and the full Tuwunel round trip need a deployed environment and are the next validation step. Per §5.2's plan, this TypeScript broker is the deployable Strategy A; a Rust embedding into a homeserver (Strategy B) remains the intended end state and would reuse the same protocol logic.
Appendix B: Deployment profiles #
B.1 Tuwunel (native OIDC) + broker — no MAS #
Tuwunel ships its own MSC3861 authorization server which "runs only to broker for a configured
identity_provider". The Matey broker is that provider (config verified against Tuwunel
v1.8.x, docs/authentication/providers.md):
[[global.identity_provider]]
brand = "matey"
name = "AT Protocol"
client_id = "tuwunel" # doubles as provider id; never change it
client_secret = "…"
issuer_url = "https://matey-auth.example.org"
callback_url = "https://matrix.example.org/_matrix/client/unstable/login/sso/callback/tuwunel"
forward_action_prompt = true # action=register → prompt=create at the broker
Properties of this profile:
- Localparts derive from the broker's
preferred_usernameclaim (Tuwunel's claim order tries it first), so localpart policy lives in the broker (MATEY_LOCALPART_STYLE). Do not settrusted = trueor claim-matchinguserid_claims(spec §8: auto-linking is takeover surface). - Tuwunel does not forward
login_hintupstream: users retype their handle at the broker (inventory item 11). MAS forwards hints; Tuwunel support is a desirable upstream contribution. - Existing-account linking is admin-mediated:
!admin query oauth associate <provider_id> @user:… --claim sub=<did>(in-memory approval, consumed at next login). Self-serve linking (§5.4.2 step 4) needs MAS today. - Bonus: the same provider serves legacy
m.login.sso, so pre-OAuth clients also get AT login. - See
deploy/for the complete Compose stack (Tuwunel + broker + Caddy).
B.2 Synapse + MAS + broker #
The profile the spec's §5.2 sample YAML targets: the broker is a MAS upstream_oauth2 provider
(discovery_mode: oidc, pkce_method: always), claims_imports maps sub/preferred_username,
and additional_authorization_parameters forwards login_hint so users never retype their
handle. Account linking is self-serve through MAS's account UI; on_conflict MUST stay fail.
B.3 Native embedding (phase 2) #
Fold the adapter into a homeserver: an atproto OAuth client (PAR/DPoP, per-login issuer
discovery, sub-authority verification) as a first-class authentication backend in MAS or in a
Conduit-family server (Tuwunel/Continuwuity are Rust; atproto-oauth/atproto-identity/
atrium-oauth crates cover the client machinery). Zero sidecars, one binary — pursued as an
upstream contribution once this spec stabilizes, not as a fork.
Appendix C: Shared-state applications over moorings #
(Informative — the design target that motivates Matey: inviting people to view, comment on, or edit documents whose shared state is CRDT-based.)
Matey deliberately keeps the identity layer separable from any one application, but the pieces compose into collaborative-document infrastructure as follows:
Document = room. A Matrix room is a replicated, access-controlled, federated event log. A shared document lives in one room: its membership is the document's ACL, its event timeline is the document's operation history. Everything Matrix already provides — invites, federation, E2EE, retention, moderation — applies to documents for free.
Ops as events. CRDT updates travel as room events under a namespaced type (this repo uses
com.berjon.matey.example.*; a document system would define e.g. ….doc.update carrying
Yjs/Automerge update payloads, with periodic state snapshots as state events or media uploads to
bound catch-up cost). Matrix guarantees eventual delivery of the same event set to every member's
server, and CRDT semantics make application order-independent — the two layers fit precisely
because neither needs a total order. The counter example is the degenerate case: a PN-counter
whose "update" is {delta: ±1}, folded with event-id dedup. Its properties (commutative,
idempotent, replay-safe) are exactly what a document CRDT needs from the transport, demonstrated
in ~40 lines.
Invite by AT identity. Sharing a document with alice.example.com means: resolve handle →
DID → mooring → verified MXID → invite (§4; implemented in the example's invite flow). The
inviter needs no knowledge of Alice's homeserver, and the invitation is anchored to her
portable identity, not to a server-bound account. If Alice has no Matrix account yet, the §5.4.1
flow provisions one transparently on first accept — AT-native onboarding into the document
system. Invitation-by-identity-not-server is the property that makes cross-organization document
sharing tractable.
Roles. View/comment/edit maps onto Matrix power levels per event type: viewers at PL 0 with
events["….doc.update"] > 0 (read-only via membership), commenters allowed on the comment event
type only, editors allowed on updates. Role changes are state events — auditable and federated.
Finer-grained or capability-style models can layer on later MSCs without changing the identity
substrate.
Why moorings matter here. Document collaboration is exactly the setting where "is this account really that person?" carries weight — you share drafts with identities, often across organizations. Verified (ideally signed, §4.5) moorings give every collaborator's avatar a cryptographically-checkable link to their public AT identity, and profile sync (§6) keeps the names and faces current.
Design constraints kept for this future (why some spec choices look the way they do): the verification API is cheap and cacheable per member (member-list badges need it); the client library is transport-agnostic and framework-free (embeddable in a document editor); event-type namespacing is reserved under one root; nothing in the login or ceremony flows assumes the Bluesky AppView — any atproto lexicon ecosystem (including a future document lexicon mirroring room state into the user's repo) can sit alongside.