--- 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](https://tangled.org/did:plc:swttlkbjvcoln67iievud7b3/issues/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](https://atproto.com/specs/oauth) 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](https://atproto.com/specs/lexicon) says "Any new fields must be optional", and that `knownValues` is "not a closed enum". Two new event names and one optional field on `bot.did.getAccountLedger` fit both rules. - The reference PDS ([`account-manager.ts`](https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/account-manager/account-manager.ts)) ends different tokens for different actions. `takedownAccount` ends the account's refresh tokens and its OAuth tokens in one transaction. `updateAccountPassword`, which `com.atproto.admin.updateAccountPassword` calls, ends the refresh tokens and keeps the OAuth tokens. - The reference OAuth provider's `revoke` ([`oauth-provider.ts`](https://github.com/bluesky-social/atproto/blob/main/packages/oauth/oauth-provider/src/oauth-provider.ts)) deletes the token and records nothing. - Ozone keeps a moderator's action as an event with `createdBy` (a DID), `subject` and `createdAt`, in one log read by subject ([`modEventView`](https://github.com/bluesky-social/atproto/blob/main/lexicons/tools/ozone/moderation/defs.json)). 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, `loginsEnded` and `tokensRevoked`, 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_all` already pays per account. Two `knownValues` and one optional field on `bot.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`](../crates/didbot-pds/src/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](auth-types.md), "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: ```json [ { "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: ```json "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 --account ` 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 [--account ]` | 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 ` | 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_SHAPE` gains `("loginsEnded", &["clientId", "operator"])` and `("tokensRevoked", &["operator"])`. `ENTRY_SHAPE` and `ACCOUNT_SHAPE` stay as they are: both events travel in `ledgerAppended`, and the revocation is the `accountTokenRevoked` the log already has. - `SHAPE` moves, since it hashes all three tables. `LAYOUT` goes from 19 to 20, because `wal::reader::KNOWN` orders its rows by it. - `KNOWN` holds 20, and 19 with the shape `572265878073496352` its 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_layout` writes `tests/fixtures/layout/20/` on the commit that moves the shape, and the 19 fixture stays. The 19 fixture holds no `ledgerAppended` entry, 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](../docs/operations.md), "Upgrading across a layout change"). - `pds.grants` keeps its shape. It is outside the stamp. ## Tests 1. `crates/didbot-pds/tests/ledger.rs`: the operator revokes an account's tokens, and the directory is reopened. The last entry is `tokensRevoked` naming the operator, with the `seq` and `at` it had, and the token is refused. A checkpoint and a second reopen leave the entry exactly once. 2. `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 one `loginsEnded` to 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. 3. `crates/didbot-serve/src/tests/dashboard_tests.rs`: `revoke-tokens` answers 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-logins` answers one entry per account it reached, and none when it ended nothing. 4. `crates/didbot-serve/tests/dashboard_apps.rs`: after `revoke-tokens`, an app's login on that account stops authenticating, and the same app's login on another account keeps working. 5. `crates/didbot/tests/scenarios/sessions.rs`: a pipeline's session leaks. `didbot account revoke-tokens` turns the stolen token into `InvalidToken`, a `didbot-pds` restart keeps it refused, `bot.did.getAccountLedger` ends in `tokensRevoked` naming the operator, and a fresh OpenID Connect proof still takes a session. 6. `crates/didbot/tests/conformance/bot_did.rs`: a ledger holding both events conforms to the lexicon. 7. `crates/didbot-pds/tests/layouts.rs` opens both fixtures, and the 20 fixture's `expected.json` names its ledger entries. `crates/didbot-pds/tests/durability.rs` checks the two new rows against `ledger.rs`. ## Done - [x] **Where the entries go.** The account's own ledger. - [x] **What `revoke-tokens` ends.** Every token that authenticates as the account. - [x] **Whether the public ledger names the app.** Yes, as `clientId`. - [x] **Whether an action that ended nothing appends an entry.** No. - [x] **The names.** `loginsEnded`, `tokensRevoked` and `revoke-tokens`. - [x] The two `LedgerEvent` variants, their labels and `LEDGER_SHAPE` rows, `LAYOUT` 20, the 19 row in `KNOWN`, and the 20 fixture. - [x] `OAuthGrantStore::revoke_client` answers each ended login with its account. It and `revoke_account` answer `Unkept` when `pds.grants` does not take the change, and `AccountTokenStore::revoke` answers `Unwritten` when the log does not. `FileAccountTokenStore` holds one lock across each change and its log entry. - [x] `Registry::revoke_tokens` and `Registry::record_logins_ended`, each taking the operator's DID and answering the entry it appended. - [x] `POST /dashboard/api/accounts/{did}/revoke-tokens` and `didbot account revoke-tokens`; the `end-logins` answer and `didbot app end-logins` output with an entry per account. - [x] `getAccountLedger#entry`'s two `knownValues` and `clientId`. - [x] The verb in [ownership](../docs/ownership.md)'s table and in [the command line](../docs/cli.md). - [x] The tests above.