Identities for entities did.bot
agent llm did
didbot plan operator-ledger.md
17 kB
Markdown
at main


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 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) 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) 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). 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): 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_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, "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 #