The did:at DID Method #
Status: Draft Version: 0.3
1. Overview #
did:at is a DID method in which the
method-specific identifier is an AT-URI
pointing at a record — in an atproto repository, or in a space (per the
atproto Spaces proposal). Resolving the DID means fetching that record and
returning the DID document it contains.
The identity is therefore controlled by whoever can write the record: creating, rotating, and deactivating the DID are ordinary record writes, signed and replayable like any other record.
Because the authority of an AT-URI may itself be a DID, and did:at is a
DID, identifiers can nest:
did:at:did:at:did:plc:23423492..at.did.doc..1234..at.did.doc..6789
is valid: the inner record (at://did:plc:23423492/at.did.doc/1234) holds a
DID document, whose repo holds the outer record, and so on to any depth.
Resolvers MUST therefore bound resolution with a recursion parameter (§7).
2. Method Name #
The method name is at. A DID that uses this method MUST begin with
did:at:.
3. Method-Specific Identifier #
The method-specific identifier is an AT-URI with the at:// scheme prefix
removed and each / path separator replaced by .. (DID syntax forbids
/ in identifiers but permits .):
at://<authority>/<p1>/<p2>/…/<pn> ⇔ did:at:<authority>..<p1>..<p2>..…..<pn>
3.1 Design note: why .. and not : or escaping #
:separators collide with the colons inside the authority (every DID contains them), forcing arity heuristics and restricting authorities to single-segment DID methods. Variable-length paths (spaces, §3.3) make that unworkable.- Percent-escaping (
did%3Aplc%3A…) is unambiguous but unreadable, and nesteddid:atidentifiers double-encode at every level (%3A→%253A→%25253A). ..can never occur inside a handle, DID, or NSID (domain labels and NSID segments cannot be empty), so components keep their natural colons — any DID method fits in one component, includingdid:webwith ports or paths. The only cost is a small addressability restriction (§3.5).
3.2 Grammar #
Splitting the method-specific identifier on .. yields a component list; the
grammar below is over that list. It is recursive: a nested did:at consumes
its own path components.
msid = did-or-handle path
path = collection rkey ; repo record
/ "space" stype skey did-or-handle rtype rkey ; space record
did-or-handle = did-at-prefixed / did / handle ; one component, except that a
; did:at also consumes a path
collection = nsid ; e.g. "at.did.doc"
stype = nsid ; space type
rtype = nsid ; record type within the space
skey = record-key ; the space's key
rkey = record-key
A space path addresses at://sdid/space/stype/skey/did/rtype/rkey: the
record of type rtype with key rkey, authored by member did, in the space
stype/skey hosted by authority sdid. The literal component space is
what distinguishes the two path forms — it can never be a collection, since
space is not a valid NSID.
3.3 Parsing #
Parsing is left-to-right recursive descent over the component list. It is
deterministic: the arity of a path is decided by the space literal, and the
nesting depth of a DID is carried entirely by did:at: prefixes inside a
single component.
- Strip
did:at:and split the remainder on... No component may be empty or begin or end with.. - parseDid at cursor i: let c be component i.
- If c begins with
did:at:— strip that prefix and parseDid the remainder of c (its authority chain lives entirely within this component), then parse the nested DID's path (step 3) from the following components. The components of an embeddeddid:atflatten into the list innermost-first. - Otherwise c is a complete DID (any method) or a valid atproto handle; consume one component.
- If c begins with
- parsePath at cursor i:
- If component i is the literal
space: consumespace,stype(NSID),skey(record key), then parseDid (the member — which may itself be adid:at), thenrtype(NSID) andrkey. - Otherwise: consume
collection(NSID) andrkey.
- If component i is the literal
- The identifier is parseDid followed by parsePath, and MUST consume the component list exactly.
3.4 Examples #
| DID | AT-URI it denotes |
|---|---|
did:at:did:plc:23423492..at.did.doc..1234 |
at://did:plc:23423492/at.did.doc/1234 |
did:at:alice.example.com..at.did.doc..self |
at://alice.example.com/at.did.doc/self |
did:at:did:at:did:plc:23423492..at.did.doc..1234..at.did.doc..6789 |
at://did:at:did:plc:23423492..at.did.doc..1234/at.did.doc/6789 (nested authority) |
did:at:did:plc:23423492..space..com.example.room..3jabc..did:plc:99887..at.did.doc..self |
at://did:plc:23423492/space/com.example.room/3jabc/did:plc:99887/at.did.doc/self (space record) |
(A did:at written inside an AT-URI keeps its .. separators, as in the
third row — the authority position of an AT-URI cannot contain / either.)
3.5 Character restrictions #
DID syntax limits identifier characters to A–Z a–z 0–9 . - _ % plus :.
Consequently:
- Components keep literal colons, so rkeys containing
:anddid:webauthorities with ports or paths are all addressable. - A record key containing
~, containing.., or beginning or ending with.cannot be addressed by this method. (~is outside the DID character set; the dot forms would corrupt the component split.) Handles, DIDs, and NSIDs cannot violate these rules.
4. The DID Document Record #
The conventional collection is at.did.document. A record in it looks like:
{
"$type": "at.did.document",
"didDocument": {
"@context": ["https://www.w3.org/ns/did/v1"],
"id": "did:at:alice.example.com..at.did.document..self",
"alsoKnownAs": ["at://did:plc:44ybard66vv44zksje25o7dz"],
"verificationMethod": [
{
"id": "did:at:alice.example.com..at.did.document..self#key-1",
"type": "Multikey",
"controller": "did:at:alice.example.com..at.did.document..self",
"publicKeyMultibase": "zQ3sh..."
}
],
"service": [
{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://pds.example.com"
}
]
},
"deactivated": false
}
A lexicon schema is provided in lexicons/at.did.document.json. Any collection
(or space record type) may be used, but the record value MUST contain a
didDocument object.
4.1 Document requirements #
didDocument.idMUST be byte-for-byte equal to thedid:atDID being resolved. This binds the document to its identifier: since the identifier encodes the record's location, only a writer to that location can publish a document for it.- If the DID is intended to be usable as the authority or member of a nested
did:at, the document MUST additionally provide:- an
alsoKnownAsentry of the format://<did>naming the atproto repo that holds (or authors) the nested records, and - a
serviceentry with id#atproto_pdsgiving that repo's PDS endpoint (optional if the resolver can resolve thealsoKnownAsDID normally).
- an
5. Operations #
| DID operation | atproto operation |
|---|---|
| Create | com.atproto.repo.createRecord / putRecord writing the record (space records: the corresponding Spaces write endpoint) |
| Read (Resolve) | §7 |
| Update | com.atproto.repo.putRecord replacing the record |
| Deactivate | com.atproto.repo.deleteRecord, or setting "deactivated": true |
All writes are authorized by the repo owner's (or space member's) atproto credentials; no method-specific keys are involved in the control plane.
6. Resolving DIDs Along the Path #
Resolution must turn the authority — and, for space paths, the member DID — into concrete atproto identities:
- handle — resolve per the atproto handle-resolution spec to a DID, then proceed as below.
did:plc/did:web— resolve per the atproto DID spec; the repo DID is the identifier itself and the PDS URL is the#atproto_pdsservice endpoint.did:at— recursively resolve it (this consumes recursion budget, §7). From the resulting DID document take the firstalsoKnownAsentry of the format://<did>as the repo DID, and the#atproto_pdsservice endpoint as the PDS URL (falling back to normal resolution of the repo DID if the service entry is absent). If the document has noat://alias, resolution fails withinvalidDidDocument.- Any other DID method: resolution fails with
unsupportedAuthority.
7. Resolution Algorithm #
Resolvers MUST accept a recursion parameter, maxRecursion, giving the maximum
number of did:at resolutions a single request may perform (the outermost
resolution counts as one; nested authorities and nested member DIDs each count
as one). The budget is a single shared counter for the whole request —
sibling resolutions (a space's authority and its member) draw from the same
budget, so it bounds total fan-out, not just nesting depth. The RECOMMENDED
default is 8. Resolvers SHOULD also track the set of did:at DIDs already
being resolved on the current chain, to fail fast on cycles.
resolve(did, maxRecursion = 8):
budget ← maxRecursion ; seen ← ∅ ; return resolveOne(did)
resolveOne(did): # every did:at on the chain re-enters here,
# sharing budget and seen
1. if budget = 0 → error recursionLimitExceeded
budget ← budget − 1
2. if did ∈ seen → error cycleDetected
seen ← seen ∪ {did}
3. (authority, path) ← parse(did) # §3.3, error invalidDid
4. (repoDid, pdsUrl) ← resolveDid(authority) # §6; a did:at re-enters
# resolveOne
5. fetch the record:
a. repo path (collection, rkey):
GET {pdsUrl}/xrpc/com.atproto.repo.getRecord
?repo={repoDid}&collection={collection}&rkey={rkey}
b. space path (stype, skey, member, rtype, rkey):
memberDid ← resolveDid(member) # §6; did:at re-enters
# resolveOne
GET {pdsUrl}/xrpc/com.atproto.space.getRecord
?space=at://{repoDid}/space/{stype}/{skey}
&repo={memberDid}&collection={rtype}&rkey={rkey}
(endpoint name provisional — this method binds to whatever
record-fetch endpoint the Spaces proposal standardizes on)
on 404 → error notFound
6. if record.value.deactivated → error deactivated (return doc + metadata)
7. doc ← record.value.didDocument
if doc missing or doc.id ≠ did → error invalidDidDocument
8. return { didDocument: doc,
didDocumentMetadata: { deactivated: false },
didResolutionMetadata: { recursionRemaining: budget } }
8. Security and Privacy Considerations #
- Control = write access. Anyone who can write the record (repo owner,
their PDS operator acting maliciously, a leaked app password; for space
records, the authoring member and the space host) controls the DID. The
trust root is the authority's identity system (
did:plc's rotation keys,did:web's TLS, handle DNS). - Space records add the space host to the trust chain. The host serves the
member's record; a malicious host can withhold or roll it back (substitution
is still bounded by the §4.1
idcheck). - Recursion bombs and cycles. Nested identifiers let a short DID trigger
unbounded network fan-out;
maxRecursionand cycle detection are mandatory, not optional, for a conforming resolver. - The
idequality check (§4.1) is load-bearing. Without it, a repo could serve a document claiming to be some other DID. - Mutability without history. Unlike
did:plc, there is no audit log of prior documents at the resolver's disposal beyond what the firehose / repo history retains. Verifiers needing key-rotation history should not use this method alone. - Availability. Resolution depends on the liveness of every PDS, space host, and identity service along the chain; each nesting level multiplies failure modes.
9. Limitations (v0.3) #
- Record keys containing
~or.., or beginning/ending with., are unaddressable (§3.5). - Handle authorities inherit handle-reassignment risk; prefer DID authorities for long-lived identifiers.
- The space fetch endpoint (§7.5b) tracks a proposal that has not stabilized.
- No verifiable document history.