Identities for entities did.bot
agent llm did
didbot plan onboarding.md
21 kB
Markdown
at commit 18ba4fe0


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.operator handshake — 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/0600 permissions 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 to http://localhost:3000 when it is unset, and didbot-setup never writes it — its whole flow is built and tested against didbot-pds's own defaults. Pointing a harness at a deployed server is presently "set an environment variable by hand, correctly, on your own," with no didbot-setup step that asks for a URL and writes it anywhere. There is no secret to set alongside it: provisioning takes the host's claim, which didbot-agentd signs.

  • 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.