diff --git a/plan/labels.md b/plan/labels.md index 51510c87..5603f552 100644 --- a/plan/labels.md +++ b/plan/labels.md @@ -2,12 +2,12 @@ id: labels title: What is true about an agent arrives as a label an app already draws status: open -crates: [vibescrobble-index, vibescrobble-query, vibescrobble-serve, vibescrobble-lexicon] -dependsOn: [ownership, index] +crates: [vibescrobble-pds, vibescrobble-identity, vibescrobble-serve, vibescrobble-lexicon] +dependsOn: [pds-writes, ownership] exitCriterion: > - A reader who has never heard of this project subscribes to a labeler here, - and their client draws, on an agent's account, that it is automated and whose - it is — from labels the reader verified against a key we did not hand them. + 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 @@ -23,80 +23,154 @@ signed a second time — a label from a service we run is our claim, exactly as `zone.quernstone.agent.identity` is. What changes is that somebody else's software shows it to somebody who never asked us anything. -## The two labelers - -**One global, saying that an account is automated.** An agent account looks -like a person's account to every client in the network, and that is the fact -worth broadcasting first. Anybody may subscribe, including people with no -relationship to this deployment. - -**One per owner, saying whatever that owner wants said about their own -agents** — model, family, the repository it works in, the swarm it belongs to. -Subscribing to it is opting into one owner's vocabulary, which is the right -shape: nobody else has to carry it. - -Ownership falls out of the split rather than needing a label value of its own. -A label carries its signer in `src`, so a statement from an owner's labeler -already says whose the agent is. A global service that tried to say the same -thing would need a declared value per owner, edited into a record every time -somebody new arrives, and no client would have a string to draw for it. - -- [ ] **Settle the vocabulary before publishing one.** A label value is a short - token from a set declared in advance, with a locale string per value. It - is not a field and it carries no payload: "which repository this agent - works in" is one declared value per repository, forever, in a record that - readers have already subscribed to. Whatever the owner labeler ends up - offering has to survive that, and "arbitrary labels on your own bots" does - not survive it as stated. Decide which facts are a small closed set - (agent type, model family, harness) and which stay records - ([provenance](provenance.md) is where they live now). -- [ ] **Check that the ecosystem will subscribe to us at all**, before - anything else here. A labeler is discovered through a service entry in its - DID document and a declaration record in its repository; every account - here is `did:web`, nothing federates yet, and no relay carries these - repositories. If a mainstream client will not resolve a `did:web` labeler, - the render payoff is a demonstration against our own reader until - federation lands. This is a day's work to find out and it gates the rest. -- [ ] **Whose key signs the owner labeler.** If this stack holds it, the - "owner's labeler" is our claim wearing the owner's name, which is the - asymmetry [policy-store](policy-store.md) exists to protect. Either the - owner signs — the same posture as the site that writes their policy — or - the service is named for what it is, an operator labeler reporting what - the owner told us. -- [ ] **Where the service runs: the control plane, not the personal data - server.** Three reasons and one consequence. The facts are already - computed on the index side, from the firehose and the vouch chain, and a - labeler on the server would recompute them. A label signing key must not - sit beside the custodial agent keys and the zone credential - ([deployment](../docs/deployment.md) separates those hosts on purpose). - And the server is the party a reader is auditing; a labeler is the - reader's instrument. The consequence is that the labeler *account* can - still be hosted here — it is an ordinary account with a declaration record - — while the *service*, its endpoint and its key live with the index. -- [ ] **Emit from the index rather than at provisioning.** One subscription, - one derivation, one place where a label and the record it came from can - disagree and be caught. -- [ ] **The account-lifetime problem.** Agent accounts are ephemeral and the - label sequence is append-only and permanent. A thousand accounts a week - that each live an hour is a stream nobody wants to replay. 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. -- [ ] **Retraction, and how it reaches a reader.** A wrong label is negated, - not deleted, and a client that is not subscribed at that moment learns on - its next backfill. Whatever [e-stop](e-stop.md) halts, it does not unsay - a label that has already been sequenced. -- [ ] **Serve both label endpoints, and be honest about which.** Query answers - a reader asking about specific accounts; subscribe is the live sequence a - client backfills from with a cursor. The firehose work in - [index](index.md) is the closest thing already built, and the cursor - semantics are not the same. -- [ ] **Say what a label is not.** These are informational, not moderation - signals: nothing here should ask a client to blur or hide an account, and - the declaration record has to be written so it does not. +## 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 owner'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/vibescrobble-pds/src/provision.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/vibescrobble-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 owner's own account + +Asked and answered no. An owner *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 owner'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 server becomes an account.** A repository at the apex DID, a + signing key, a handle it claims and answers for at + `/.well-known/atproto-did`, and the `app.bsky.labeler.service` + declaration at `self`. Everything else here waits on 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. Ownership needs no label value of its own — a label carries +its signer, so a statement from a per-owner or per-operator labeler already +says whose the agent is, and a global service would otherwise need a declared +value per owner that no client has a string to draw for. + +Not started, and not started until the stack above works end to end. ## Done -Nothing. The epic is a decision first — the vocabulary, the key, and whether -anything in the wider network will resolve a `did:web` labeler — and no code -should land before those three are answered. +Nothing. Four of the five properties the layout needs are built, which is why +this is written as one epic rather than a proposal: what is missing is the +server's own account, not the naming.