Account Activity Log
DraftThe activity log is the account owner’s record of what was done to and with the account on this server: sign-ins, sign-outs, revocations, credential changes, device enrollment and the links that open the account to someone else. It answers “was that me?” next to the session list, and it is what an operator and a user reconstruct an incident from. Code: capsule-server::activity.
It is not the provenance chain, which signs changes to library data, and it is not the moderation record, which records what an operator did to the account. The three are separate records with separate writers.
Invariants
Section titled “Invariants”- Owner-only. An account reads its own log and nobody else’s, over a route with no account parameter. There is no admin read over HTTP, for the reason moderation has no admin surface: no admin authentication model exists. The operator holding the database credentials can read the table, as they can read every table.
- Append-only. An event is never updated. Rows leave the log only by the retention bound below, never by a user or a request.
- Identifiers, never content. The server is end-to-end encrypted, so no field may carry what only clients can read: no asset content, no album names, no captions, no labels, no file names, and no key material. An event names which session, share link or drop link, by the identifier the server already holds.
- Recording never refuses the act. An event is written after the action it describes has happened. A store that cannot record it is logged at
errorwith the event’s fields and the action stands: refusing a sign-out, a revocation or a password change because the log is down would turn an audit outage into a security outage. - Recording never discloses an account.
sign_in.password_refusedandsign_in.lockedare written only for an address that has an account, so the sign-in’s answer, its status and code included, is the one an unknown address gets, and the event is written off the response path: a write the response waited on would show in its timing which addresses exist. - The event set is closed per server version. Every event the server writes is a kind listed below. A new kind is an additive change: a client renders a kind it does not know as a generic entry with its time, and never refuses the listing.
Events
Section titled “Events”| Kind | Written when | subject |
|---|---|---|
account.registered | an account is created, by registration or by the first identity-provider sign-in of an identity | its first session, when registration opened one; absent for an account an identity-provider sign-in created, whose sign_in.oidc names the session |
sign_in.password | a password sign-in opens a session | the new session |
sign_in.password_refused | a password sign-in is refused because the password is wrong for this account; at most one an hour per account, see Retention | — |
sign_in.locked | a password sign-in is refused because this account is locked; at most one an hour per account, see Retention | — |
sign_in.second_factor | a sign-in completes its second factor and opens a session | the new session |
sign_in.second_factor_refused | a second-factor code is refused for a sign-in whose first factor verified | — |
sign_in.oidc | an identity-provider sign-in (browser or device flow) opens a session | the new session |
sign_out | a session signs itself out | the closed session |
session.revoked | a session revokes another session of the account | the revoked session |
sessions.revoked_all | every session is revoked with master-key proof | — |
password.changed | the account password changes | — |
totp.enabled | a second factor is confirmed | — |
totp.disabled | a second factor is removed | — |
escrow.replaced | the master-key escrow is stored over an earlier one, so the old recovery secret stops working | — |
device.enrolled | a cross-device enrollment code is redeemed | — |
share_link.created | a share link is created | the link’s id |
share_link.revoked | a share link is revoked | the link’s id |
drop_link.created | a guest-upload link is provisioned | the link’s id |
drop_link.revoked | a guest-upload link is revoked | the link’s id |
share_link.served | a share link serves the first blob of a window; aggregated, see Serving | the link’s id; absent for the links past the account’s hourly bound |
peer.served | a federated peer, under a capability over one of the account’s albums, is served the first blob of a window; aggregated, see Serving | the peer’s id; absent for the peers past the account’s hourly bound |
Not recorded, each for a stated reason:
- A sign-in attempt against an address no account answers to, including a federated account’s address, which holds no password. There is no account whose log it belongs in.
- A refused re-authentication or password change. Both are acted by a session the account already holds; their refusals are in the request log.
- Blob downloads by the account or by a member of its albums on this server. One event per blob per request would grow with library size and record an access pattern over content addresses, and these readers hold a session the owner’s roster admitted. Downloads through a share link or by a federated peer are the security-relevant subset, and are recorded aggregated (see Serving).
- A first escrow, and the sealed identity and album-key slots beside it. A first escrow invalidates no recovery secret, and the other slots are sealed under the master key, so replacing them changes nothing that opens the account. Only replacing the master-key escrow is a credential change.
- Token refresh. It rotates a session that is already recorded, once per access-token lifetime, and would bury every other event.
Fields
Section titled “Fields”| Field | Meaning |
|---|---|
event_id | A UUIDv7 the server mints for the event |
kind | One of the kinds above |
at | The server’s clock when the event was recorded |
request_id | The X-Request-Id of the request that caused it (request ids); absent for an event no request caused |
session_id | The session that acted, when one did; absent for an unauthenticated act such as a sign-in or a revoke-all |
subject | The identifier the event is about, per the table above |
No client address and no user agent. An address is the most useful field for spotting a stranger’s sign-in and the most sensitive one to keep: it is personal data, it outlives the session it describes, and it is the one field that cannot be withdrawn from a log once written. Adding it later is additive; removing it is not.
The request_id ties an event to the server’s own log lines for the same request, which carry the method, route, client address and outcome for as long as the operator’s log retention keeps them. The activity log is the durable half, the request log the detailed half.
Retention
Section titled “Retention”An account’s log keeps events for 90 days and at most 1,000 events, whichever bound is reached first:
- A read never returns an event older than 90 days, whether or not it has been deleted yet.
- Recording an event deletes the same account’s events beyond either bound, in the same operation, so the log of an active account never exceeds its bound.
- An account that records nothing more keeps its expired rows, unread, until it next records or the operator’s sweep deletes them. The sweep is part of
capsule-server gc, not a request’s work: an applied pass deletes every event older than 90 days, across accounts, in batches of at most 1,000 rows so it never holds a long lock on the table; a dry run, the default, reports how many it would delete and deletes nothing. It deletes only events past the retention window, never one inside it, and nothing but activity events. - The count bound is what keeps a burst of events (a scripted loop against the second factor) from growing the table without limit. The cost of the bound is that such a flood pushes the account’s older events out of its log; the request log still holds them for as long as the operator keeps it.
sign_in.password_refusedandsign_in.lockedare caused by anyone who knows an address, and the password sign-in route charges no budget for them. So each is coalesced: one is written per account per hour, the attempts after it in that hour are counted into it, and the count goes to the operator’s log (atinfo) when the next attempt of that kind for the account, or a sweep of expired windows (run when many accounts are tracked), closes the window after the hour is over; an account that goes quiet may never have its count logged abovedebug. Every attempt is still in the request log. A wrong-password flood therefore adds at most two events an hour to an account’s log, and needs weeks of uninterrupted attempts to push out the events before it. Serving through links and to peers is bounded the same way, at ten events an hour (see Serving). Coalescing is per server process, and decides nothing about the sign-in’s answer.- Those two events are written off the response path, at most 16 at a time per server process. One past that bound is not written and is logged at
errorwith its fields. A server that is stopping waits a few seconds for the ones in flight; any still in flight after that are lost, and the stop logs it aterror.
Serving
Section titled “Serving”A blob served to somebody other than the account is one request per blob, so it is recorded aggregated: one event per account and link or peer per hour, never one per blob.
- The first delivery that carries bytes (
200or206) through a share link, or to a peer under a capability, in an hour writes oneshare_link.servedorpeer.servednaming the link or the peer. Every later one in that hour is counted into it and not written; the count goes to the operator’s log (atinfo) when the window closes, as for the coalesced sign-in events. A revalidation (304), a refused range and a refused fetch serve nothing and record nothing. - An account’s serving names at most 8 links and peers an hour, together. Every further link or peer in that hour is counted into one
share_link.servedorpeer.servedwith nosubject, one of each kind, and not written again. Serving therefore adds at most ten events an hour to an account’s log, 240 a day, however many links are viewed: it takes about four days of uninterrupted serving across more than eight links and peers to push an earlier event, a sign-in included, out through the 1,000-event bound. The request log still names every link. - The event carries the link’s or the peer’s id and nothing about what was read: no content address, no album id, no size, no client address. Which blobs a link serves is the owner’s own record; which of them a viewer fetched is not the server’s to keep.
- The event is written when the window opens, so it says that serving started and when; how much was served is in the operator’s log, not in the event, because an event is never updated. A count on the event would be an additive field.
- An account’s own downloads record nothing, and neither do a member’s on this server.
- The windows are kept in memory, per server process, in a set apart from the sign-in windows and under the same 16-write in-flight bound: no blob request makes a store write of its own, and none waits on one. Each set holds at most 65,536 open windows, so serving can never fill the sign-in windows; an event that would open one past its set’s bound is not written and is logged at
errorwith its fields. A full set is swept for closed windows only once the first of them can have closed, so refusing past the bound costs a request nothing. A restart forgets the open windows, so the first serve after it writes again.
The Port
Section titled “The Port”The log is a durable port in the PostgreSQL family, beside the asset index, accounts and moderation, with an in-memory adapter for tests and --memory deployments. Both adapters pass one conformance suite. It is not a generic event store: it records the closed set above and reads one account’s events, newest first.
Reading It
Section titled “Reading It”GET /v1/auth/activity returns the caller’s own events, newest first, at most limit of them (default 100, maximum 1,000, which is the retention bound). The CLI reads it with capsule auth activity --json.
Rate Limits
Section titled “Rate Limits”No event feeds a counter budget. Budgets are charged at the request edge, before the action and against a counter store built for that; the log is written after the action, to PostgreSQL. A budget fed from the log would make a log outage disable a limiter. Second-factor attempts and link creation are budgeted at the routes that cause them. A password sign-in is bounded by the account lockout, not by a budget, so its refusal events are coalesced instead (see Retention).