Identities for entities did.bot
agent llm did
didbot docs server-lifecycle.md
8.8 kB

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 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-claim 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::ownership, 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-claim 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 — DNS resolving, TLS terminating, the did:web document serving, describeServer agreeing — is checked by didbot_claim::preflight, on the operator's own machine, and reported by didbot-claim --check. 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.