id: onboarding title: An operator establishes a server before the server can establish anyone else status: open crates: [didbot-attest, didbot-pds, didbot-serve, didbot-setup, didbot-reconcile] dependsOn: [ownership, deploy] exitCriterion: > An operator takes a freshly applied deployment — instance up, no accounts, nothing configured beyond Terraform's defaults — through generating and installing the real secrets, an operator attestation step, and the first-run ownership handshake, then points one agent host's harness at it and gets a working, provisioned account, using only documented steps and no fact this file invents. #
onboarding #
Every other epic in this directory describes a system already running: an account exists, a zone resolves, policy is being polled. Nothing describes the hour before any of that is true — Terraform has applied, an EC2 instance is up, and the operator has not yet done anything the server would recognise as them. dev-setup covers the symmetric gap on the other side, wiring one machine's harness to a stack; this is the half it does not cover, and the half that has to happen first.
The bootstrap paradox #
A provisioning request carries a host's claim. bot.did.provisionAgent
mints only under a claim signed with the node key of a host the operator has
vouched for — see handshake. The key is generated on the
host and never leaves it, so there is no shared secret to distribute or get
wrong.
So the paradox is not what it was. It was: the secret that lets an operator act on a fresh deployment must reach the host before the deployment can be acted on. There is no such secret now, and the question that replaces it is narrower — a fresh deployment has to reach a state its operator can claim, using only what the deployment already has.
That question is answered elsewhere and this epic should not re-answer it.
The server mints its own identity through an administrative path that never
travels the xrpc write path, so its did:json and signing key exist before
it serves anything; the operator then claims it from their own machine with
didbot-claim, and the server observes that claim through its own poll. See
handshake and docs/server-lifecycle.md.
What onboarding still owes is the sequence, not the mechanism:
What "first log in" means here #
There is no human user account on this server, so "log in" is not the right frame and the epic should stop implying one. What exists instead:
--owner— a DID string the server writes into every registration record as who is answerable for that account. It is not verified at startup: any string parses.- The
bot.did.operatorhandshake — the operator writes a record into their own repository and this server reads it (crates/didbot-serve/src/ownership_poll.rs). Nothing is presented to the server and nothing is compared by it. There is no operator credential, and no HTTP surface is gated on one — see auth-types. - Local access to the box, which is what the e-stop admin socket's
0700/0600permissions amount to and the whole of that socket's authorization story.
Neither of the first two is the full handshake ownership asks for.
Ownership's own checklist says the agent-side owner claim exists (written by
the server at provisioning) and the human's vouch of the server is meant to
come from the owner's own repository — but ownership's "three statements"
item is still open, including "the server answers its controlling DID on an
unauthenticated status endpoint," which is the piece that would let a
stranger, or the operator themselves, confirm the server agrees who its
owner is. Until that exists, nothing on first boot confirms --owner names
a DID that has actually agreed to be named — the server just asserts it, on
one process argument, forever.
Required operator attestation #
The owner's framing names this explicitly, and it is worth being precise about what it is not: attestation is about the node an agent runs on — proving a machine before it is trusted with provisioning. The operator is a different subject. Nothing in this codebase today asks the operator to prove anything to the server over HTTP at all: the handshake runs the other way, with the server reading a record the operator wrote in their own repository. That is a claim about a DID rather than possession of a string, which is the right direction — but this server never checks a signature over a challenge it chose, so it is a claim it reads, not one it verifies interactively.
What an operator attestation step would need to establish, before the server mints its first account:
The first agent #
Once a server is up, the handshake has completed, and --owner is
established, a person still has to get one agent host talking to it.
didbot-setup does the machine half: it describes
which services this machine runs and says whether they are answering. What it
explicitly does not do, checked against its own source:
-
It never sets
DIDBOT_PDS_URL. A harness client falls back tohttp://localhost:3000when it is unset, anddidbot-setupnever writes it — its whole flow is built and tested againstdidbot-pds's own defaults. Pointing a harness at a deployed server is presently "set an environment variable by hand, correctly, on your own," with nodidbot-setupstep that asks for a URL and writes it anywhere. There is no secret to set alongside it: provisioning takes the host's claim, whichdidbot-agentdsigns. -
A session with no usable account provisions one on its first tool call, so once the variables above are set correctly by hand, that call is what actually creates the account — there is no separate "first login" moment to perform.
Failure modes worth naming #
These are not hypothetical: each is a documented gap or an enforced-but-silent default above, restated as what an operator actually sees when it happens.
Done #
-
Unlike `didbot-claim`'s checks it **acts**: it reads the zone, compares it against what this deployment published, and republishes the difference. What "wrong" means is decided per name — a missing record is republished, one pointing elsewhere is replaced, a **wrong TTL is left alone** (structurally: `RecordTarget` carries none, and lowering a TTL before a migration is ordinary practice), `TXT` values belong to the ACME renewal path, and the `NS` delegation above the zone is the operator's. A record it did not publish is **never touched**, and not by a check somebody remembered to write: `Repair` has `Publish` and `Replace` and no third variant, `Drift::Unattributed` yields no repair at all, and a failed read is a `ViewError` rather than an empty zone. It never gates serving either — `ZonePolicy` carries no capability that could, which a test asserts by destructuring it over every state. Bounded at five minutes with backoff doubling to an hour, two confirming reads before anything is written, twenty-five writes per tick, and five attempts per name before it is quarantined and reported. That last budget is the one aimed at people: somebody changing a record by hand gets argued with five times rather than forever. Between the interval and the confirmation count, drift is repaired five to ten minutes after it happens — this epic's answer to the "how long is propagation" question the items above kept deferring. It is also the caller that brought `didbot_fsm::Gates` back. A repair awaiting its confirming read is `Pending`, not `Failed`, and collapsing the two would make a healthy first tick look like an outage — which is exactly the signal the backoff is computed from. -
The axis is **reads against writes**. `unclaimed` — where a deployment spends most of its early life, and where a server whose claim lapsed returns to — reads everything `claimed` does and writes nothing: no agent accounts, no records, no blobs, no outward announcements. Withdrawing reads would protect nothing and would retroactively break every signature made under a `did:web` document this server has already served. Three things it makes structurally true. A second self-bootstrap keypair cannot be minted, because `ServerLifecycle::begin_bootstrap` is the only source of the `BootstrapPermit` a mint takes by value and it shuts the door in the same critical section. Never-claimed and lapsed are one state with one recovery — run `didbot-claim` again — so the bootstrap flow is exercised on every re-claim rather than once per server lifetime. And the emergency stop cannot be thrown before an identity exists: `ServerPolicy::may_halt` is false in `booting` and `provisioning`, so the boot-time self-pause that used to stand in for a lifecycle state is gone. -
They live there because a server checking its own DNS confirms only what it wrote, a server checking its own TLS is often checking a loopback path, and the failure worth catching — a *different* deployment answering the same name — is invisible from inside the one that is wrong. The claim flow runs the same function first, so a check that passes and a claim that then fails on it is a real race rather than two implementations disagreeing. -
Bounded on the server's **outbound** rate rather than on inbound requests. `MIN_POLL_INTERVAL` is a floor between two polls and every nudge inside one window coalesces into the same single poll, so an unbounded burst costs the operator's PDS one extra request — which the timer was going to spend anyway. `a_burst_of_nudges_produces_at_most_one_extra_poll` fires two hundred and asserts exactly one extra outbound read.