# The `did:at` DID Method **Status:** Draft **Version:** 0.3 ## 1. Overview `did:at` is a [DID method](https://www.w3.org/TR/did-core/) in which the method-specific identifier is an [AT-URI](https://atproto.com/specs/at-uri-scheme) 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://///…/ ⇔ did:at:......….. ``` ### 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 nested `did:at` identifiers 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, including `did:web` with 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. ```abnf 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. 1. Strip `did:at:` and split the remainder on `..`. No component may be empty or begin or end with `.`. 2. **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 embedded `did:at` flatten into the list innermost-first. - Otherwise *c* is a complete DID (any method) or a valid atproto handle; consume one component. 3. **parsePath** at cursor *i*: - If component *i* is the literal `space`: consume `space`, `stype` (NSID), `skey` (record key), then **parseDid** (the member — which may itself be a `did:at`), then `rtype` (NSID) and `rkey`. - Otherwise: consume `collection` (NSID) and `rkey`. 4. 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 `:` and `did:web` authorities 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: ```json { "$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.id` MUST be byte-for-byte equal to the `did:at` DID 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 `alsoKnownAs` entry of the form `at://` naming the atproto repo that holds (or authors) the nested records, and - a `service` entry with id `#atproto_pds` giving that repo's PDS endpoint (optional if the resolver can resolve the `alsoKnownAs` DID normally). ## 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_pds` service endpoint. - **`did:at`** — recursively resolve it (this consumes recursion budget, §7). From the resulting DID document take the first `alsoKnownAs` entry of the form `at://` as the repo DID, and the `#atproto_pds` service endpoint as the PDS URL (falling back to normal resolution of the repo DID if the service entry is absent). If the document has no `at://` alias, resolution fails with `invalidDidDocument`. - 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 `id` check). - **Recursion bombs and cycles.** Nested identifiers let a short DID trigger unbounded network fan-out; `maxRecursion` and cycle detection are mandatory, not optional, for a conforming resolver. - **The `id` equality 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.