--- id: account-types title: Not every account is a session status: open crates: [didbot-pds, didbot-lexicon, didbot-name, didbot-serve] dependsOn: [agent-accounts, provenance] exitCriterion: > A service account provisioned from a CI run keeps its DID and its name across runs, and a reader can tell from the record written at its birth what kind of account it is without asking this server. --- # account-types Everything here assumes an account is a harness session. A CI bot, a build machine or a long-running daemon needs an account too, and none of them has a session, a model or a parent. They are not agents and they still need somewhere to live. The first instinct is a two-way split, ephemeral against permanent. That is the wrong axis, and relaxing the pruning default is what shows it: if a session's DID stays resolvable for months anyway, both kinds are long-lived and the split distinguishes nothing. Three things vary independently instead. **Name provenance.** The server picked the name out of a pool, or the operator asserted it and it is theirs. **Renewal.** Admitted once at provisioning, or re-proving itself on every run. **Retention.** How long after the account stops being used it keeps resolving, and what "stops being used" means for something that is idle rather than gone. A CI identity is asserted, renewing, kept. A session is issued, admitted once, expiring. A cloud machine is asserted and renewing, and its retention is whatever that machine's lifetime turns out to be — cloud instances are disposable too, and sometimes they are not. So the kind picks defaults and the account carries the values, rather than the kind deciding behaviour directly. - [ ] **The three axes as fields, with the kind supplying defaults.** A kind that is an enum driving behaviour has to be reopened for every deployment that wants a variant. A kind that names a row of defaults does not. Two of the three landed: [`AccountKind`](../crates/didbot-pds/src/kind.rs) names the four members of the registration record's `actor` union and supplies a row of defaults, and `AgentAccount` carries a `nameProvenance` and a `retention` of its own. **Renewal** is not a field, deliberately: nothing renews, so it would be a stored value nothing ever writes a second time — an unenforced control. It lands with the mechanism, below. - [ ] **Whether an account may write its own profile is policy, and it is finer than any policy here.** [write-policy](write-policy.md) decides which accounts may write which collections; this asks which *fields* of one collection an account may set, in a lexicon this project does not own. Two things follow. The ceiling has to be an enumeration of fields, so one nobody has named is outside it. And a self-set `displayName` or picture is a visual impersonation surface inside this project's own dashboards, so whatever the default becomes, an interface showing one account to a human should show the handle the server issued beside the name the account chose. - [ ] **Create-or-attach, keyed on the reservation.** Provisioning is create-or-conflict today, and the idempotency lives in the hook's state file rather than on the server. A runner starting its four hundredth job asks for `nightly-builder` and gets the account that already exists. The reservation is the natural key for that, so this is not a second endpoint with a DID the caller has to have remembered. - [ ] **Retention as a per-account value, evaluated against last attestation for an account that renews.** A build machine can write nothing for six weeks and must not be swept, and it is proving itself the whole time. Keying a sweep on last write would collect exactly the accounts that are working correctly. The value landed — `Provisioner::sweep_stale` asks each account's `Retention` rather than one deployment-wide cutoff, and an account holding `Until` survives a window it does not use. The *clock* did not: there is no last attestation to read, so the kinds that would renew hold `Forever` instead of a duration measured against a clock that does not describe them. This closes when renewal does. - [ ] **Per-run accounts are not the granularity.** A CI account is per identity — this workflow, in this repository — and the run identifier is provenance on the records it writes. Minting an account per run spends a DID, a hostname and a DNS write on something nothing will ever resolve twice. - [ ] **`generation`, and a provisioning check that consults the ledger.** Provisioning refuses a duplicate by asking `store.get`, which knows live accounts only, so a deleted label re-mints into a fresh account at the same DID with a new key and an empty repository. The ledger is the right authority — it is keyed by DID and kept past the account it describes — and re-minting an identifier with a history should be refused unless an operator has released it deliberately. Release is legitimate: somebody retires a name and later wants it back for something unrelated. What must not happen is the identifier moving to a different account by accident, and `generation` is what makes it visible when it moves on purpose, to a reader that re-resolves the document rather than one that cached the key. - [ ] **Renewal is the attestation model changing**, and it belongs in [attestation](attestation.md): a claim is admitted once and spent, and an account that outlives its proof needs to make a new one. This epic needs that and does not own it. - [ ] **Issuing a credential to something with no harness** belongs in [credentials](credentials.md), which is built on `CLAUDE_ENV_FILE` and is session-scoped by construction. A CI runner has no hook to stamp anything, including the confirm call [oauth](oauth.md) relies on. - [ ] **Amend the trust model.** [The trust model](../docs/trust-model.md) rests on identity being the harness's to assert and never the model's. An account with no harness gets its identity from its attestation credential instead, so the sentence that guarantee is written in stops covering it. The boundary has moved rather than gone, and it should be written down rather than inherited. ## Done **Correction, not new work:** an item here ticked "the index learns a model from a record". There is no index in this repository — `didbot-index` was deleted when the reading side moved to vibescrobble.com — so nothing here learns a model from anything. The reasoning it recorded (the last turn to say anything is the honest source, rather than a copy on the profile) is sound and is kept by [provenance](provenance.md); the tick is not. - [x] **`pinned` is derived.** It was a boolean on [`AgentAccount`](../crates/didbot-pds/src/account.rs) standing in for a retention policy, and once retention is a value a pin is one of its settings — two mechanisms for one decision, and the one that lost would still be the one some caller was reading. `pinned` is a method over `Retention::Forever` now: `AccountStore` takes a retention rather than a boolean, unpinning names the kind's own default rather than clearing a flag, and a log written before this replays its boolean into the retention it meant. `AccountState` (provisioning/active/frozen/soft-deleted, see [agent-accounts](agent-accounts.md)'s Done section) is the lifecycle axis this epic asked for and stays its own field, independent of retention — a frozen or soft-deleted account can still be pinned or not, and the two answer different questions. - [x] **Decide whether a deployment may provision without an owner.** It may not silently. `owner` is a constructor argument on `Provisioner` and required by the registration schema, so a deployment that cannot name one does not compile — and the development server, which carries `did:web:owner.invalid` so that trying the stack does not require a human DID, warns at startup that nobody is accountable for the accounts the run mints. Saying it in a doc comment beside the constant was not the deployment saying it; the person looking for somebody accountable is reading the log. - [x] **Reserved names: a third state in the registry.** [`NameRegistry`](../crates/didbot-pds/src/names.rs) now holds `Live`, `Released(when)` and `Reserved(Reservation)`: a reserved name is unclaimable by the namer before any account holds it, survives the account it was reserved for, and never ages out — `prune` and `snapshot` treat it the way the ledger's own state is treated, not the way a hold's cache entry is. The reason is an open enum rather than a boolean, because "reserved" and "reserved because a zone lives there" are different answers to an operator debugging a failed provisioning; a zone apex and an operational block are the two reasons today. [zone-scale](zone-scale.md)'s `ZoneManager` is what actually reserves a zone's apex, as a side effect of creating the zone rather than a separate step an operator could forget — see that epic for the write-up of the inverse direction (a zone cannot be created where an agent already lives) and the open question of whether a zone may be created at a name still inside its release hold. - [x] **One lexicon describes an account, and it is the registration.** `bot.did.profile` is retired. Its presentation half was always `app.bsky.actor.profile`'s job and that record is written already; its harness and agent type were admission facts and are the registration record's agent member; its parent was the weaker of two copies of the same claim, and the server's own is the one the index reads now. What went nowhere is the model and the record switch, deliberately: a model changes every turn and is stamped on the records a turn writes, and the switch is enforced at the write path and answered from the account, so a stored copy could only ever disagree with the store it was copied from. - [x] **The second write is gone with it.** `putAgentProfile` existed because a session's transcript has no assistant row when `SessionStart` fires, so the model arrived one request later; the hook kept a note of what it had already published to avoid a round trip per tool call. With no account-level model there is nothing to refresh, so the route, the state it needed and the `PreToolUse` call all went. This closes [provenance](provenance.md)'s "carry the model without a second write" by removing the write rather than by finding a cheaper one. - [x] **`actor.registration` is the record every repository is born holding**, composed from the ledger as its predecessor was, and `agent.identity` and `agent.attestation` are gone. The attestation lexicon folded in rather than being replaced: what stays true of an admission — which backend, how much it proved, which node — is part of what the server registered. The attest crate stopped stamping a `$type` on its `Provenance` at the same time, because that value is facts the server stores and was never written into a repository. - [x] **An owner is required, and it is a DID.** An account nobody is answerable for defeats the one property a stranger can act on, so the schema refuses it rather than leaving it to a server to remember. It is a constructor argument rather than a builder for the same reason: a deployment that cannot name an owner does not compile, let alone register anything. The development server carries a default that resolves to nothing and says so, because refusing to start would make the flag mandatory before anybody could try the stack. - [x] **The admission facts moved into the ledger.** The record is composed from the bookkeeping and from nothing else — that is what makes the mirror checkable — so a fact the record carries has to be a fact the bookkeeping holds. `Provisioned` gained the harness, the agent type and the parent, defaulted on the way in so a log written before they existed still replays. `generation` is counted from the same ledger rather than stored, because a second copy is a second thing to be wrong. - [x] **The node is published, as the one the account was admitted on.** It was held back before, on the grounds that a node identifier links otherwise unrelated agents to one machine. That is a real property and not one to solve by omission for a deployment run by its own operator. What the field must not do is claim to be current, so it is named for admission: an agent resumed on another machine has not made the record wrong. - [x] **The agent member requires nothing of its own.** It required a harness when the lexicon was drafted, which was wrong twice: an agent is the kind defined by what it does *not* have, and every fact a harness reports is one it may have failed to read. The union still does the work it was chosen for on the two kinds with real requirements. - [x] **The `bot.did.registration` lexicon, with the kind as an open union.** One record for every kind of account, which is what keeps `revision`, `handles` and `keys` from being written twice. The per-kind fields sit in a union member rather than behind a `kind` string, because a lexicon cannot make one field's presence depend on another's value and a union member can declare its own `required` — so an agent without a harness, a pipeline without a binding and a host without an identifier are each refused by the validator rather than by prose. The union is open: the server is the only writer, so there is no hostile variant to refuse, and closing it would mean every reader had to be upgraded before any deployment could mint a new kind. `$type` is the only discriminator; a `kind` string beside it would be a second one to disagree with. - [x] **Called a registration, because that is what it is.** The identity is the DID and the keys in its document; this record is the server's account of admitting an account and keeping it, and naming it `identity` would both misdescribe it and spend the one name a credential-bearing record should have. `registration` is not a human-signup word in this neighbourhood: SPIRE calls the server-side workload entries registration entries, DNS has registries and registrars with no human in the loop, and this plan already said "a registration and lifecycle event feed" in [agent-accounts](agent-accounts.md) before the record existed. The trait that provisions is `Registry`. `enrollment` was the runner-up and leans further towards devices; it matched nothing already written here. - [x] **Only admission facts and bookkeeping, never present state.** A record that claims permanence may not carry a field that describes what an account is doing now: the model changes every turn, the effort with it, and the machine changes when a session resumes elsewhere or a container is rescheduled. So the node is recorded as the one an account was *admitted on*, which stays true forever, and the harness as the one that *asked for* the account. Anything genuinely current is either stamped on the records a turn writes or is a question for the server, and anything the server must attest across an account's life is a history array, which is what `handles` and `keys` already are. - [x] **Three kinds drawn from what the credentials actually say**, rather than from how the accounts feel: an agent, a continuous integration job, and a host. Writing the fields out is what separated the last two — a job is bound to a workflow definition and a host to a machine, and before the fields existed they looked like one kind. - [x] **A binding is an issuer and a stable subject, and nothing else.** Everything else a credential carries changes per presentation, so it is provenance on the records that presentation wrote. The subject must be the platform's stable identifier rather than its readable one: every platform here issues both, a binding on the readable one breaks silently at the next rename, and the failure looks like an admission failure. Where the platform offers an audience, not setting one is what lets a token minted for somebody else be replayed here — and a credential format with no audience and no expiry, which is what a detached-signature instance identity document is, has to be bounded by the seen-nonce store instead. - [x] **The verified claims are carried, and the credential is not.** A permanent record is the wrong place for a proof that expires, and inlining one would grow the record on every rotation. The old `agent.attestation` field for raw evidence does not come across.