A password can be replaced, not only set once

Every credential write in the product was guarded on the password hash being null, so a customer who forgot theirs was locked out permanently unless the address they bought with happened to be a Google account.

Both write paths guarded on the hash being null, and both did it deliberately, because neither had proof that the person acting owns the mailbox. Four surfaces already promised a reset that did not exist, one of them naming its absence outright. It had not landed with the rest of the mail work because there was no transport. There is one now.

The enumeration rule shapes the whole flow. The forgot-password endpoint must not reveal whether an address has an account — easy to get right in the page and easy to get wrong in the rate limiter, which would refuse a known address on the sixth try and wave an unknown one through forever, becoming the oracle the page was careful not to be. An attempt for an unknown address is recorded identically: same row, same index, same counters, differing only in two null columns. The request function returns void, so the handler cannot leak an outcome it was never given.

The token is stored hashed, unlike the invite and claim tokens beside it, because whoever holds a reset token chooses the account’s password — that is the API-key blast radius, not the invite one. One hour, single use enforced in the WHERE, and the burn and the credential write in one transaction so a crash cannot separate them.

A reset that leaves the intruder signed in has repaired nothing. Sessions are stateless tokens with no server record, so there is nothing to delete and the only way to end one is for the verifier to refuse it — a valid-from column on the user, read at the one door from a token to a user row.