--- id: labels title: What is true about an agent arrives as a label an app already draws status: open crates: [didbot-pds, didbot-identity, didbot-serve, didbot-lexicon] dependsOn: [pds-writes, ownership] exitCriterion: > The operator subscribes to this server in a client, and that client draws, on an agent's account, that it is automated and whose it is — from labels the server signed as its own first account and served itself. --- # labels Everything this system knows about an agent is a record in a repository. A reader who wants any of it has to know the collection, fetch it, and decide how to draw it. Nothing does that. A label is the one statement about an account that existing clients already render without being taught: a subscriber's client puts it on the profile and on every post. So this epic buys reach, not assurance. The facts do not become truer for being signed a second time — a label from a service we run is our claim, exactly as `bot.did.registration` is. What changes is that somebody else's software shows it to somebody who never asked us anything. ## The server is its own first account The whole thing is one stack: the server holds the accounts, is an account itself, signs the labels, and serves them. The operator subscribes to it the way they would subscribe to anyone's labeler, and their ordinary client draws what this deployment knows about the agents it minted. That resolves the earlier open question about whose key signs. Not the operator's personal account, and not a separate service beside the index — the server's own DID, which already exists and is already reserved. For a deployment at `foo.example`, all five of these hold at once, and four are already built: - **`foo.example` is the PDS.** The URL an operator types. - **`foo.example` is a DID this server manages and no account can take.** `did:web:foo.example` is what `Zone::service_did` returns, and [`did_document`](../crates/didbot-pds/src/provision/mod.rs) answers for it before it scans accounts. The reservation is structural rather than a blocklist: an agent hostname is always a label plus the zone, so it is strictly below the zone and never equal to it. - **`foo.example` is the labeler.** Two additions to that document, an `#atproto_labeler` service entry and an `#atproto_label` verification method. - **Agents are its immediate subdomains.** `Zone::new` in [did.rs](../crates/didbot-identity/src/did.rs) is exactly this layout, and is the constructor a delegated subzone is the variant of. - **One certificate covers the apex and `*.foo.example`.** Both names are needed, because a wildcard does not match the bare domain it is under. What is missing is the account. The apex DID today has an empty `verificationMethod` and no `alsoKnownAs`: it holds no key, no repository and no handle, because nothing has needed it to. A labeler needs a declaration record at `self`, which needs a repository, which needs a signing key. ## What this shape gives up, and why that is acceptable here Recorded because it is a real cost and the decision was to pay it. - **The apex goes from holding no keys to holding two**, a repository signing key and a label signing key, on the host that already holds the custodial agent keys. [deployment](../docs/deployment.md) separates hosts so that a compromise is bounded; this puts one more thing inside the same boundary. It is bounded by what that host could already do: whoever holds it can be any agent under the zone, so being able to sign labels about them too is not a new class of lie. - **`did:web` has no audit log.** Whoever serves the apex `did.json` can replace the label signing key, and a subscriber cannot tell it ever changed. A `did:plc` keeps a rotation history a stranger can audit. This is the argument for the ecosystem labeler below being a `did:plc`, and it is not an argument for this one: a reader subscribing to a deployment's labeler is already trusting that deployment for the accounts themselves. - **A flat namespace leaves the DNS credential nothing to be scoped to.** Deployment says to narrow it to the subzone. With agents at `*.foo.example` there is no subzone, so the credential that mints agent names can also repoint the apex, its document and therefore the labeler's key. Delegating `agents.foo.example` is what buys that back, and it is available whenever it is worth the second name. - **One certificate for both names is a choice, not a requirement.** The rule that a wildcard must not shadow the server's own name still holds — `*.foo.example` cannot serve `foo.example`. Putting the apex in the same subject-alternative-name list hands that separation back. A second issuance for the apex keeps it, and costs one certificate. ## Why not the operator's own account Asked and answered no. An operator *can* make their personal DID a labeler: a `did:plc` gets the service entry and the label verification method through `requestPlcOperationSignature`, `signPlcOperation` and `submitPlcOperation`, which their own server signs for them. The reasons not to are what that turns their account into. - **They acquire a moderation inbox.** `app.bsky.labeler.service` is also a report destination, and clients offer it to subscribers. Declarable away with an explicit empty `reasonTypes` — empty is distinct from absent, which means every reason — but the default is to receive reports nobody is staffing. - **Their profile becomes a service.** A labeler DID renders as a moderation service with a subscribe control rather than as a person. - **One labeler per DID.** One service entry, one declaration record, one vocabulary, for as long as they hold the identity. - **The DID document change is in the PLC audit log permanently**, and every label they ever sign points at that DID as its source. - **It only carries their authority if they hold the key.** If this stack holds it, or if the endpoint in their document points at our host, then the operator's labeler is our claim wearing their name — the asymmetry [policy-store](policy-store.md) exists to protect. ## The vocabulary is the constraint, not the plumbing - [ ] **Settle what gets a value before publishing one.** A label value is a short token (`[a-z-]`, declared with a locale string per value) from a set written into a record subscribers already hold. It is not a field and it carries no payload: "which repository this agent works in" is one declared value per repository, forever. Decide which facts are a small closed set — agent type, model family, harness — and which stay records in [provenance](provenance.md), which is where they live now. - [ ] **"This account is automated" may need no labeler at all.** A `bot` self-label in each agent's `app.bsky.actor.profile` says it to every client with no subscription and no service, and that is the cheaper half. Settle whether the labeler's job is only what a self-label cannot do — a third party saying whose the agent is — before building for the rest. - [ ] **Say what a label is not.** These are informational: severity `inform`, blurs `none`, and nothing here asks a client to hide an account. ## The work - [ ] **The `app.bsky.labeler.service` declaration at `self`.** The account exists and its repository is empty of this: a declaration is a vocabulary, and the vocabulary is the decision above. Writing one before that is settled publishes a set of label values to subscribers who then hold it. - [ ] **The two service entries and the label key** in the document `for_service` builds, which today carries one service and no keys. - [ ] **Sign and sequence labels.** DAG-CBOR signed with the label key, an append-only sequence, `neg` for retraction rather than deletion. - [ ] **Serve both endpoints.** Query answers a reader asking about named accounts; subscribe is the live sequence a client backfills with a cursor. The firehose in [index](index.md) is the closest thing built and the cursor semantics are not the same. - [ ] **Emit from the ledger the accounts already live in**, so a label and the record it came from have one source and can be checked against each other. - [ ] **The account-lifetime problem.** Agent accounts are ephemeral and a label sequence is append-only and permanent. Set `exp` from the account's expected life so readers age labels out without a retraction, decide whether a deleted account is negated or left to expire, and say what the sequence looks like after a year. - [ ] **Check what will resolve a `did:web` labeler**, and treat the answer as information rather than a gate. Nothing here federates yet and no relay carries these repositories, so the first subscriber is our own reader either way; whether a mainstream client will also resolve it decides how much of this is visible outside the deployment. - [ ] **Retraction reaches a reader on their next backfill, and no sooner.** Whatever [e-stop](e-stop.md) halts, it does not unsay a label already sequenced. ## Later, and separately: an ecosystem labeler One service saying that an account is automated, subscribable by anyone, across deployments. It should not be this server's apex: it is not this zone's server, and naming it after this zone claims a scope it does not have. Its own `did:plc`, for the audit log and because it is the resolution case every client already handles. Who operates an agent needs no label value of its own — a label carries its signer, so a statement from any operator's labeler already says whose the agent is, and a global service would otherwise need a declared value per operator that no client has a string to draw for. Not started, and not started until the stack above works end to end. ## Later, and separately: a public blocklist of every agent under did.bot The labeler says what an account is. It does not do anything about it — these labels are `inform`, they blur nothing, and a reader who has decided they want no automated accounts in their replies still has to act on each one by hand. The thing that answers that is a moderation list: one record per account, subscribed to once, applied by the subscriber's own client as a block. Offering it is the point of offering it. A server that mints agent accounts onto a public network and leaves everyone else to notice them one at a time is handing its costs to strangers. One subscription covering every account this project has ever minted is the smallest honest version of the opt-out, and it is a list somebody else's software already knows how to consume. - **A list, not a label.** `app.bsky.graph.list` with a `modlist` purpose, one `app.bsky.graph.listitem` per account, and the subscriber's own `app.bsky.graph.listblock` doing the blocking at their end. Nothing here blocks anybody. The list is our claim about which accounts these are; the block is the reader's decision about it, and stays revocable by them. - **Its own `did:plc`, and not this deployment's apex**, for the reason the ecosystem labeler wants one: `pds.did.bot` is a single zone and the claim is about did.bot as a whole, so naming it after one deployment claims a scope it does not have and leaves the key rotation unauditable. - **Being listed is not an accusation.** Every account on it is here because it is automated and this project minted it, which is the same fact the `bot` self-label and the registration record already state. Nothing is inferred about behaviour, and there is no path onto the list that runs through somebody reporting an account. - **It is not [e-stop](e-stop.md).** Halting an agent is something the operator does to their own accounts. This changes nothing on this side and takes effect only in the client of a reader who chose to subscribe. ### The work, once there is anything to list - [ ] **Settle what "all" can mean.** This stack knows the accounts it minted in its own zone. Delegated subzones, a second deployment, and anything self-hosted are known only through the vouch chain that [fleet](fleet.md) and [index](index.md) already walk — so the list is as complete as enrolment is, and the service has to say so rather than imply a census. - [ ] **Find out whether a subscriber can see it at all.** A modlist is records in a repository, and a client renders it only if the appview it talks to has indexed that repository. Nothing here federates yet and no relay carries these repositories ([federation](federation.md)), which makes an indexed host a precondition for this one rather than the preference it is for the labeler. - [ ] **Decide what happens to an entry when the account ends.** Agent accounts are ephemeral and a list has no `exp` to lean on the way a label does. Removing the item keeps the list the size of the live fleet and loses the record of a name that existed; keeping it grows the list forever with DIDs nobody can resolve. Whichever it is, find out what size of list a client will actually load before assuming either is free. - [ ] **Emit it from the same ledger the labels come from**, so the list and the labels cannot disagree about which accounts exist. - [ ] **Say who can ask to be taken off, and what that means.** An account that moves off did.bot, and a person insisting an account is theirs and not automated, are different requests with different answers. Both need one written down, because a public list with no way off it is a liability this project would be choosing. ## Done - [x] **The server is its own first account.** The apex DID has a signing key, a repository, a document carrying a verification method rather than only an endpoint, and a handle it both claims in `alsoKnownAs` and answers for at `/.well-known/atproto-did`. It is adopted once and is idempotent across restarts, because a regenerated key is not a rotation — it orphans every commit the account has signed. - [x] **It shares the agents' path and not their birth.** One key store, one repository builder, one document function, one sync surface, and one profile builder — the server's record is the same `app.bsky.actor.profile` every account here gets, drawn avatar and `bot` self-label included, with two placeholder fields a deployment replaces. What it does not get is the born-populated agent records: there is no provisioning to mirror and no attestation to record, so its `provenance` is absent rather than invented. - [x] **It is not an agent anywhere that counts agents**, and the operations that only make sense for one — deletion, the record switch — are refused by identity. - [x] **The apex is no longer claimable as a handle.** It sits at or below the zone, so nothing in the handle rules had stopped an agent from asking for the server's own name. An account holding it is what stops that.