Identities for entities did.bot
agent llm did
didbot docs conformance.md
62 kB

Conformance #

atproto is a protocol other people implement. Every identifier this server mints — a record key, a DID, a handle, an at:// URI — is a string somebody else's code will parse, and the only way to know it will parse is to check against the same vectors they check against.

The official corpus at https://github.com/bluesky-social/atproto-interop-tests is CC-0, so it is vendored into this repository under vendor/atproto-interop-tests/ rather than fetched at test time: a conformance suite that only runs when GitHub is reachable is a suite that stops running. PROVENANCE.md in that directory records which upstream commit the copy came from and when. scripts/refresh-interop-vectors.sh replaces it and rewrites the provenance, so the copy moves forward deliberately instead of rotting.

The com.atproto.* lexicon documents are vendored the same way and for the same reason, under vendor/atproto-lexicons/, by scripts/refresh-atproto-lexicons.sh. Those are not vectors — they are the specification of the wire surface this server claims to implement — and what reads them is described under the wire. They are dual MIT/Apache-2.0 rather than CC-0, so the licence files are copied beside them and PROVENANCE.md is the attribution.

The executable half is crates/didbot/tests/conformance.rs. It is an ordinary cargo test target and adds no dependency to anything.

What runs #

Each syntax suite reads <name>_syntax_valid.txt and <name>_syntax_invalid.txt and asserts the obvious thing about every line in each.

Suite Validator
tid [crate::pds::tid::validate]
recordkey [crate::pds::records::validate_record_key]
nsid [crate::nsid_syntax::validate]
handle [crate::identity::validate_handle]
did [crate::identity::validate_did]
atidentifier either of the two above
aturi [crate::pds::AtUri::parse]
datetime [crate::pds::format::datetime::validate]
language [crate::pds::format::language::validate]
uri [crate::pds::format::uri::validate]
cid [crate::pds::format::cid::validate]

The lexicon/ vectors run too, and they are a different shape: rather than a list of strings they are a small catalog of lexicon documents plus two lists of records that must and must not validate against them. They exercise blobs, bytes, unions, refs and closed enumerations — none of which this project's own five lexicons use — so they test the record validator far harder than lexicons/ can. crates/didbot/tests/conformance/records.rs runs them against [crate::pds::Catalog].

The same directory holds two lists of whole documents — lexicon-valid.json and lexicon-invalid.json — which ask a narrower question: is this a lexicon document at all. The same file runs them through the pipeline a document really travels, LexiconDoc and then Catalog::insert. Refusing all seven takes four checks beyond parsing: the id is an NSID, unknown and ref are field types rather than definitions, and a primary definition is named main.

The data-model/ vectors are a third shape again, and the only ones that check bytes rather than a verdict. Two of the three files are the familiar accept-and-refuse pair, run against [crate::data::Value::object_from_json]; the third, data-model-fixtures.json, gives a value, the exact DAG-CBOR encoding it must produce, and the CID that encoding must hash to. crates/didbot/tests/conformance/data_model.rs checks all three, and the fixtures are why the suite is worth having: an encoder checked against its own decoder round-trips perfectly whatever map ordering or integer width it picked, and would still give every record an identity no other implementation would agree with.

Because those files are data rather than code, the runner also checks that it is reading them. A vector file that is present, parses and holds an empty list makes every loop over it run zero times, so each file is asserted non-empty as it is read and the failure names the file. A file nobody opens is the same silence one level up, so the directory listing is compared against the three names the runner reads: a refresh that adds a fourth file fails rather than leaving the new vectors unrun.

The firehose/ vectors are the fifth shape and the misleadingly named ones. They were skipped here for a while on the reading that they tested the protocol's framed DAG-CBOR stream, which this project deliberately does not speak. They do not. commit-proof-fixtures.json gives a leaf set, the root over it, a set of adds and deletes, the root after them, and the blocks a subscriber needs to check the operation without holding the repository. crates/didbot/tests/conformance/firehose.rs runs all six.

That makes it the only upstream vector asserting a root CID over a set of records, which is the one claim mst/ does not make, and the six shapes are chosen to be awkward: a split two layers deep, a leafless split, a split whose neighbour is two layers down, and two commits that merge and split in the same operation. The proofs come from [crate::data::mst::Tree::covering_proof], which is the walk to the changed key plus the walks to the keys either side of it — adding or removing a key can split a node or merge two, so a proof of the key alone verifies the lookup and cannot reproduce the root the commit started from. In every fixture the proof this project builds is exactly the size upstream requires, and in four of the six it is smaller than the tree.

Upstream's own runner asserts one further thing that is not asserted here: it loads the after-tree from a store holding only the proof, inverts the operations in every order, and requires the before-root back. That needs a tree loadable from partial storage and mutable one operation at a time, and [crate::data::mst::Tree::build] is deliberately a pure function of a whole leaf set. What is checked instead is the property the inversion demonstrates — the proof carries every block upstream says it needs.

The mst/ vectors are the fourth shape, and the odd ones: they check two pure functions that a Merkle search tree is built out of, and there is no tree here. A key's height is the count of leading zero bits of its sha-256 divided by two — the fanout is four, so two zero bits buy one layer — and a common prefix length is how many leading bytes two adjacent keys share, which is what lets a node store a suffix instead of a whole key. Both live in [crate::data::mst], and crates/didbot/tests/conformance/mst.rs runs key_heights.json and common_prefix.json against them.

It also runs example_keys.txt, which is a vector file that does not announce itself. Upstream generates it by searching for keys at a wanted height and naming each one after the height it found, so C4/134079 is an assertion that that key sits at layer four — 156 more height cases, reaching layers three and five, which the nine explicit ones never do.

There is now a tree as well, and upstream ships nothing that checks one: a root CID over a set of records is the one claim mst/ does not make. So it is checked against a repository somebody else's server built. crates/didbot-data/tests/reference_repository.rs reads tests/fixtures/reference-repository.tsv — 147 records of a real public repository, captured with goat repo export and reduced to the two columns a tree is a function of — and requires our root to be the root the reference implementation's commit signed. Two larger repositories were checked the same way while the tree was written, at 1,729 and 10,803 records, and are not vendored because the fixture would be a megabyte.

That test is the one that earns its place. The plausible layering rule — the one that collapses a level nothing sits on — passes every self-consistency check a tree can run against itself and reproduces none of the three repositories.

Checking the two functions before the tree is the same argument as the data-model fixtures, only sharper. A tree built on a wrong height function is not merely self-consistent, it is correct: every lookup succeeds and every proof it makes verifies against itself. The only symptom is a root CID nobody else reproduces. No test a tree runs against itself can find that, and the one thing this suite cannot say — the prefix is counted in bytes, and every candidate upstream ships is ASCII, so a character count passes all of it. That claim is asserted in the crate's own unit tests instead.

Three of those validators did not exist before the vectors did. This project minted TIDs without being able to read one, built at:// URIs without being able to parse one, and accepted a requested handle without checking it was a handle. A format with a writer and no reader is a format nothing can verify, which is the gap a conformance suite exists to find.

What the vectors cannot tell us #

A vector says a validator agrees with the specification. It says nothing about whether the strings this server produces are ones that validator would accept, and that is the failure that would actually reach a user: an rkey no other implementation can read, a URI that resolves nowhere. So the same test target mints real output and feeds it back — every record key the store hands out is a valid TID, every URI the wire layer builds parses back into the three parts it was built from, every DID and hostname the minter produces is valid under the protocol's own generic rules.

That last one is the load-bearing direction. AgentDid is deliberately much stricter than atproto's DID grammar — did:web only, hostname-level only, ports only on loopback — so the generic vectors are not run against it; they would report failures that are the entire point of the type. What is asserted instead is the implication that matters: everything we mint is also a DID by the protocol's definition. If that ever stops being true, the strictness has stopped being a subset and has become a dialect.

A handle is checked the same way, and then one step further, because a handle is the one identifier here that has to agree with something else. atproto's rule is bidirectional: a handle resolves to a DID, and that DID's document must claim the handle back in alsoKnownAs. So the emit tests provision real accounts through a real Provisioner — with a namer and without, because they are different paths — and assert the loop closes for every one of them.

Property What is minted, and what it is fed back through
every issued handle is a handle a name composed from a word list and the zone, through validate_handle
the well-known answers what the document claims Registry::handle_did against DidDocument::claims_handle
no two accounts answer to one handle every account in a population of two hundred
a name nobody holds resolves to nothing a handle under the zone that was never issued

The second row is the load-bearing one, and it caught a real disagreement: an account with no issued handle claims its own hostname in alsoKnownAs, which is the honest did:web default, and the well-known used to deny it because nothing was stored. Both answers now derive from one function, so the property is by construction and the test says the construction held. Handle resolution is verified in both directions, including why the DNS TXT resolution method is not implemented and why the reading side checks containment rather than making the round trip.

Keys and signatures #

crypto/ was listed here as blocked on signing, which conflated two things. This server does not sign a commit — there is no Merkle search tree to sign over — but it has had signature primitives since the beginning: a keypair per account, the public half published as publicKeyMultibase in a DID document, and verification. The vectors ask only about those, so nothing had to be built to run them.

File What it asserts
w3c_didkey_K256.json A private key encodes to the did:key the W3C vectors expect, and that string decodes back to the same key
signature-fixtures.json A signature is accepted only when it is a 64-byte compact pair with a low-S value that verifies

This is the load-bearing direction rather than an incidental one. Every DID document this server serves carries a multibase public key, and a resolver that decodes it differently than we encode it gets a different key — which looks like a valid document describing an account whose signatures never verify. Nothing else in the suite would notice.

The two rejections are checked for the reason as well as the verdict: the high-s fixture must be refused by the low-S rule and the der-encoded one by the length. A fixture rejected for the wrong reason keeps passing after the check it is watching is deleted.

ES256 and w3c_didkey_P256.json name P-256, and they used to be asserted present and deliberately unrun, because there was nothing to run them against. There is now. [crate::key::secp256r1] verifies a P-256 signature and decodes a P-256 did:key, and does not sign: the crate's argument for one curve is an argument about signing, where k256 normalizes s and p256 does not, so a P-256 signer here would emit a malleable signature about half the time. Verifying is the other direction — a key from elsewhere is not ours to normalize, it is ours to judge — so the low-s rule k256 applies internally is written out by hand there.

Half the network's accounts sign on that curve, so a server that will one day read another server's commit needs to read both. A further test asserts that every fixture in the file is claimed by one of the two curves, because being filtered out by both loops is exactly how P-256 sat unrun.

The wire, checked against the documents that define it #

The suites above check strings and bytes. They say nothing about whether a client written against the lexicons can talk to the routes, which is the epic's actual exit criterion.

crates/didbot/tests/conformance/wire.rs is the automated half. It drives every com.atproto.* route the router serves and checks each response body against the output schema the upstream document declares — vendor/atproto-lexicons/, Bluesky's own documents copied unedited by scripts/refresh-atproto-lexicons.sh. The requests never leave the process: the router is handed them directly, as in tests/end_to_end.rs, so this needs no port, no network and no client written in another language.

The distinction between that and an ordinary route test is the whole point. A response type written from a reading of a document is checked by a test written from the same reading, so the two agree with each other and neither agrees with anything else. That is not hypothetical: for months every write route stored the record and answered without a cid, which the lexicon has always marked required, and nothing in this repository noticed until the official client refused a response. Reintroducing that bug now fails seven tests with `cid` is required and is missing.

What it checks How
every route's response satisfies its output schema Catalog::validate_output against the vendored document
every request these tests compose satisfies its input schema the same, in the other direction, so a fixture cannot rot and go on proving things
every route this server serves is a method upstream defines serve::ATPROTO_METHODS, which the route table itself is generated from
every refusal carries an error name the document declares plus InvalidRequest and MethodNotImplemented, which XRPC defines for all methods
paging conforms on every page, not just the first listRecords is the one response whose shape changes with the request
a foreign record round-trips and is reported unknown checked against upstream's own knownValues, not this project's spelling of them
a blob upload answers the four-field blob shape the one response whose schema is a lexicon type rather than a hand-written object

Two silences are treated as failures rather than passes. A method the vendor directory does not hold, and a method whose document declares no schema for the body in hand, both fail the assertion instead of quietly checking nothing — com.atproto.sync.getRepo returns a CAR file and is named as declaring no JSON schema, so that the day it grows one this suite stops skipping it.

There is one place this server deliberately says more than its document declares, and it is a named constant with the reason attached rather than a check nobody runs: com.atproto.repo.getRecord answers RepoNotFound for a repository that was never here, where the document declares only RecordNotFound. Those are different things to a caller, and com.atproto.sync.getRecord declares the name for the same condition, so it is the protocol's word rather than one invented here. Every other undeclared name fails.

The same surface, driven by somebody else's client #

What has been done by hand, and is worth writing down because the result is specific: the official @atproto/api was pointed at a development server and run through every com.atproto.repo.* route. It validates each response against the lexicon it generated its types from, so it is a conformance check that happens to be somebody else's code.

What it did Result
describeRepo, by DID and by handle accepted
getRecord accepted
getRecord for a key that is not there raised its own RecordNotFoundError — the typed class the lexicon's declared error name generates
getRecord in a repository that is not here XRPCError with RepoNotFound
deleteRecord, twice accepted both times
listRecords paged with cursor past a repository of more than a hundred records every row arrived exactly once, in key order, and the last page carried no cursor
listRecords with reverse the same rows, oldest first
listRecords, createRecord, applyWrites accepted, each response carrying the cid the lexicon requires
getRecord naming the cid a write returned accepted, and the same record came back
putRecord carrying the swapRecord that is there accepted, and answered with the new CID
putRecord carrying a swapRecord that has moved on raised its own InvalidSwapError — the typed class the lexicon's declared error name generates — and the record was unchanged
deleteRecord carrying a stale swapRecord InvalidSwapError, and the record was still there afterwards
createRecord over a literal:self record that exists XRPCError with InvalidRequest, naming putRecord
createRecord carrying the swapCommit getRepo publishes accepted
createRecord carrying a swapCommit the repository has moved off InvalidSwapError
applyWrites carrying one swapCommit for three writes accepted, three results

Since validation became optimistic the same client also writes a real foreign record — an app.bsky.feed.post, composed by @atproto/api from its own generated types and carrying a field no version of that lexicon declares — reads it back through getRecord, and gets the same object it sent. The write answers validationStatus: "unknown", which is the protocol's word for a record stored under a lexicon the server could not resolve, and a record in the same session answers "valid". That is the epic's exit criterion for the collection set: the client knows nothing about this project, and this project knows nothing about the client's lexicon.

Two rows are worth reading twice. listRecords, createRecord and applyWrites used to read "stored the records and then refused the response: must have the property "cid"", which was the known gap and the only thing in the surface that failed; naming a record on the way in closed it. And the swap rows used to be one row saying UnsupportedSwap — a name the client does not recognise — where the client now raises InvalidSwapError, the class generated from the error name the lexicon declares. Seventeen calls, nothing refused that should not have been.

The CID a client is handed is checked against something that is not this project. goat repo ls over the same repository's CAR export lists each record under exactly the CID applyWrites returned for it, so the name a client holds is the name the file uses.

The same client drives the three blob methods, and every row passes. It is worth listing separately because a blob is the one thing here whose identity a client can check on its own: the reference uploadBlob returns names bytes, and the client recomputes that name from the bytes with its own multiformats rather than believing the server's.

What it did Result
uploadBlob with a PNG accepted, and parsed into the client's own BlobRef — so the {"$type": "blob", …} shape validated against the lexicon that generated it
the CID, recomputed from the file by the client identical to the one the upload returned
getBlob the bytes back, sha256 identical to the file, content-type as uploaded
getBlob after a SIGKILL and a restart identical again, from --data
getBlob for a CID nobody uploaded XRPCError with BlobNotFound
listBlobs names the blob; a repository nobody minted is RepoNotFound
listBlobs with since XRPCError with InvalidRequest, refused by name because nothing here counts references from records to blobs

Three sabotage runs go with it, since a store that never refuses anything is not checking anything. One bit flipped in a stored blob makes getBlob answer 500 BlobCorrupt naming what the bytes actually hash to, having served none of them. An upload cut off half way — the connection reset mid-body — leaves the temporary directory empty and the listing unchanged. And a body over the configured cap is 413 BlobTooLarge, a body that would break the account quota is 403 AccountQuotaExceeded, and neither leaves a file behind.

Account state, and where it does not map onto the wire #

AgentAccount carries an AccountState — whether its data exists — and a set of locks — whether it serves; account-lifecycle.md holds both tables, generated from the types. Hard delete is not a state — it removes the account row entirely, key included, which is this server's only honest way to represent "no evidence it ever existed". Two protocol surfaces are supposed to carry this, and neither carries it cleanly.

com.atproto.sync.subscribeRepos's #account event. The lexicon defines active as "the account has a repository which can be fetched from the host" — fetchability, not authority to write — and status is only meaningful when active is false ("if active=false, this optional field indicates a reason"). status uses knownValues rather than a closed enum, so a value outside the six listed words (takendown, suspended, deleted, deactivated, desynchronized, throttled) is not rejected — but that open union only lets a deployment name new kinds of inactive. It gives no way to say "active but not writable", because #account does not model write authority at all.

position active status
reserved, provisioning (no event) (no event) — nothing is emitted before activation: #identity, #account and #commit are all withheld until the initial records are in the repository, so a relay that learns of the account and crawls it never finds an emptier version than the one announced.
active true absent
active + frozen or quarantined true absent — the lossy case. The repository is still fetchable, so active is honestly true, and there is no status word for "readable, not writable". suspended was considered and rejected: it implies active: false, which would tell a relay to stop fetching a repository that is still there and unchanged — a worse lie than saying nothing. A relay or appview watching #account alone cannot learn that an account is frozen; the lock is announced nowhere on this stream.
active + suspended false suspended — an operator stopped the account serving. Emitted when the tag is hung, and active: true again when the last read-removing tag is lifted.
active + deactivated false deactivated — the account stopped itself. When both read-removing locks hang, the operator's word is the reason given.
decommissioning, decommissioned false deleted — emitted once, by Provisioner::erase, after the repository, its blobs and its credentials are gone and the name is burned, so a consumer that acts on it immediately finds no repository. The document, the key and the DNS record keep serving.
(hard delete) (no event) (no event) — the row leaves after it was erased, and the erasure already said deleted; a row reaped before activation was never announced and reaping it must not be the first thing a relay hears. Once deleted is on the wire it is out of this server's control — a relay or appview that cached the repository keeps serving what it has — so "no evidence it ever existed" is true of this server's own surfaces and not of the network.

com.atproto.sync.getRepoStatus is the same table, one row on request: active and status come from the same AgentAccount::sync_status the #account event is announced from, rev is present exactly when active is true, and the two (no event) rows are RepoNotFound. The other com.atproto.sync.* reads of one repository refuse a suspended or deactivated account as RepoSuspended or RepoDeactivated, and every other position they do not serve as RepoNotFound. crates/didbot/tests/conformance/wire.rs drives both surfaces through a lifecycle and across every state-and-lock position and asserts they agree.

Where a genuinely inactive position does not fit any of the six known values, the right answer is a namespaced custom status, never reusing a listed word for a meaning the lexicon does not define it as. takendown belongs to a moderation surface this deployment does not vendor; desynchronized and throttled describe a host, not an account.

bot.did.listAgents. This server's own surface, and where a lock is actually visible: AgentSummary.state carries the same AccountState the #account mapping is derived from, and AgentSummary.locks every tag with the party that hung it — one set of types, read by both surfaces, so they cannot silently drift apart the way a hand-maintained second enumeration would. An operator or dashboard that needs to know an account is frozen reads this, not the firehose.

There is no per-agent get route yet. When there is, it carries the same field for the same reason.

com.atproto.server.describeServer, checked against the lexicon schema #

Discovery: what this server is, before a caller has an account or even a DID to look up. It joins wire.rs's suite the same way every other route does — .conforms validates the response against the vendored output schema, which requires did and availableUserDomains and declares inviteCodeRequired, phoneVerificationRequired, blobUploadLimit, links and contact all optional.

This server answers only what it can answer honestly. did is the service DID com.atproto.server.getSession and the DID document already agree on. availableUserDomains is read out of Registry::available_user_domains, which is the deployment's real didbot_identity::ZoneRegistry when one is configured and the single configured zone otherwise — never a fixed string — so a several-zone deployment reports every zone it mints handles under. The five optional fields are left off the response entirely rather than filled with an invented value: there is no invite system, no phone verification, no configured blob limit surfaced through Registry, and no configured policy or contact document to point at. A test asserts the domain reported is the zone the test server was actually built with, not a value the response type happened to default to.

The export, checked by something that is not us #

A CAR file is the one thing this server produces that a stranger can check without trusting it, so it is checked by a stranger. goat — the CLI from bluesky-social/indigo, a Go implementation sharing no code with this one — reads an export from com.atproto.sync.getRepo, parses the commit, walks the Merkle search tree and lists every record. docs/running-locally.md has the commands.

That check is manual, because vendoring a Go toolchain into cargo test is not a trade worth making. What runs unattended is the pair of things it would otherwise be the only evidence for. didbot-repo/tests/car_framing.rs walks the CARv1 framing back apart — every section as long as its prefix claims, every block hashing to the CID in front of it, a truncated file refused and a flipped byte breaking exactly one block — without decoding anything. And didbot-serve's sync tests write records through the HTTP surface, fetch the CAR through the HTTP surface, decode the commit block with a reader written from the specification rather than from this project's encoder, and verify its signature with a key taken out of /.well-known/did.json. That last one is plan/pds-writes.md's exit criterion, run on every commit.

The same module does it for a proof. com.atproto.sync.getRecord answers with the blocks on one key's path through the tree, and the test walks them the way a stranger would: check the commit's signature with the published key, hash each node block and require it to be the one the previous step named, follow the link, and end at the key or at the node the key would have to be in. Two sabotage cases run alongside it, because a verifier that never refuses anything is not a verifier — a path with a node removed is caught by the missing block, and a node lifted out of a different repository and filed under the CID the path named is caught by the hash. The node reader, like the commit reader, is written from the specification.

The joins between the layers #

Each of the checks above holds one layer to something outside it. What none of them can say is whether the layers agree with each other, and that is where a repository can be self-consistent and still wrong: the record store, the commit history, the tree, the CAR writer and the HTTP surface each answer correctly and describe different repositories. crates/didbot/tests/repo_invariants.rs is the suite for the joins. It writes through the real provisioner, reads back over the real router, and rebuilds what it was handed with the decoder rather than the encoder.

Three claims, and each one spans crates. A repository emptied back to a set of records it already held returns to the byte-identical tree root and to a commit that is not one it has made before — the tree is didbot-data's pure function of its leaves, the leaves are didbot-pds's store having genuinely forgotten, and the chain is checked at every commit rather than at the endpoints, because a history that forked and rejoined leaves the endpoints agreeing. The records com.atproto.repo.listRecords reports, re-encoded and keyed into a tree by the test itself, root where the signed commit says the repository roots — which is the only assertion anywhere that the JSON surface and the CAR surface describe one repository. And a reader holding the export at a revision plus getRepo's since delta can walk every leaf of the new root without asking again, which is the claim a delta actually makes and is not implied by it being smaller or by its blocks being a subset of the whole.

com.atproto.sync.subscribeRepos #

The method is implemented. It is a WebSocket at /xrpc/com.atproto.sync.subscribeRepos, and every message is one binary frame holding two concatenated DAG-CBOR values: a header, {"op": 1, "t": "#commit"} for a message or {"op": -1} for an error, and then the body. ?cursor= is the bare integer the lexicon declares.

The lexicon's message union names #commit, #sync, #identity, #account and #info. What this server puts on the stream, and where the rest of a repository's state comes from:

Frame What this server sends
#commit One per write, carrying everything the lexicon marks required — see below for the covering proof in blocks.
#identity On an identity change, as described under #identity and #account.
#account On an account-state change that maps onto the lexicon's status, as described in the same section.
#info OutdatedCursor, for a cursor older than the replay buffer reaches. It is the one name this server sends.
#sync A consumer that wants a repository's current state without the commits that produced it reads it from com.atproto.sync.getRepo, which serves the whole repository as a CAR.

#handle and #tombstone are not part of the lexicon's union.

What a #commit carries #

Everything the lexicon marks required. blocks is a CARv1 file rooted at the new commit and holding a covering proof of every key the commit touched: the union, over those keys, of the Merkle search tree nodes on each key's own walk from the root and on the walks to its immediate neighbours either side — see didbot_repo::Repository::covering_proof and, under it, [crate::data::mst::Tree::covering_proof] — plus every block the commit created, which is not always a subset of that proof. since is the revision the commit replaced, null for a repository's first. prevData is that revision's tree root, so a consumer can check that the operations invert back to the tree it holds — without holding it: a relay is not a consumer that already applied the previous commit, and a diff against blocks it does not have would name nothing it could check.

A covering proof is more than the diff com.atproto.sync.getRepo's since answers for the same two revisions. That diff, and the plain created-only diff a naive implementation of this frame would carry, both answer "what did this commit add" for a reader who already holds everything before it. A relay holds nothing before it: com.atproto.sync.subscribeRepos's own verification proposal has it invert every operation in the frame, starting from the new tree and reconstructing prevData, to check the frame's claim without trusting the frame's own node list. A split or a merge moves a node neither side's own key touched — a node the commit did not create and a created-only diff therefore does not carry — and inversion needs to read it. crates/didbot/tests/conformance/firehose.rs's a_created_only_diff_is_not_a_covering_proof measures the gap directly against two of the six upstream commit-proof fixtures.

Removals #

A deleteRecord is announced exactly as a write is: the same #commit, over the same covering proof, with one op whose action is delete, whose cid is null as the lexicon requires, and whose prev is the CID the key held. applyWrites announces its deletions the same way, inside the one commit the batch makes.

prev is on every op that is not a create — an update carries it too — and it is the field the lexicon calls "required for the inductive firehose". A consumer inverting a commit puts a deleted record back and restores an updated one's previous value, and nothing else in the frame names either. A delete op without it is a frame a relay can parse and cannot check.

The covering proof is right for a removal for the same reason it is right for an addition, and by the same code: the proof is taken over the tree as the commit leaves it, over every key the commit touched, and a removed key is still a key with a walk and with neighbours either side — its walk now proves absence rather than presence. Upstream's own commit-proof fixtures cover removals (their dels), and crates/didbot/tests/conformance/firehose.rs's each_commit_proof_carries_the_blocks_upstream_requires runs adds and deletes through the same union. crates/didbot/tests/subscribe_repos.rs's a_deletions_own_frame_inverts_to_prev_data_from_its_own_blocks_alone then does what a relay does with a real server's own frames: deletes twenty records one at a time from an out-of-order tree and inverts each removal back to prevData from that frame's blocks alone.

A consumer that was disconnected across a deletion sees it when it resumes: the deletion is a numbered frame in the one sequence space, so it falls in the gap between the consumer's cursor and now like any other commit, and arrives before the commits after it, once. a_consumer_disconnected_across_a_deletion_sees_it_on_resume is that assertion. A consumer whose cursor the buffer can no longer reach is told so — OutdatedCursor, above — rather than resumed silently, so a missed removal is never a thing a consumer believes it has and has not.

An account deletion is not a set of record deletions. Removing an account drops its records without announcing one #commit per record; what goes out is the #account above, active: false, status: "deleted", which says the whole repository is no longer fetchable. Per-record frames would be worse than useless: they would name commits over a repository that no longer exists for a consumer to reconcile them against. That #account belongs to the erasure — Provisioner::erase, which Registry::delete, Registry::hard_delete and the retention sweep all run — and goes out once, after the repository is gone. See the account-status table above.

The lexicon's three deprecated-but-required fields are written at their fixed empty values and never anything else: rebase and tooBig are always false, and blobs is always empty. Nothing here produces a rebase, nothing splits a commit for size, and no record written through this server references a blob the same commit uploaded.

prevData is absent, rather than wrong, for the first commit a repository makes after a restart that replayed a log written before the tree root was kept. Every later commit carries it.

Sequence numbers across a restart #

The lexicon's cursor is an integer with nowhere to name the run of the server that minted it, so the number itself survives a restart: a run reserves a block of numbers in the write-ahead log before handing any of them out, and the next run resumes above the last reservation. A crash therefore skips numbers rather than repeating them. The lexicon requires the sequence to increase and not to be gapless, and a consumer whose cursor lands in a skipped range is behind a restart that emptied the replay buffer anyway.

A cursor the in-memory replay buffer can no longer reach — because the consumer fell behind, or because the server restarted — gets #info naming OutdatedCursor and then everything the buffer still holds. Saying so is the point: the consumer has a hole only a re-read of the repositories can fill. A cursor naming a number this server has not assigned is the lexicon's FutureCursor error, and the connection closes.

#identity and #account #

#identity goes out once, at provisioning, and only once the account is AccountState::Active — later than the moment /.well-known/did.json starts answering for it, which is when the account is created, AccountState::Provisioning. The document resolving and the repository being complete are two different facts, and #identity says the second: "this DID resolves and its repository is fetchable, go and read it". Sending it any earlier — while the account exists but its initial records are still landing — would be a lie a consumer acts on: it fetches the repository, finds it empty or partial, and caches that. See AccountState::Provisioning and Provisioner::announceable.

It carries the account's handle: the one claimed_handle computes, which is what the DID document's alsoKnownAs claims and what /.well-known/atproto-did maps back to this DID. One function answers all three, so an indexer that takes the handle off the frame and runs atproto's bidirectional check against this server gets an agreement. An account that asked for no handle of its own is announced under its DID's own hostname, which is the name it answers to.

#account goes out at least twice in an account's life: active: true beside the #identity above, and active: false, status: "deleted" once the repository, its blobs and its credentials are gone — and in between, once per change to whether the repository can be fetched, as the locks that remove reads are hung and lifted.

Both share one sequence space and one replay buffer with #commit, so a consumer resuming from a cursor sees every event of all three kinds after it, interleaved, in order, once each. The ordering at provisioning is fixed: #identity, then #account, then the commits for the records the repository is born holding — one commit per record, each with a real tree delta and a prevData genuinely equal to the tree before it, rather than the two records landing in one uncommitted repository and the second commit's prevData claiming a change that never happened.

The account statuses this deployment produces #

The lexicon's status has six known values and this server produces three: suspended, deactivated and deleted, each the wire spelling of one position in account-lifecycle.md's tables.

Two things move without an account leaving active and are announced nowhere on this stream. Retention is how long an account keeps resolving once it stops being used, and a pin is that value set to never; it changes nothing a consumer can observe, because a pinned account and an unpinned one serve the same repository to the same readers. A write-only lock — frozen, quarantined — refuses writes and leaves the repository readable, which is not suspended (the account is not suspended from anything a relay can see) and not throttled (nothing is being slowed); a lifecycle StateChanged event carries it instead. Mapping either onto the closest-looking value would tell a relay something untrue.

desynchronized is likewise never sent, because nothing here produces the condition: a repository is derived from its records on every read, so it cannot be behind a commit it has already announced.

excludeIdentity, excludeAccount and excludeCommit #

Read at connect time, alongside cursor, and applied as a per-connection delivery filter. A frame of an excluded kind is not sent to that consumer and is not replayed to it either.

Sequence numbers are not renumbered per consumer. Every consumer shares one global sequence space whatever it excludes, so excluding a kind leaves visible gaps in the numbers a consumer sees — and that is the property that makes the parameter safe: a cursor that skipped past frames the consumer never received still names a point in the one stream, so reconnecting with it is neither an error nor a replay of what was excluded.

A value of false or 0 turns the exclusion off; the parameter present with any other value, or with none, turns it on. Anything unparseable reads as false, which sends the consumer more than it asked for rather than less.

com.atproto.sync.requestCrawl and notifyOfUpdate, called outward rather than served #

These are the two com.atproto.* methods in this project that are a client of, not routes it answers. A relay learns of a new personal data server by opening a subscribeRepos connection to it, and nothing makes a relay dial an address it has never heard of — requestCrawl is that announcement, and without it the subscribeRepos implementation above is a stream nobody is listening to. notifyOfUpdate is upstream's deprecated predecessor for the same request, kept because its own description names a recovery case requestCrawl's does not: resuming after a gap left by a dropped subscription. Neither is in ATPROTO_METHODS or auth::ROUTE_CREDENTIALS, and wire.rs's harness — which drives routes this server serves — does not exercise either.

didbot_serve::relay is the client: RelayClient::request_crawl and RelayClient::notify_of_update post the lexicons' identical {"hostname": ..} body to {relay}/xrpc/com.atproto.sync.requestCrawl and {relay}/xrpc/com.atproto.sync.notifyOfUpdate respectively. An operator asks for either explicitly, and the answer reports what the relay said rather than a log line the operator has to go find. Design decisions, each stated once and checked by a test in relay.rs's own module:

Question Answer
When does either fire? Only when an operator asks, over POST /dashboard/api/relay/announce — from the dashboard or from didbot announce. There is no startup announce and no timer.
What credential does it need? A session from this deployment's own operator sign-in, plus that session's CSRF token, exactly as the e-stop's own routes take. There is still no operator credential anywhere in didbot: the sign-in is atproto OAuth against the operator's own server, requesting atproto and nothing else, and what this server compares is the verified sub against its --operator.
What does the answer distinguish? Three outcomes: accepted, a relay that answered but refused (Refused, carrying the relay's status and body, covering HostBanned and anything else a relay might say), and a relay that could not be reached at all (Unreachable, carrying the transport error).
Which relay? [relay].hostname in didbot-config, or --relay-hostname directly — never hardcoded.
What about *.localhost? didbot-pds.rs leaves a loopback zone's relay unwired, whatever --relay-hostname says — the same Zone::is_loopback check that already gates --tls acme and the placeholder-secret refusal, because no relay can reach a loopback address.

What is verified without a real relay: the request body (relay::request_crawl_body) is asserted equal to {"hostname": ..}, which is exactly what both vendored input schemas require. RelayClient is exercised against a real, ephemeral loopback HTTP server standing in for a relay — not a hand-rolled mock of what reqwest does — covering a relay that accepts, one that answers a non-2xx status (the Refused path), and one that is simply not listening (Unreachable, the connection-refused path), for both request_crawl and notify_of_update.

What is not verified, and cannot be from here: everything about a real relay's actual behaviour — whether Bluesky's own relay, or any other, accepts this server's announcement, opens a subscribeRepos connection against it, and advances past the initial cursor. That is federation's question, not this suite's, and needs a live deployment and a real relay to answer. There is also no durable, public alerting for either call's failure — an operator sees the outcome in the moment they asked, over the socket, but nothing records it past that; plan/alerts.md is where a repository-backed version of this would eventually live, and this branch does not build it.

There is no reverse of either call. No com.atproto.* method un-announces a host; a relay that should stop crawling this server is de-listed out-of-band, by whatever process that relay runs, not by anything this server can send it. This is the same asymmetry the hard-delete note above records one layer down: this server has no way to make a relay that already crawled it forget that it did.

Everything upstream ships, and the one line that is refused #

Every directory in the interop corpus is vendored and every file in every one of them is run. There is no suite waiting on anything, and this section used to be a list of four.

mst/ was on that list as blocked on a Merkle search tree, which was true of the directory and true of none of the files in it: everything upstream ships there is a question about a pure function. data-model/ was blocked on an encoder. crypto/'s P-256 half was blocked on a second curve. And firehose/ was left out on the reading that it tested a frame format — the one reading that was simply wrong, since its only file is a set of tree commit proofs and has nothing to do with how a frame is packed. That mistake cost the strongest available check on the tree for as long as it stood, which is the argument for vendoring a directory before deciding what is in it.

com.atproto.sync.subscribeRepos is served, and no vendored vector asks about it — see the section below for what it carries and what it does not.

One candidate does not pass, and it is upstream's file disagreeing with upstream's specification rather than this server disagreeing with anything. nsid_syntax_valid.txt holds an NSID whose domain authority is 283 characters; the NSID specification caps an authority at 253, which is the DNS limit, because the authority is a hostname with its segments reversed. Accepting it would mean accepting a lexicon namespace under a domain that cannot exist. Upstream's own README says the specs are the authority where the two conflict and that such files should be corrected, so the candidate is excused by name with that reason attached, and two things fail if it ever moves: the excusal goes stale if the line leaves the file, and a separate test measures the authority and fails if it comes back within the cap.

What the lexicon/ vectors check is unchanged by the collection set opening. They ask whether a record satisfies a document, and every one of them supplies its own document — so they run against the explicit path, which is what a held lexicon still gets. The stance decides which records reach that path, not what it does with them. What no vendored suite asks about is the other branch: a record stored under a lexicon nobody here holds, which is checked instead by the round trip through getRecord and by the bytes in the export (export::tests::a_foreign_record_is_in_the_tree_byte_for_byte).

lexicon/, syntax/cid_*, syntax/datetime_*, syntax/language_* and syntax/uri_* were on this list until record validation arrived. Each was blocked on the same thing: there was no boundary at which a record's fields were checked, so there was nowhere for a datetime or a language tag to be wrong. cid is the odd one out and is worth stating plainly — this server now emits content identifiers, in a CAR export, over a signed commit, but not on the write path: RecordStore::put still returns an rkey alone. It validates a client's CIDs because a client can write one into a record, which is a different question from whether we mint any — and that is why the suite still runs against the lenient string check in [crate::pds::format::cid] rather than against the strict decode in [crate::data::Cid], which refuses every multibase but the one an encoder can reproduce byte for byte.

datetime_parse_invalid.txt and language_parse_invalid.txt are a third category upstream, and worth naming because the distinction outlives the two files. They hold candidates whose syntax is fine and whose meaning is not: 1985-13-12T23:20:50.123Z is a well-formed timestamp in month thirteen, and de-DE-1901-1901 is a well-formed language tag that repeats a variant subtag. A parser that only checks shape will accept both, and a suite that ran only the syntax files would look green while it did. Suite::also_invalid names the extra file, and both validators run against all three of theirs.

A second corpus: sixteen thousand tree diffs #

Everything above is Bluesky's corpus. There is a second one now: David Buchanan's mst-test-suite, which asks the question the official vectors do not. Given two Merkle search trees, what changed?

That is the question a repository server answers on every commit. A firehose message is a diff — these nodes were created, these were deleted, these records changed, and here are the blocks that prove it — and nothing here checked such an answer against another implementation. The official corpus cannot: it has one file about whole trees, firehose/'s commit proofs, and six cases in it.

This corpus has 16,384. Upstream mined seven record paths whose heights build a perfect binary tree, deleted every subset of the seven to get 128 trees, and made a case out of every ordered pair. The awkward shapes are not rare in there. A node splits, two nodes merge, the root changes layer because the only key holding it up was deleted, the tree empties, the tree fills. A suite written by hand holds the cases its author thought of.

Each case names two CAR files and lists five results. Four are checked in crates/didbot/tests/conformance/mst_diff.rs:

Field What it is
created_nodes node blocks the second tree has and the first does not
deleted_nodes the same the other way round
record_ops the records that changed, with the value on each side
proof_nodes the blocks proving those changes against the second root

The fifth, inductive_proof_nodes, is checked in mst_inversion.rs and is described below. firehose_cids is checked by nobody: upstream sets it to the string TODO in every case, and the refresh script refuses to vendor a corpus where that has changed without a runner to read it.

What it took to run one case at all #

Three things this workspace did not have, because everything before this wrote blocks and never read one back.

A DAG-CBOR decoder, [crate::data::dag_cbor::decode]. It is a stricter piece of code than the encoder beside it: plain CBOR has five widths for an integer, two length forms for every string and any order for a map's keys, and all of them decode to something. A decoder that accepted them would hand back values whose re-encoding is a different block with a different CID, which is a peer handing this server two spellings of one record and getting two identities for it. Every non-canonical form is an error here, and the tests assert the round trip rather than the reading.

A CAR reader, didbot_repo::car::read. It hashes every block and checks it against the CID it was filed under, because a CAR file is exactly the claim that these bytes have those names.

And a tree that can be loaded rather than built. [crate::data::mst::Tree] is this project's own tree: a pure function of a whole leaf set, immutable, sure of its own blocks. [crate::data::mst::Store] is the other situation — a pile of blocks that arrived from elsewhere and a root somebody claims. Nothing in it is trusted, so every walk over it can fail: a link may name a block that is not there, a subtree may claim a layer it is not on, a node may not decode.

The rule that decides how big a proof is #

proof_nodes is one walk per changed path over the tree after the change: an inclusion proof for a record that is now there, an exclusion proof for one that is now gone. The walk stops as soon as it is below the layer the path's own height puts it on.

That early exit is not an optimisation. A path can only ever be an entry at the one layer its hash puts it on, so a walk that carried on would be reading blocks that cannot hold the answer, and the exclusion proof made of them would be larger while proving the same thing. Removing the rule and running the suite fails 304 cases — all of them proofs that are correct and too big.

Running a commit backwards #

inductive_proof_nodes is the odd field, because it is not a claim about a diff. It is a claim about what a verifier has to read.

Bluesky's sync iteration proposal has a firehose consumer check a commit without holding the repository: take the tree after the commit, invert every operation in the message — a create becomes a delete, a delete becomes a create of the previous value — and require the root it ends on to be the root the commit says it came from. The blocks that inversion reads are the blocks the producer had to put in the message, and that set is what the field records. It is not derivable from the diff. Which nodes get read depends on how a tree rewrites itself when a key is inserted into a full node or removed from one that then merges with its neighbour.

Checking it needs a tree that can be mutated one operation at a time, which this workspace deliberately does not have. [crate::data::mst::Tree::build] is a pure function of a leaf set on purpose: a second way to arrive at a root is a second thing that can be wrong about it, and a repository this size gains nothing from an incremental path. So the rewriter lives in the test that needs it, mst_inversion.rs, and not in the crate, where it would be a write path nothing writes through.

It is written to match upstream's atmst step for step — where it descends, where it splits and merges, and which nodes it reads on the way — because the read set is what is being compared. Two rewriters can reach the same root having read different blocks, and which of those sets is minimal is not a question these vectors answer. The test asserts both things anyway, and in that order: the root first, because that is the property the proposal specifies and a rewriter that lands elsewhere has verified nothing, and the read set second, because it is what catches a rewriter that is correct and reads the whole tree to be so.

Why this corpus is not vendored #

Everything else this suite reads is in vendor/, and the argument for that is at the top of this page. It does not extend here. The interop vectors are 96kB and the lexicon documents 81kB; this corpus is 16,384 files and 24MB, which is more than the rest of this repository put together. Carrying it would make every clone, every search and every pull request diff pay for a test corpus — and the forge this project lives on refuses a pull request whose diff is larger than it will send, which is how the size stopped being theoretical.

The offline argument that justifies vendoring the interop vectors does not carry the weight either. Nothing runs this suite unattended: there is no CI here, so the only thing that runs it is a person at a terminal, and that person has a network.

So scripts/fetch-mst-vectors.sh clones the suite, and the runner reads it in upstream's own layout — walk tests/ for .json files and assume nothing about the arrangement, which is what upstream's README asks a runner to do.

It is cached per machine, at $XDG_CACHE_HOME/didbot/mst-test-suite, beside the configuration and state this project already keeps under the same directories. Per checkout was the obvious place and the wrong one: the corpus is immutable upstream data that varies with nothing, this project is worked from several worktrees at once, and each of them wanted its own 72MB and failed four tests until somebody fetched one. A clone left in a checkout's own .cache/ is moved rather than re-downloaded on the first run. MST_TEST_SUITE points at a corpus of your own and wins over both.

One clone shared by every checkout means a plain run that fast-forwarded it would move the corpus under whatever else is reading it, so a corpus already there is left alone and reported. --update, or a ref, changes it on purpose. The CAR files are read from the same clone: 128 of them, 63kB, one tree apiece and no record blocks in any of them, because a diff never looks inside a record.

A corpus that is not there fails the tests that need it, naming the script to run. It does not skip. A test that passes without having checked anything is the failure this whole page exists to prevent, and it is worse than a red line telling somebody to run one command.

Adding a suite #

One line, if a validator exists: Suite::new("cid").run(cid::validate), where the name is the vector files' shared prefix and the argument is anything from &str to a Result. If a validator does not exist, that is the work — and it belongs in the crate that owns the concept rather than beside the test, or the boundary that actually needs it still has nothing to call.

Suite::run reports every failure in one panic, naming the file, the line, the candidate and what the validator said. A vector file is a few hundred near-identical strings, so a report that says handle_syntax_invalid.txt:52 is worth a great deal more than one that says assertion failed.

When a vector fails #

A failure is a finding about this implementation, not a problem with the harness, and the first move is to fix the implementation. Where a vector genuinely does not apply, Suite::excused takes the candidate and a reason, and both appear in the source. Deleting a line from a vendored file would make the copy diverge from upstream silently and turn the next refresh into a conflict nobody can adjudicate.

There is one excused candidate today. nsid_syntax_valid.txt contains an NSID whose domain authority is 283 characters; the NSID specification caps the authority at 253, which is the DNS name limit. Upstream's own README says the specification wins where the two disagree, and accepting the candidate would mean accepting a lexicon namespace under a domain that cannot exist.

Whitespace is a test case #

Several vectors are invalid for no reason other than a leading or trailing space: one.two.three is in nsid_syntax_invalid.txt on that basis alone. The whitespace-fixing hooks in prek.toml therefore exclude vendor/, and the runner never trims a candidate. A fixer reaching those files would edit test cases into passing, and the diff would look like tidying.