User
An account, what "deleted" means here, and the session rows behind the users screen.
User extends ASP.NET Core Identity's IdentityUser. Identity supplies the
credential half — login, address, password hash, lockout — and the product adds
the rest.
| Field | Meaning |
|---|---|
FirstName, LastName | optional |
Note | a sentence about the account, written by staff. Not a tag |
Tags | free display metadata, never queried. Opaque jsonb |
ApprovedAt | when staff approved the account. Absent means pending |
IsTemporary | a bulk account handed out on a slip of paper |
ExpiresAt | when it runs out |
BlockedReason | why the account is blocked |
CreatedAt, LastSeenAt | |
Anonymized | the account has been deleted, which here means emptied |
Approval is not confirmation
ApprovedAt is deliberately not the same fact as EmailConfirmed. Confirming
an address proves somebody reads that mailbox; approving an account is a
decision a person made. One state holding both would make an installation that
confirms no addresses unable to approve anybody.
Blocked and expired are computed, not stored twice
Blocking is LockoutEnd, and never a second boolean: two fields answering
is this account blocked is two fields that can disagree, and Identity already
enforces the first one at sign-in. BlockedReason says why; it does not say
whether.
Expiry is one expression over ExpiresAt, never written back as a block.
Setting a block from the date would put two writers on one field — a manager and
the clock — and both ways they disagree are silent: unblocking would defeat the
expiry, and moving the date would leave a stale block behind.
Deletion is anonymization
A user row is never removed
Submissions and results name a user id and it
has to stay resolvable, so deletion empties the row in place — immediately, with
no grace period. Anonymized exists so a screen can say "deleted account"
rather than showing a blank name and leaving a reader to guess.
An account merge follows the same rule: both accounts keep their rows, and the
emptied one is anonymized when the undo window closes. user:merge is its own
permission because it hands one person's submissions and points to somebody
else.
Emptying the name is only the first half
What somebody wrote, and what they left in a queue, carries their name as surely as the name field does. Deletion reaches those too.
Questions they asked keep their row and lose their content. Topic and body
both become [deleted]. A question signed with a class and a surname is not
anonymous because the account row is.
Print requests still outstanding are disposed of — the ones waiting in the queue and the ones somebody at a printer has already taken. Bytes somebody asked to have printed are theirs, and a deleted account must not leave them sitting in a queue for whoever is next at the printer. Disposal is the same act an operator performs on a request they finish with: the reference goes, the file goes with it, and the record of the request stays. The outcome is recorded as discarded, with nobody named as having resolved it.
Every session is closed and stripped, not only the open ones. The rows stay, because what is deleted is the person and not the record that somebody signed in; the address and the user agent are cleared from all of them. An anonymization that leaves personal data behind is not one.
Submissions stay and their origin does not. A submission is somebody's record
in a contest and survives erasure by design, so IpAddress and DeviceId are
cleared and the submission is not. The exclusion reason goes with them; the
exclusion itself stays. Whether a submission counted is contest history, and
clearing that would quietly move a board — the sentence about a named person is
not history.
None of this reaches a backup
Every step above rewrites rows and deletes bytes in the live store. A backup taken before the deletion still holds the name, the addresses and the source somebody asked to have printed, and restoring one brings them back.
Passwords
Ordinary participants authenticate through OIDC; see Identity. The embedded Identity stays permanently for administrator, local and temporary accounts — a room of bulk logins handed out on paper has no mailboxes and no provider. Nothing prevents an installation from running entirely on local accounts, and that is not the recommendation: registering a provider is.
An address is optional on an account, deliberately, and the seeded administrator is such an account.
UserSession
One sign-in, which is neither a person nor a browser tab.
| Field | Meaning |
|---|---|
StartedAt, EndedAt | EndedAt is set on sign-out, so history survives it |
LastRequestAt, LastRequestPath | an API path, not the screen somebody was looking at |
IpAddress | inet, normalized before it is stored |
UserAgent | |
DeviceId | the name this browser gives itself, or null |
ExpiresAt |
IpAddress is inet rather than text because the question anybody will ever
ask of it — is this inside the examination room's network — is a containment
test, and over a string it is a string comparison.
The number of open WebSockets for a session is counted live from the connection registry and never stored. A count written to a row is a count that survives a crash and tells the users screen somebody is present who left hours ago.
DeviceId is self-declared: it arrives in a header the page writes, so whoever
is using the browser can read, change or clear it. It is weaker evidence than
the session cookie, which is HttpOnly. It is stored as a Guid rather than as
the text that arrived — client-supplied text stored whole is text that reaches a
screen unvalidated.