Changing the Email on an Account
Status: built 2026-08-21. No migration of its own — email_change_requests sits in 000002 beside humans, whose email column it exists to move, and patients.change_email in 000006 with the rest of the patients.* family.
The address on a patient's account is the credential they sign in with. Before this feature there was no path to change it — not self-service, not staff, not Console — and the reason was deliberate rather than an oversight: humans.email is written once at provisioning and every write path since has been closed on purpose. This document is what re-opens it, and the shape of the opening.
What was closed, and by what
Three independent locks, all of which this feature has to unlock deliberately:
- The DB grant.
000002runsREVOKE INSERT, UPDATE, DELETE, TRUNCATE ON humans FROM restartix_app. The app pool cannot write the table at all. - The application allow-list.
human.Repository.UpdateSelfServiceFieldsaccepts exactlypreferred_language,name,timezone, and its doc comment states the allow-list exists so that "a future caller" cannot reachemailorblockedthrough it. - The UI. The Portal account section renders the address read-only, with the rationale in the file: it is the identity the patient authenticates with, so changing it is an auth flow, not a profile edit.
This feature keeps all three locks and adds one narrow, verified path past them. It does not widen UpdateSelfServiceFields; the email write is its own admin-pool method with its own audit obligation, because the two things are not the same kind of change.
The two requests hiding in "change my email"
They have different answers and only one of them is this feature.
A typo at intake, where the patient never had an account. Usually nothing is broken. resolveOrCreateProfile uses the typed address only as a lookup key — it creates the profile from name + phone and never stores the address at all. The typo lives on appointments.contact_email, which staff can already edit on the appointment. No account exists, so there is nothing to re-point.
A real account with a login. This is the feature. And it is the one where the obvious manual fix — change the primary address in the Clerk dashboard — is a trap, because it repairs the login and silently breaks identity resolution.
Why the Clerk-dashboard fix is wrong
Changing the address in Clerk alone leaves humans.email stale. Nothing syncs it back: the resolver keys on provider_subject_id, so sign-in keeps working and the divergence is invisible.
It is not invisible for long, because humans.FindByEmail is the reuse-before-create key for both public booking and front-desk intake. The next time anyone types the patient's new address into either door, the lookup misses, the resolver concludes "new person", and a second patient_profiles row is created. That is precisely the split-history failure resolveOrCreateProfile exists to prevent, and it stays hidden until a clinician is reading half a record.
So the invariant this feature must hold is: Clerk and humans.email move together, or neither moves.
Shape
A request row, a token, and a confirmation. Modelled on form_sessions (000043), which solved the same problem — authorise one specific act, for a person who may not be logged in, without minting a login.
POST /v1/me/email-change self-service, authenticated
DELETE /v1/me/email-change cancel own pending request
POST /v1/organizations/{id}/patients/{pid}/email-change staff-initiated
GET /v1/organizations/{id}/patients/{pid}/email-change pending state for the front desk
DELETE /v1/organizations/{id}/patients/{pid}/email-change staff cancel
POST /v1/public/email-change/confirm the patient clicks the linkThe confirm endpoint is public, and that is the point. The patient changing their address is very often a patient who cannot sign in — the old work mailbox is gone and it is the only address on the account. A confirm route requiring authentication would answer everything except the case people actually ring about. The token is the authority, exactly as it is for a form session.
email_change_requests
Only the SHA-256 of the token is stored. The raw token is returned once, into an email body, and never persisted — same posture as form_sessions.token_hash, and for the same reason: this credential ends in a change of who owns an account.
old_email is snapshotted at request time. It is not redundant with humans.email: after the change completes, the old value is the only record of what the account used to be, and it is what the "this was requested" notice is addressed to.
organization_id is nullable, unlike almost every other table in the platform. This is not a tenant table — it hangs off humans, which has no organization_id either, because an account is platform-scope and a person's sign-in address does not belong to a clinic. NULL means the patient did it themselves; non-NULL records which clinic's front desk started it, which is exactly the forensic question worth being able to answer later.
RLS mirrors humans: a SELECT policy (own row; or staff at the initiating org holding patients.change_email), with every write grant revoked from the app pool and the service writing through the admin pool.
The flow, and where each guard sits
Request (either door):
- normalise to lowercase, RFC-parse, reject an address equal to the current one
- reject a collision against
humans_email_provider_org_unique→409 - cancel any outstanding request for this human — one live request at a time, so a second attempt cannot be confirmed by a stale link
- mint 32 random bytes, store the SHA-256, expire in 24h
- email the confirm link to the new address
- email a notice to the old address
Confirm (public, token-authorised):
- re-check the collision. Time passes between request and click, and the address may have been taken in the meantime; a check only at request time would let two accounts race onto one address and let the unique index decide the winner with a 500.
- update Clerk and
humans.email— Clerk first, because a Clerk failure must leave the platform unchanged rather than the reverse - bump
portal_credential_generation. The credential changed, so every outstanding re-entry token is stale by definition and must die. - stamp
confirmed_at, write the audit row
The notice to the old address is a security control, not a courtesy
It is sent at request time, not at completion, and that ordering is the whole value. It arrives while the change is still reversible and the window is still open, so a patient whose account someone is trying to walk off with finds out in time to say so. A completion notice to an address that has just been detached from the account tells them only that they were already too late.
A completion notice is a reasonable later addition. It is not what makes this safe.
Why staff cannot complete a change
The decision (2026-08-21) is staff initiate, patient confirms. The front desk types the new address; the platform mails the link there; nothing moves until the patient clicks it.
Letting staff change the address outright would be faster and it would be an account-takeover primitive: anyone holding the permission could re-point a patient's login at an address they control, and the audit row would prove it only afterwards. Requiring the patient to confirm from the new inbox costs the front desk nothing they are not already doing — they are on the phone with the patient — and it means the permission grants the ability to offer a change, never to make one.
It also still solves the ticket that motivated the feature: the patient has the new inbox. It is the old one they have lost.
Permission
New code patients.change_email, granted to admin and customer_support.
Deliberately not folded into patients.manage. These requests arrive at the front desk by phone and routing each one through a clinic admin would make the feature slow enough that staff work around it — but the ability to start re-pointing a login should be legible in a role's permission list rather than hidden inside a broad manage grant, precisely so that a clinic that does not want reception holding it can take it away without losing everything else patients.manage covers.
Staff-initiated requests refuse with 422 when the patient has no account yet (patient_profiles.human_id IS NULL) — there is no credential to change, and the contact detail they are looking for is on the appointment.
Notifications
Two new categories, both transactional (the recipient's relationship with the platform depends on the message arriving, so preferences and the consent ledger are bypassed) and both BillingScopePlatform — this is RestartiX talking to somebody about their RestartiX account, not a clinic talking to its patient, and it must not meter against a clinic's email quota.
| Category | To | Carries |
|---|---|---|
email_change_confirm | the new address | the one-shot link, and its expiry |
email_change_notice | the old address | that a change was requested, masked target, how to stop it |
Address-based recipients in both cases: the confirm goes to an address that belongs to no humans row yet, and the notice goes to one that is about to belong to none.
Audit
Both the request and the confirmation write audit rows against entity_type = "human". The before/after carry the actual addresses rather than masked ones — this is the forensic record, and the question it exists to answer ("who moved this account, from what, to where") cannot be answered by a***@b***.
This is an ordinary state-change audit, not a ActionRead case: the operational metadata exemption does not apply, because re-pointing a login is a state transition in the most literal sense the audit rules describe.
Known gaps, recorded rather than fixed
- A completion notice to the old address is not sent. See above.
- Duplicate profiles already created by the stale-email failure mode are not merged by this feature. Profile merge is its own problem and does not get smuggled in here.
- Staff accounts are out of scope. The endpoints are patient-shaped; a specialist changing their own sign-in address goes through the same
/v1/me/email-changeroute, which is identity-generic, but there is no staff-initiated equivalent for another staff member's account.