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. didbot-verify is that check, and it runs on the stranger's
machine.
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 owner's repository, keyed by the server's hostname | the owner, 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 owner'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.
What the verifier does #
- Resolves the agent's DID document itself and reads its
#atproto_pdsendpoint. Where the repository is, not where anyone says it is. - Reads
bot.did.registrationfrom that repository. This is the server's statement, and on its own it proves nothing. - Resolves the server's DID document independently, for its endpoint and
its current
#atprotosigning key. - Resolves the owner's DID document independently, and refuses if their
repository turns out to be served by the same server the claim is about
— one party serving both halves makes agreement between them worthless.
"The same server" is decided on the endpoint's origin, not on its
spelling: the default port for the scheme, a trailing dot on the host,
host case and a trailing slash are all normalized away first, so
https://agents.exampleandhttps://agents.example:443are one server and this refusal cannot be escaped by respelling it. - Reads
bot.did.operatorfrom the owner's repository, at the record key that is the server's hostname, derived from the server's DID rather than accepted from anybody. - Confirms 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 is refused, in both directions. So is 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 verifier built on either passes for a server that simply
says the right thing.
What sits underneath and is not checked here: the owner 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.
Revocation, and how long it takes #
An owner 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 owner'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: a run of
didbot-verify re-reads both records with nothing cached between runs, and
a revocation is visible to the next run. A verifier that runs 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 owner 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 owner 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 verified from the owner'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 owner's repository contradicts it, because the owner 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 owner 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.