# 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 ``` ```mermaid 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 was `claimed` a second ago starts here too, and answers nothing until it has looked — a `503` with a `Retry-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 and `ServerLifecycle::begin_bootstrap` is the only source of the `BootstrapPermit` a 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` on the operator's own machine, through `didbot operate --check`. The policy page checks the document and `describeServer` before it writes a claim. 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.