The account lifecycle #
didbot_pds::AccountState is whether an account's data exists, and whether it
is being created or destroyed right now. It answers nothing about whether the
account serves: that is a set of locks, didbot_pds::Locks, hung on the
account by named parties, and the account serves when the hasp is empty.
didbot_pds::ServerState (see server-lifecycle.md) is
the deployment's own standing, which is a third thing.
Everything below the marker is generated from the types by
didbot_pds::account_lifecycle::generated_docs and checked against them by
the_committed_diagram_matches_the_types. Regenerate it with:
UPDATE_DOCS=1 cargo test -p didbot-pds account_lifecycle
stateDiagram-v2
direction LR
[*] --> reserved
reserved --> provisioning: row and key stored
provisioning --> active: records landed, announced
active --> decommissioning: erase
decommissioning --> decommissioned: erased
decommissioned --> [*]: hard delete
| state | serves the document | serves the verification method | serves the repository | accepts external writes | on #account |
|---|---|---|---|---|---|
reserved |
no | no | no | no | nothing announced |
provisioning |
yes | yes | no | no | nothing announced |
active |
yes | yes | yes | yes | active: true |
decommissioning |
yes | yes | no | no | active: false, status: deleted |
decommissioned |
yes | yes | no | no | active: false, status: deleted |
| lock | removes reads | removes writes | on #account while active |
|---|---|---|---|
frozen |
no | yes | active: true |
quarantined |
no | yes | active: true |
suspended |
yes | yes | active: false, status: suspended |
deactivated |
yes | yes | active: false, status: deactivated |
| hold | refuses |
|---|---|
prevent-data-deletion |
active --> decommissioning, whoever asks |
The state is data existence, and it moves one way #
Data can always be served again, and can never be un-erased. That is why the
axis is a state machine with no edge backwards, and why a reason to stop
serving is never a state: frozen as a state would have to be left again,
and the state that answers "does the data exist" cannot be the state that
answers "who says it may not serve".
An -ing state is work that is running, so each one names what advances it
and what finishes it when nothing else does. Registry::provision moves an
account from reserved through provisioning to active in one call;
Registry::delete moves it from active through decommissioning to
decommissioned. A process that dies between two durable steps leaves the
-ing state behind, and Provisioner::sweep_stale is what acts on it: a
reserved or provisioning row is reaped, and a decommissioning one is
resumed and finished. tests/erasure.rs kills the process at each step.
reserved is the first durable step of provisioning: the row exists and the
name is held, and nothing serves. The hostname already resolves, through the
zone's wildcard record.
decommissioned keeps the name, the zone, the certificate and
did.json: a did:web identity cannot leave the domain that hosts it
without breaking every signature it ever made.
An account of any kind, in any state from active on, keeps its row however
old it is, until the operator deletes it with
POST /dashboard/api/accounts/{did}/delete or didbot account delete, which
calls that route. That is Registry::hard_delete with the DID confirmed, and
it erases the data first when the data is still there. The stale sweep takes
only a reserved or provisioning row, once it is a week old.
Locks are lockout/tagout #
Any party may hang a lock; each tag is lifted only by the party that hung it;
the account serves when nothing hangs. Two parties hanging the same kind of
lock are two tags, so lifting one never lifts the other. The parties are
didbot_pds::Party, and Registry::lock/unlock take an Actor that names
which one is acting and, for an operator, who.
One recorded exception: an operator lifts a policy's quarantined, and the
ledger says which operator overrode it. plan/policy.md's rule that an
evaluator can never unfreeze holds — the policy's tag comes off by an
operator's hand, never by a later verdict.
A lock is confined to a subtree. Every account is created beneath one
operator (see who operates an account), and a tag hung on a
principal is hung on everything live beneath it as Party::Parent — by the
parent, so it comes off below only when it comes off above. An operator
record lapsing hangs quarantined the same way, and the confinement poll
lifts it when the record returns. No Actor is the parent, so nothing
lifts an inherited tag at the child.
Subtraction always wins. A lock that removes reads removes writes too, and
HostedAccount::policy() — the one call an enforcement point makes — is the
state's baseline with every tag subtracted. No lock touches the document.
Over XRPC the account's own credential is the only one this server
authenticates, so bot.did.freezeAccount/unfreezeAccount and
bot.did.deactivateAccount/activateAccount hang and lift the account's own
tags. The operator acts on one account through the dashboard's
/dashboard/api/accounts/{did}/ routes, which didbot account calls. The
policy's tag and every hold are in-process calls on Registry.
Self-service erasure respects every lock. Registry::delete is refused
while anything hangs: an account that is frozen, quarantined or suspended does
not get to destroy what the lock was hung to keep. An operator's
Registry::operator_erase and Registry::hard_delete ignore the locks.
A hold refuses a destructive edge #
PreventDataDeletion is set and cleared by an operator, both recorded in the
account's ledger, and every eraser — the account's own, an operator's, the
hard delete — is refused while it is set. The stale sweep passes a held row
by. The store refuses the row's removal again on its own, so a caller that
skipped the provisioner's check still cannot get past it. It adjusts nothing
about service: stopping an account is a lock.
What a relay sees #
#account on com.atproto.sync.subscribeRepos is this host's answer to can
you fetch this repository from me, and if not, why. active is
fetchability; status is the reason when it is false. The generated tables
above are the mapping, computed by AccountState::sync_status over the state
and the locks, and the same function decides whether a lock change is
announced at all: a tag that changes the answer is, one that does not is not.
The write axis has no wire representation, so a consumer of #account alone
cannot learn that an account is frozen or quarantined; bot.did.listAccounts
carries every tag with the party that hung it. takendown, desynchronized
and throttled are values this deployment has no position for. See
conformance.md.