atprotocrates

Cn.

Concepts

Not an introduction to AT Protocol. This is what the workspace means by each primitive, which crate owns it, and where implementations usually go wrong.

§ 01

Content identifiers

A CID is a hash with its own instructions attached. It says which hash function produced it, how long the digest is, and how the bytes it addresses should be interpreted, so a reader can verify content without being told out of band what to verify it as.

AT Protocol uses a deliberately narrow profile of the general format. Version 1 only. Two codecs: 0x55 for raw bytes (blobs) and 0x71 for DAG-CBOR (records and tree nodes). SHA-256, always, with a 32-byte digest. In string form it is base32, lowercase, with a leading b.

Anatomy of a record CIDFig. 1
bafyreibvjvcv745gucpxvomnehdaj7lqrhqhsp6dnxnc7ldz6ldwxbfvxa
│
└── b        multibase prefix — base32, lowercase

    then, decoded:
    0x01     CID version 1
    0x71     codec: dag-cbor       (0x55 = raw, for blobs)
    0x12     multihash: sha2-256
    0x20     digest length: 32 bytes
    ...      the 32-byte digest

Because the codec and version are fixed by the profile, the human-readable prefix is stable enough to recognise on sight: bafyrei… is a DAG-CBOR CID, bafkrei… is a raw one. That is a diagnostic shortcut, not a validation strategy.

Where CIDs appear, and in what form
ContextFormNotes
JSON{"$link": "bafyrei…"}The AT Protocol JSON encoding of a link.
DAG-CBORtag 42 + 0x00 + 36 bytesThe leading 0x00 is the identity multibase prefix, and it is required.
Binary36 bytes4 bytes of prefix, 32 of digest.

The dropped zero

Inside DAG-CBOR, a CID is written as tag 42 wrapping a byte string that begins with 0x00. That byte is the identity multibase prefix and it is not part of the CID. Encoders that omit it, and decoders that forget to strip it, produce CIDs that are wrong by exactly one byte, which is enough to make every hash downstream disagree.

§ 02

Deterministic encoding

Content addressing only works if the same value always encodes to the same bytes. CBOR by itself does not guarantee that: map keys can come in any order, integers can be written longer than they need to be, and floats admit NaN. DRISL is the constrained profile that closes those gaps.

  • Map keys are sorted, and the sort is over the encoded key bytes.
  • Integers use the shortest form that holds the value.
  • NaN and the infinities are rejected outright, not normalised.
  • There is exactly one valid encoding of any given value.

That last constraint means a CID is a function of the value, not of whoever serialised it. Two independent implementations that both follow the rules will agree on the hash, which is what makes cross-implementation verification possible.

§ 03

Merkle Search Trees & commits

A repository is a key-value map from record path (collection/rkey) to record CID. The MST is the structure that map is stored in, and its properties are chosen for one purpose: two repositories holding the same records must produce the same root hash, no matter what order the records arrived in.

That is achieved by deriving each key’s height from the hash of the key itself (leading zero bits in SHA-256) rather than from insertion order or a balancing rule. The tree shape is a function of its contents. Nothing about history leaks into it.

Above the tree sits the commit: a small DAG-CBOR object carrying the repository DID, a version, the root CID of the tree, a rev (a TID, monotonically increasing), an optional prev, and a signature over all of it. Verifying a repository means checking the commit signature against the key in the account’s DID document, then checking that the tree hashes to the root the commit claims.

What a diff gives you
OperationUse
insert / get / deleteOrdinary record CRUD against the tree.
listEnumerate a collection without materialising the whole repository.
diffTwo roots in, the set of changed leaves out. This is how sync stays cheap: a peer sends only the blocks you are missing.

§ 04

TIDs and AT-URIs

A TID is a timestamp identifier: microseconds since the epoch plus a small random clock identifier, base32-sortable, thirteen characters. Record keys use them so that lexical order is chronological order, and so two writers on different machines are unlikely to collide.

An AT-URI addresses a record by identity rather than by location. The authority is a DID or a handle; the path is the collection NSID and the record key.

Anatomy of an AT-URIFig. 2
at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3juf4hq2xrl2s
     └──────────── authority ────────┘ └── collection ──┘ └─ rkey ────┘
        DID (stable) or handle (not)      an NSID          often a TID

Prefer the DID form anywhere you store or compare URIs. A handle-form AT-URI is a reference that can silently start pointing at somebody else.

§ 05

DIDs and handles

Every account has two names and they do different jobs. The handle is a domain name: memorable, transferable, and therefore untrustworthy as a key. The DID is permanent and opaque, and it is what you store.

DID methods in this workspace
MethodResolved byStatus
did:plcThe PLC directory, with an auditable operation log.Resolved.
did:web/.well-known/did.json on the DID’s own domain.Resolved.
did:keyNothing — the key is the identifier.Resolved.
did:webvhNot resolved here. Syntactically valid, but no resolver.

Handle resolution has two independent paths: a DNS TXT record at _atproto.<handle>, and an HTTPS fetch of /.well-known/atproto-did. Both are consulted. When they disagree, which happens during migrations and also when something is wrong, the resolver reports the conflict instead of picking a winner for you.

Verify both directions

Handle resolution proves that a domain claims a DID. It does not prove the DID claims the domain back. The DID document’s alsoKnownAs is the other half; without checking it, anyone who can set a TXT record can point a handle at somebody else’s account and have your application render it as theirs.

§ 06

NSIDs and lexicons

A lexicon is the JSON schema for a record type or an XRPC method. Its name is an NSID: a reversed domain plus a name, like app.bsky.feed.post. The reversal is not cosmetic. It says who has authority over the schema: whoever controls bsky.app controls what app.bsky.* means.

Resolution follows that authority chain, and it is longer than most people expect:

  1. Turn the NSID’s domain part into a DNS name with a _lexicon. prefix.
  2. Look up the TXT record to find the authoritative DID.
  3. Resolve that DID to a document.
  4. Take the PDS endpoint out of the document.
  5. Fetch the schema record over XRPC.

Referenced lexicons resolve recursively, bounded by a depth limit. Fragment-only references (#main, #someDef) resolve against the enclosing lexicon rather than failing.

§ 07

DPoP and token binding

A bearer token is worth whatever it costs to steal. DPoP (RFC 9449) binds a token to a key the client holds, so intercepting the token is not sufficient. Every request must also carry a fresh proof, signed by that key, naming the method and URL it is for.

What a DPoP proof asserts
ClaimBinds the proof to
htmThe HTTP method.
htuThe target URL.
athA hash of the access token, so proof and token travel together.
jtiA unique identifier, so a captured proof cannot be replayed.
nonceA value the server chose, when the server insists on choosing one.

The nonce handshake looks like a failure

A server may reject your first proof with use_dpop_nonce and return the nonce it wants in a header. The correct response is to retry with it. Clients that treat the first rejection as terminal work against servers that do not demand nonces and mysteriously fail against servers that do.

§ 08

Scopes

Early AT Protocol OAuth had two scopes worth naming — atproto, and transition:generic for everything an app password used to do. That is a blunt instrument: it grants an application every collection in a repository when it wanted one.

The granular grammar replaces it. A scope names a resource class and, usually, a constraint on it, so a client can ask for what it actually needs and a user can see what they are agreeing to.

Scope kinds in the scopes module
KindGoverns
repoRecords in the user’s repository, narrowed by collection and by action.
blobBlob upload, narrowed by MIME pattern.
rpcXRPC calls, narrowed by lexicon and by the audience allowed to serve them.
accountAccount-level resources such as the email address, and what may be done to them.
identityIdentity operations, including handle changes.
includeA permission set pulled in by reference, so a client asks for a named bundle rather than reciting it.
transitionThe legacy blanket grants, kept for applications that have not migrated.

The crate parses these into typed values rather than passing strings around, so an unrecognised or malformed scope is a ParseError at the boundary instead of a permission check that quietly matches nothing.

§ 09

Attestation

Commit signatures prove a repository’s owner published a record. Attestations prove something narrower and more useful: that a specific party vouched for a specific record’s content, independently of which repository it sits in.

The workflow is CID-first. The record is prepared with $sig metadata carrying a $type and a repository, serialised deterministically, hashed to a CID, and the CID bytes are signed. Two shapes exist:

  • Inline — the signature is embedded in the record itself.
  • Remote — a separate proof record holds a strongRef to the content. Two CIDs are now in play: the content’s, and the proof record’s. They are easy to confuse.

Why the repository field exists

The repository field in $sig is what stops a record being cloned out of one repository into another with its signature intact. Without it, a valid attestation is a portable endorsement of content that travels wherever the content is copied.

§ 10

Permissioned spaces

A public repository is public by construction: the MST leaks its key set even to a reader who cannot decrypt the records. Spaces are the draft answer: a permissioned realm where membership gates reads, and where the commit structure is designed so that a leaked commit does not become evidence.

The primitives, from the 0016 draft:

  • LtHash — a lattice hash implementing the SetHash trait, committing to a set without committing to an order.
  • Deniable commits — a commit signs only its per-commit context and binds the set-hash digest through an HKDF-keyed HMAC with per-commit random key material. A leaked commit does not prove what was in it.
  • Two-step credentials — a delegation token is exchanged for a short-lived space credential, with replay protection over the token identifier.

Draft, not standard

The 0016 proposal is still settling. atproto-space tracks it rather than leading it, and is unpublished for that reason. atproto-pds serves it, the second implementation anywhere and the first in Rust. That makes it useful for interoperability testing and unsuitable for anything durable.