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 #
- The agent's DID document, resolved directly, says where its repository
is through its
#atproto_pdsendpoint. Where the repository is, not where anyone says it is. 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.- The server's DID document, resolved directly, gives its endpoint and its
current
#atprotosigning key.com.atproto.server.describeServeron that endpoint has to name the same DID the document does. - 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.
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.- 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 itsdid:webdocument and cross-checksdescribeServer, 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, andclaim-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.