Identities for entities did.bot
agent llm did
didbot docs operator-verification.md
8.5 kB

Verifying who an agent belongs to #

A stranger meets an agent and wants one thing: is there a human answerable for this, and can that human withdraw it. Answering it takes two records written by two parties, checked against each other by somebody who trusts neither. Every check below is a read of a public record or a DID document, and the section after them says where each one runs.

The records #

Record Lives in Written by Says
bot.did.registration the agent's repository, at self the hosting server, at provisioning owner: the party accountable. operator: the server itself.
bot.did.operator the operator's repository, keyed by the server's hostname the operator, with their own key subject: the server. subjectKey: the key it publishes.
bot.did.policy the operator's repository the operator, with their own key actions: which requests it is consulted on. document: what it refuses, in the engine its $type names.
bot.did.policyBinding the operator's repository the operator, with their own key policies: which, by AT-URI. subjects, includes, excludes: whom.

The first two are what the verifier checks. The last two are how the operator governs what their agents may do: a policy only ever denies, and a binding applies this repository's policies to its subjects, to every account admitted beneath them, or both, minus the excluded accounts and everything beneath those. The policies a binding names must currently be in the same repository as the binding; a reference into another repository is not supported yet. A server builds the whole set from one reading of both collections, and a set any record spoils is refused whole, so a server that cannot read or build its policies keeps the last set it built rather than widening. It publishes a digest of the records it enforces as policy.enforced on bot.did.stats, so whether it is obeying the operator is a string comparison against the same digest computed over the operator's repository.

Neither party can write the other's. The agent cannot write its own registration — bot.did.registration is server-authored and every write path refuses it — and the server cannot write into the operator's repository, because it holds no credential there and never has. That asymmetry is the whole design: a compromised server can stop being claimed, and cannot claim itself.

Every record in the operator's repository is the operator's own: nothing in this project writes there. A binding is how the operator speaks about an agent, and it is still the operator's record, not the agent's.

The checks #

  1. The agent's DID document, resolved directly, says where its repository is through its #atproto_pds endpoint. Where the repository is, not where anyone says it is.
  2. bot.did.registration, read from that repository, names the operator and the server. This is the server's statement, and on its own it proves nothing.
  3. The server's DID document, resolved directly, gives its endpoint and its current #atproto signing key. com.atproto.server.describeServer on that endpoint has to name the same DID the document does.
  4. The operator's DID document, resolved directly, says where the operator's repository is. A repository served by the same server the claim is about proves nothing: one party is then serving both halves, and their agreement is not evidence.
  5. bot.did.operator, read from the operator's repository at the record key that is the server's hostname. The key is derived from the server's own DID (AgentDid::authority), never accepted from anybody.
  6. The two agree: the claim names that server, binds the key that server publishes now, and has not expired.

A pair only one side states fails, in both directions. So does a claim binding a key the server has since rotated away from.

What is never consulted: the server's own #service registration record, whose owner field this server sets from its own poll, and any endpoint that would answer "who operates me". Both are a server's word about itself. A check built on either passes for a server that simply says the right thing.

What sits underneath and is not checked here: the operator delegated the zone the server's DID resolves under, the server proved control of that zone through ACME DNS-01, and TLS proves the connection reaches the holder of the resulting certificate. The key comparison is a consistency check on top of that, not a substitute for it.

Where the checks run #

The reads in steps 3 and 5 — the document, the describeServer cross-check, the signing key and the record key — are one crate, didbot-claim-check, so every caller asks the same questions and writes the same record:

  • didbot operate --check <hostname>, on the operator's own machine, runs them over a real network: it resolves the hostname, opens HTTPS to it, reads its did:web document and cross-checks describeServer, then reports which of those a stranger can do. didbot operate <hostname> <operator> writes the operator record at the derived key once they pass.
  • The policy site's onboarding run, in a browser over fetch, runs the same steps and then two more: claim-written, whether the operator record stands in the operator's repository, and claim-observed, whether the server's own poll has found it. A check a browser cannot make — the certificate — is reported as not made, never as passed.
  • The server itself reads step 5 on a timer, from the operator's repository at the same derived key, and pauses when the record is gone; see below.

bot.did.registration is read the way any record is, with com.atproto.repo.listRecords against the agent's repository; running locally shows the request.

Revocation, and how long it takes #

An operator revokes by deleting their bot.did.operator record, or by letting its expiresAt pass. There are two readers, with two different bounds.

The server itself polls the operator's repository every DEFAULT_POLL_INTERVAL and pauses once DEFAULT_GRACE_WINDOW has elapsed with no claim found. The pause therefore lands no later than the grace window plus one interval after the record stops being readable — six hours and five minutes as this deployment ships. The grace window is not slack: a server cannot tell "the operator revoked" from "the operator's own PDS is down", so it waits before treating one as the other. A shorter window converts an outage into a stopped fleet; a longer one makes revocation decorative.

A third party is not polling anything, so there is no window: each read of the two records starts from nothing, and a revocation is visible to the next one. A reader that looks hourly sees it within the hour.

DNS is not the mechanism for any of this, and cannot be. A deleted agent's name stays cached for as long as its record's TTL, and a resolver that answers from cache is not evidence of anything.

The honest limit is that a verdict is about the instant it was made, which is why every one carries the time it was checked. A verdict already read cannot be un-read. Nothing an operator does afterwards reaches a copy of it, and anyone acting on a week-old confirmation is acting on history.

Enumeration is a lower bound #

"Which agents does this operator have" is derived, not authoritative. It comes from bot.did.listAgents — one server's account of its own contents — and each DID in it is then checked against the operator's own record. So the count is a floor, and four things push it below the truth:

  • The server can omit an agent from its listing. Nothing in the operator's repository contradicts it, because the operator writes one record per server and not one per agent.
  • A deployment may close the listing. An empty answer then means nothing about how many agents exist.
  • An operator may run several servers. A survey asks one; there is no directory of the rest.
  • A listing is a moment, and agents are provisioned and deprovisioned.

What a server cannot do is inflate the count. A DID it invents and lists has no bot.did.operator record behind it, so it is reported as unconfirmed rather than counted. Containment is the other half: a server cannot mint an agent outside the zones it has been delegated, so the agents it can fabricate are at least confined to names it already answers for.