The server lifecycle #
didbot_pds::ServerState is what this deployment may do right now, as
distinct from AccountState, which is what any one account may do. It answers
the question nothing answered before it existed: is this deployment finished
being born, and is its public standing intact.
Everything below the marker is generated from the types by
didbot_pds::server_state::generated_docs and checked against them by
the_committed_diagram_matches_the_types. Regenerate it with:
UPDATE_DOCS=1 cargo test -p didbot-pds server_state
stateDiagram-v2
[*] --> booting
booting --> provisioning: nothing on disk: mint an identity
booting --> unclaimed: an identity on disk already
booting --> claimed: an identity on disk, and a claim already polled
provisioning --> unclaimed: keypair minted (one way)
unclaimed --> claimed: a poll found the operator's claim standing
claimed --> unclaimed: the claim stopped standing
| state | mints bootstrap keypair | serves reads | accepts record writes | provisions accounts | announces outward | may throw the e-stop |
|---|---|---|---|---|---|---|
booting |
no | no | no | no | no | no |
provisioning |
yes | no | no | no | no | no |
unclaimed |
no | yes | no | no | no | yes |
claimed |
no | yes | yes | yes | yes | yes |
What the states are for #
booting— we do not know what we have yet. Epistemic, not a health state: nothing about it says the deployment is unwell, only that this process has not read from disk what exists. It resolves on one store lookup. A restart of a server that wasclaimeda second ago starts here too, and answers nothing until it has looked — a503with aRetry-After, which is transient and honest, not a withdrawal.provisioning— the one-time mint of this deployment's own DID keypair. Entered once, ever, and never returned to: there is no un-minting a key whose signatures are already replicated, so the capability that opens the door exists in no other state andServerLifecycle::begin_bootstrapis the only source of theBootstrapPermita mint takes by value.unclaimed— has an identity, and nobody is answerable for it. Reads everything, writes nothing.claimed— an operator's claim stands, and everything is on.
The axis is reads against writes #
unclaimed is where most of a deployment's early life is spent and it is not
a dark state. Documents resolve, repositories serve, com.atproto.repo.*
reads answer, the firehose streams — exactly as in claimed. What stops is
the creation of new data: no agent accounts, no records, no blobs, no outward
announcements.
That is the honest boundary. A server nobody has claimed still holds whatever
it held a minute ago, and withdrawing reads would protect nothing — it would
break every consumer of data this server already published, and for a
did:web document, which has no audit log, retroactively break every
signature made under it. What an unclaimed server must not do is accumulate
more of what nobody is answerable for.
Never-claimed and lapsed are one state #
There is no distinction between "an operator has not claimed this yet" and
"an operator's claim stopped standing." Both are unclaimed, and the
recovery from both is identical: run didbot operate again. That is a feature
rather than a simplification — the bootstrap flow gets exercised on every
re-claim instead of once per server lifetime, so it cannot rot between the
two times anyone needs it.
The grace window separating a transient outage at the operator's PDS from a
real revocation still exists; it lives in didbot_pds::operator, which holds
the timestamps. By the time a poll reports to the lifecycle the question has
been answered, and what arrives is one boolean.
Why the handshake cannot deadlock #
didbot operate resolves the server's did:web document and cross-checks
com.atproto.server.describeServer before writing a claim. So the identity
must exist, and be readable, while the server is still unclaimed.
It does, structurally rather than by carve-out: the server's own document
and signing key are administrative writes that never travel the xrpc write
path. provisioning mints them directly against the stores, so the gate
governing accepts_record_writes has nothing to say about them, and the
identity is on disk before the machine ever reaches unclaimed. By the time
anyone can be waiting for a claim, there is something to resolve.
the_handshake_cannot_deadlock asserts it.
Entering claimed #
Only by this server observing a standing bot.did.operator record through
its own poll. Nothing tells it. bot.did.pollOperatorClaim lets a caller ask it
to look sooner than its timer would — urgency, never authority: no body,
no credential, no operator named, and its whole effect is to wake a task that
was going to run anyway. A server that took anybody's word for a claim would
be a server anybody could claim.
The bound is on this server's outbound rate rather than on inbound
requests. MIN_POLL_INTERVAL is a floor between two polls, and every nudge
arriving inside one window coalesces into the same single poll, so an
unbounded flood costs the operator's PDS at most one extra request per window
— which the timer was going to spend anyway. A rate limiter on the route
would bound how many nudges are accepted while still letting an accepted
burst multiply outbound requests, which is the wrong quantity to bound.
What is checked, and by whom #
The server knows two things about itself and checks nothing else: whether it holds its own signing key, and whether its own poll found a claim.
Everything external — the zone delegated, DNS resolving, TLS terminating, the
did:web document serving, describeServer agreeing — is checked by
didbot_onboarding, from wherever somebody is looking: the operator's own
machine through didbot operate --check, or the policy page. A
server checking its own DNS confirms what it wrote; a server checking its own
TLS is often checking a loopback path; a server checking its own did.json
proves it can talk to itself, and the failure worth catching (a different
deployment answering the same name) is invisible from inside the one that is
wrong.
Keeping a zone this deployment manages correct is a separate, standing
obligation in every state — a second state machine, written up in
plan/onboarding.md and not built here.
The e-stop is a different axis #
didbot_pds::Estop answers "should this deployment, whatever its standing,
halt right now." A server can be claimed and halted, and neither fact is
derivable from the other. What the lifecycle owns is whether the stop may be
thrown: an emergency stop halts something that is running, so may throw the e-stop is no before an identity exists. An earlier design expressed "not
finished provisioning" by throwing a boot-time Pause, which conflated a
lifecycle state with a halt — the latch stood for a condition no operator
could act on, on a server that had never served a request, and the stop
became untestable as a stop.
How a refusal reads on the wire #
Three conditions a relay has to tell apart, and they never overlap:
| condition | answer |
|---|---|
| try later | 503, Retry-After: 30, {"error":"ServerNotReady"} naming the state |
| broken | a refused connection, a 502 in front, or a 500 with no Retry-After |
| nothing here | 200 with an empty list or stream |
A refusal is never a 404: "this method does not exist" and "this method is
not ready" are facts a relay reacts to completely differently, and only one is
worth asking about again.
com.atproto.sync.subscribeRepos is refused before the WebSocket upgrade.
Accepting the upgrade and holding a silent connection would be
indistinguishable, from the relay's side, from a healthy server with nothing
to say — relays back off on a failed connect and do not back off on silence.
Reuse #
The machinery is didbot-fsm: states with a consumer-shaped capability
policy, transitions taken atomically under one lock, one-way capabilities
claimed with a permit issued in the same critical section that shuts the
door, and persistence as a hook the consumer supplies. This lifecycle is its
first consumer and supplies no persistence hook — both facts its state is
derived from already outlive the process (the signing key is in the store,
the claim is a record in the operator's own repository), and a stored
state: claimed would be a stored claim about somebody else's repository
that lies after the first restart into a deleted record.