id: operator-ledger
title: The ledger says which operator ended an app's logins or an account's tokens
status: shipped
crates: [didbot-pds, didbot-serve, didbot-operator, didbot-lexicon]
dependsOn: []
exitCriterion: >
Ending an app's logins and revoking one account's tokens each append an
entry naming the operator to the ledger of every account they reach. The
dashboard has one route and didbot account one verb for the revocation,
and a test reads each entry back after a restart. #
operator-ledger #
Ending an app's logins recorded who did it in one log line, and nowhere that lasted. The only ways an operator ended one account's tokens were erasing it and the e-stop's Revoke, which reaches every account (issue 158).
What was recorded before #
| Action | What is written | Where | What it names | Lasts |
|---|---|---|---|---|
| End an app's logins | each login's refresh digest set to null | pds.grants |
the app and the account, not who or when | until the login would have ended (14 days at most), or the file is deleted |
| End an app's logins | one info line |
the host's log | the operator, the app, the account, the counts and the time | as long as the host keeps its log |
| End an account's tokens, in an erasure or an e-stop release | accountTokenRevoked { did, tokenHash: null } |
the write-ahead log | the account, not who or when | until the next checkpoint, which rewrites live tokens only |
What the specs and the reference implementation do #
- The atproto OAuth spec says "Auth sessions can be revoked - invalidating the refresh tokens". It asks an authorization server for "a web interface which allows authenticated users to view active OAuth sessions and delete them". An account here has no person to use that page, so that page is the operator's dashboard.
- The Lexicon spec says "Any new fields
must be optional", and that
knownValuesis "not a closed enum". Two new event names and one optional field onbot.did.getAccountLedgerfit both rules. - The reference PDS
(
account-manager.ts) ends different tokens for different actions.takedownAccountends the account's refresh tokens and its OAuth tokens in one transaction.updateAccountPassword, whichcom.atproto.admin.updateAccountPasswordcalls, ends the refresh tokens and keeps the OAuth tokens. - The reference OAuth provider's
revoke(oauth-provider.ts) deletes the token and records nothing. - Ozone keeps a moderator's action as an event with
createdBy(a DID),subjectandcreatedAt, in one log read by subject (modEventView). The events below carry the same three facts: who, which account, when.
The spec and the reference agree on everything this design relies on.
Option one: do nothing #
- Stores: nothing new.
- Costs: nothing.
- Breaks: nothing. The log line stays the only record of who ended an app's logins. A leaked account token ends when the account is erased, or when the e-stop's Revoke is released, which ends every account's tokens.
Option two: the account's own ledger #
- Stores: two ledger events,
loginsEndedandtokensRevoked, one entry in the ledger of each account an action reached. Every checkpoint keeps them, and they outlive the account, as every ledger entry does. - Costs: the layout moves from 19 to 20 (see "The layout move"). Ending an
app signed in on N accounts appends N synced entries, the cost
FileAccountTokenStore::revoke_allalready pays per account. TwoknownValuesand one optional field onbot.did.getAccountLedger#entry. One route and one verb. - Breaks: an image at 20 rewrites a 19 directory on its first boot, and an image at 19 then refuses it. Going back is the snapshot taken before the upgrade, as for every layout change. The public ledger starts naming the apps an account was signed in to, which the dashboard's logins panel shows only to the operator.
Option three: option two, plus a deployment-wide operator log #
- Stores: option two's entries, and one row per operator command in a
third row log beside the evaluation and write logs
(
row_log.rs): the time, the operator, the command, the app and the account where there is one, and the counts. - Costs: option two's, plus a new file in the data directory, a
[capacity]retention key with a stated range, a read route behind the sign-in, and a row type for the owner's review. A row log is trimmed by age, so it holds a window, not a history. - Breaks: nothing option two does not. It records an app-wide ending that reached no account, and the e-stop's throws and releases, which are log lines now. Without option two it would leave both actions out of the account's own history, where the other five operator verbs write.
Recommendation #
Option two. Both actions are done to accounts, and the account's ledger is
where lock, unlock, lift-quarantine, erase and delete already name
the operator. It keeps every entry, it outlives the account, and it is public
by default, which is this project's accountability argument
(auth-types, "Publish by default"). An app-wide ending is one
listAccountLedgers read away: every loginsEnded with that clientId.
What option three adds is outside this issue's two actions.
The two events #
As getAccountLedger returns them, and as ledgerAppended.entry holds them
in the log:
[
{ "seq": 7, "at": "2026-09-23T14:02:11Z", "event": "loginsEnded",
"operator": "did:web:operator.example",
"clientId": "https://app.example/client.json" },
{ "seq": 8, "at": "2026-09-23T14:05:40Z", "event": "tokensRevoked",
"operator": "did:web:operator.example" }
]
| Field | Type | Why it is there |
|---|---|---|
seq |
integer, from 1 | The wrapper's, as on every entry: the entry's place in this account's ledger. |
at |
datetime | The wrapper's: when the entry was appended, after the ending, by this server's clock. It is the time the issue asks for, so neither event carries another. |
event |
loginsEnded or tokensRevoked |
Which action. |
operator |
DID, required | Who acted: the --operator the sign-in checked the session against. Required, where the older events' is optional, because only the operator reaches either route. |
clientId |
string, loginsEnded only |
Which app. Nothing else keeps it: pds.grants drops an ended login once it would have expired. |
The account is not a field. The ledger is keyed by it: AccountLedger::did,
and did on ledgerAppended.
Neither event carries a count. How many logins or tokens ended is in the route's answer and the log line, and a field on a permanent entry is kept for good.
tokensRevoked carries no tokenHash. The operator names an account, and
the verb ends every token it holds: accountTokenRevoked with
tokenHash: null, the entry an erasure writes.
getAccountLedger#entry gains the two names in event.knownValues, and one
optional property:
"clientId": {
"type": "string",
"description": "For an ended login: the application's client_id.",
"format": "uri",
"maxLength": 8192
}
8192 bytes is the Lexicon spec's bound on a uri. operator is declared
already, and its description fits both events. listAccountLedgers returns
#ledger, so it changes with it.
What revoke-tokens ends: the two answers #
| Its account tokens only | Every token that authenticates as it | |
|---|---|---|
| Ends | every account token createAccount and createSession issued |
the same, every app's OAuth login on the account, and its unredeemed codes |
| Calls | AccountTokenStore::revoke |
revoke_account_credentials, the call an erasure makes |
| After a leak | an app login the stolen token approved stays live until its own end, 14 days at most, unless the operator ends each app: bot.did.approveAuthorization takes the account token |
nothing that authenticates as the account |
| Also signed out | nobody | every app on the account, which must be approved again |
| Reference | updateAccountPassword: ends refresh tokens, keeps OAuth tokens |
takedownAccount: ends both |
tokensRevoked means |
the account's own tokens ended | every token ended |
Recommended: every token. The verb is for a token that leaked, and a leaked
account token can approve an app's login, so ending the token alone leaves
what it approved. It is the call an erasure already makes, and the e-stop's
Revoke for one account. The narrow tool stays: didbot app end-logins --client <CLIENT> --account <NAME> ends one app on one account.
Both answers leave the account's state, its locks and its bot.did.credential
records alone, and send no #account frame. An account with a key or an
OpenID Connect identity takes a new session through bot.did.createSession
once no lock is hung on it. An account with neither, which is what
didbot-agentd creates, has no proof to take one with, so after this it
writes nothing again. The verb runs whatever locks are hung, so an operator
may lock first, which stops the account's writes and its own erasure, and
revoke second.
The state machine #
Option two, with the "every token" answer, and no entry for an action that
ended nothing. The route sits behind the sign-in the other account verbs use,
and the verb calls it with the session didbot login keeps.
What is stored #
| Fact | Type | Where it lives | Kept |
|---|---|---|---|
loginsEnded |
ledger entry: seq u64, at datetime, operator DID, clientId string |
the account's ledger, as ledgerAppended in the write-ahead log |
whole, by every checkpoint, and after the account is deleted |
tokensRevoked |
ledger entry: seq u64, at datetime, operator DID |
the same | the same |
| the account's tokens ended | accountTokenRevoked { did, tokenHash: null }, as now |
the write-ahead log | until the next checkpoint, which rewrites live tokens only |
| a login ended | the login's currentRefresh set to null, as now |
pds.grants |
until the login would have ended, 14 days at most |
| codes and pending consents dropped | as now | memory | — |
What moves it #
| Trigger | Checked first | Then, in order | Answers |
|---|---|---|---|
POST /dashboard/api/apps/end-logins, or didbot app end-logins --client <CLIENT> [--account <NAME>] |
the operator session and its CSRF token; a named account is held here | 1. End every live login of the app, on the named account or on all, and rewrite pds.grants. 2. Drop the app's codes and pending consents. 3. Append one loginsEnded to each account a login ended on, in DID order. |
the counts it answers now, and accounts: each account reached, with its entry |
POST /dashboard/api/accounts/{did}/revoke-tokens, or didbot account revoke-tokens <NAME> |
the operator session and its CSRF token; the account is held here and is not the server's own; any state, any locks | Under the account's turn: 1. Revoke its account tokens and append accountTokenRevoked. 2. End its logins, rewrite pds.grants, and drop its codes. 3. Append one tokensRevoked. |
action and accounts, as the other account verbs answer, and the number of app logins it ended |
| either, with nothing live to end | the same | no entry | the counts, all zero |
| either, when a store cannot write its step down | — | no entry | an error naming the store. end-logins answers its counts now whether or not pds.grants took the change. |
| the process stops between step 1 and step 3 | — | the ending stands, with no entry | a retry finds nothing live and appends nothing: the window every operator verb has, since lock hangs its tag before it appends |
| a restart | — | ledgerAppended replays each entry with its seq and at; accountTokenRevoked replays the revocation; pds.grants loads the ended logins |
— |
| a checkpoint | — | every ledger entry is carried forward whole | — |
| the account is erased or deleted later | — | both entries stay, and stateChanged or deprovisioned follows them |
— |
Only the two routes append these entries. A refresh token's reuse, a
denyClient policy, an erasure and the e-stop's Revoke end logins and tokens
as they do now, and record what they record now.
The layout move #
layout::LEDGER_SHAPEgains("loginsEnded", &["clientId", "operator"])and("tokensRevoked", &["operator"]).ENTRY_SHAPEandACCOUNT_SHAPEstay as they are: both events travel inledgerAppended, and the revocation is theaccountTokenRevokedthe log already has.SHAPEmoves, since it hashes all three tables.LAYOUTgoes from 19 to 20, becausewal::reader::KNOWNorders its rows by it.KNOWNholds 20, and 19 with the shape572265878073496352its fixture's stamp carries. The reader for 19 is today's decoder. The change adds two variants and alters none, so a layout-19 payload means the same under 20. The window is N-1, so the layout-18 reader and its fixtures go.write_the_fixture_for_this_layoutwritestests/fixtures/layout/20/on the commit that moves the shape, and the 19 fixture stays. The 19 fixture holds noledgerAppendedentry, because the generator's provisioner wrote to an in-memory ledger. The 20 one writes through the durable ledger and appends one of each new event, so the next shape change's reader is tested against ledger entries.- The first boot of a 20 image on a 19 directory reads it through the 19 row, takes a checkpoint in 20's shape and restamps (operations, "Upgrading across a layout change").
pds.grantskeeps its shape. It is outside the stamp.
Tests #
crates/didbot-pds/tests/ledger.rs: the operator revokes an account's tokens, and the directory is reopened. The last entry istokensRevokednaming the operator, with theseqandatit had, and the token is refused. A checkpoint and a second reopen leave the entry exactly once.crates/didbot-pds/tests/grant_restart.rs: two accounts are signed in to one app and a third to another. Ending the first app's logins appends oneloginsEndedto each of the two, naming the operator and the app, and none to the third. After a reopen the entries are there and the logins stay ended.crates/didbot-serve/src/tests/dashboard_tests.rs:revoke-tokensanswers with the entry it made and leaves the account's state and locks alone. The server's own account and an unknown one are refused.end-loginsanswers one entry per account it reached, and none when it ended nothing.crates/didbot-serve/tests/dashboard_apps.rs: afterrevoke-tokens, an app's login on that account stops authenticating, and the same app's login on another account keeps working.crates/didbot/tests/scenarios/sessions.rs: a pipeline's session leaks.didbot account revoke-tokensturns the stolen token intoInvalidToken, adidbot-pdsrestart keeps it refused,bot.did.getAccountLedgerends intokensRevokednaming the operator, and a fresh OpenID Connect proof still takes a session.crates/didbot/tests/conformance/bot_did.rs: a ledger holding both events conforms to the lexicon.crates/didbot-pds/tests/layouts.rsopens both fixtures, and the 20 fixture'sexpected.jsonnames its ledger entries.crates/didbot-pds/tests/durability.rschecks the two new rows againstledger.rs.