From 98aaa7729a8cb1a47537c0287d9d8632cb637bf9 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 16 Sep 2026 18:06:52 -0400 Subject: [PATCH] docs(ownership): describe creating accounts beneath a creator The two procedure pages say what createAccount, createSession and parkKey take and refuse, and how a lapsed record propagates. Co-Authored-By: Claude Fable 5.1 Change-Id: I47f6ff5ee4b0ab7efd2ec0ec0228d048dd93930e --- docs/operator-verification.md | 55 ++++++++++++++++++++----------- docs/running-locally.md | 62 +++++++++++++++++++++++++++++++---- 2 files changed, 91 insertions(+), 26 deletions(-) diff --git a/docs/operator-verification.md b/docs/operator-verification.md index 485b40c0..56a59c10 100644 --- a/docs/operator-verification.md +++ b/docs/operator-verification.md @@ -10,16 +10,21 @@ and the section after them says where each one runs. | Record | Lives in | Written by | Says | | --- | --- | --- | --- | -| `bot.did.registration` | the agent's repository, at `self` | the hosting server, at provisioning | `operator`: the account whose repository holds the `bot.did.operator` record for it. `kind`: what it is. | -| `bot.did.operator` | the operator's repository, keyed by the server's hostname | the operator, with their own key | `subject`: the server. | +| `bot.did.registration` | the account's repository, at `self` | the hosting server, at creation | `operator`: the account whose repository holds the `bot.did.operator` record for it. `kind`: what it is. `label`: what its creator called it. | +| `bot.did.operator` | the operator's repository, keyed by the hostname of the account it names; a hosted account's repository, written by the server as that account when it creates beneath itself | the operator with their own key, or the server as the creator | `subject`: the account operated. | | `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 +The first two are what the verifier checks, one edge at a time: an +account's registration names its operator, the operator's repository +holds the record naming the account, and the walk repeats from the +operator until it reaches a repository the server does not host — the +human's. The last two are how the operator governs what their accounts +may do: a policy only ever denies, and a binding applies this +repository's policies to its subjects, to every account created beneath +them, or both, minus the excluded accounts and everything beneath those. +The action `did.write` is a creation, a document change or a deletion +beneath the principal. 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 @@ -29,12 +34,13 @@ widening. It publishes a digest of the records it enforces as 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. +Neither party can write the other's. An account cannot write its own +registration or the operator records in its repository — +`bot.did.registration` and `bot.did.operator` are server-authored and +every write path refuses them — 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 @@ -42,12 +48,15 @@ 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. +1. The account'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. + This is the server's statement, and on its own it proves nothing. For + an operator hosted on the same server, the operator's own + `bot.did.operator/` record and registration are read the + same way, and the walk continues from there until the operator is a + repository the server does not host. 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. @@ -110,7 +119,13 @@ two readers, with two different bounds. `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 +and five minutes as this deployment ships. The same poll re-reads the +operator's record for every account the operator created directly, and +quarantines such an account and everything beneath it on the same +schedule; a creation beneath it re-reads the record first, so a deleted +record stops new accounts within a minute. An account beneath a hosted +creator stops when the creator's record for it is deleted, which only the +server does, when the account is erased. 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 diff --git a/docs/running-locally.md b/docs/running-locally.md index f321bd25..c78528f9 100644 --- a/docs/running-locally.md +++ b/docs/running-locally.md @@ -421,7 +421,7 @@ for no visible reason. ## What the log shows Provisioning is a sequence, and each step is logged with the agent and its -identifier as structured fields rather than interpolated text: attestation +identifier as structured fields rather than interpolated text: creator checked, identifier minted, keypair generated, DID document built, DNS record published, account stored, lifecycle event emitted. @@ -522,9 +522,11 @@ between compactions. ```sh curl -s localhost:3000/health +# Under .localhost a request with no credential creates beneath the +# server's own account; see "Creating accounts" below for a real zone. curl -s -X POST localhost:3000/xrpc/bot.did.createAccount \ -H 'content-type: application/json' \ - -d '{"accountId":"scratch"}' + -d '{"name":"scratch.agents.localhost","kind":"agent"}' curl -s --resolve 'scratch.agents.localhost:3000:[::1]' \ http://scratch.agents.localhost:3000/.well-known/did.json @@ -740,7 +742,55 @@ checked the stream from the outside. A commit trail is bounded, so a `since` this deployment has trimmed away is answered with the whole repository rather than with a diff. -Under `.localhost`, `bot.did.createAccount` mints for a request carrying no -attestation claim, and the account's registration record says -`self-asserted`. Any other zone refuses such a request as -`AttestationRequired`. +Under `.localhost`, `bot.did.createAccount` creates for a request carrying +no credential, beneath the server's own account. Any other zone refuses +such a request as `AuthenticationRequired`. + +## Creating accounts + +Every account is created beneath a creator, and the creator is whatever +authenticates *as* an account. `bot.did.createAccount` takes +`{"name": "", "kind": "service" | "host" | +"pipeline" | "agent", "label"?, "key"?, "oidc"?, "parkedKey"?}` and a +`Bearer` credential in one of three shapes: + +- an account token this server issued, for the account it names; +- a JWT `{iss: , aud: , lxm: + "bot.did.createAccount", exp, jti}` signed ES256K or ES256 by a key the + creator's document names — `#key-1` for a host holding its own key, or + `#atproto` for the operator, whose own PDS mints it as a service-auth + token. `exp` is at most ten minutes out and each `jti` is spent once; +- an OpenID Connect ID token whose `aud` is this server's DID, from the + issuer a hosted account's `#oidc` entry names and carrying every claim + it lists. Only public HTTPS issuers, or the ones `[oidc].issuers` + names, are read. + +A creator hosted here becomes the parent: the server writes +`bot.did.operator/` into the creator's repository as the creator, +and `bot.did.registration/self` into the new account naming the creator +as `operator`. The one creator not hosted here is the operator this +server was launched with (`--operator`): the server reads +`at:///bot.did.operator/` from their PDS and refuses with +`OperatorRecordMissing` when it is absent or names another account. The +response is the DID, its document, an account token and `operator`. + +`bot.did.createSession` takes the second or third shape naming a hosted +account and answers `{"did", "accountToken"}`: a session as that +account, which is how a pipeline run becomes its pipeline. Sessions +minted this way last `[sessions].proof_ttl_secs` (default an hour), and +an account holds up to `[sessions].live_per_account` (default eight) at +once. + +A machine that will hold its own key parks the public half first: +`bot.did.parkKey` with `{"name", "kind", "key"}` answers a fingerprint +and an expiry, `bot.did.describeParkedKey?name=` shows the operator +what is waiting, and the operator's `createAccount` names it as +`parkedKey`. A park lasts `--park-key-ttl` seconds (default 600), one +per name, and both routes are bounded per caller address by +`--park-key-rate-limit` per `--park-key-rate-window`. + +Creating is allowed by default. A `bot.did.policy` on the action +`did.write` refuses; a deny is a 403 `PolicyViolation` carrying the +policy's reason. Independent of policy, `[tree]` bounds what one creator +may do: `max_depth` edges from the operator (default 3), `max_children` +per creator (default 64) and `creates_per_hour` (default 60). -- 2.51.2