Skip to content

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:

  1. The DB grant. 000002 runs REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON humans FROM restartix_app. The app pool cannot write the table at all.
  2. The application allow-list. human.Repository.UpdateSelfServiceFields accepts exactly preferred_language, name, timezone, and its doc comment states the allow-list exists so that "a future caller" cannot reach email or blocked through it.
  3. 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 link

The 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_unique409
  • 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.

CategoryToCarries
email_change_confirmthe new addressthe one-shot link, and its expiry
email_change_noticethe old addressthat 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-change route, which is identity-generic, but there is no staff-initiated equivalent for another staff member's account.