ADR-0020: Profile switching and quick-switch PINs
Metadata
- Status: Accepted
- Date: 2026-08-07
- Deciders: EagraΓ Clainne Team
- Related: ADR-0017 (device sessions and refresh tokens β the chains this decision parks); ADR-0007 (JWT authentication); ADR-0002/0009 (authorization layers)
Context
EagraΓ Clainne lives on shared household devices β the kitchen tablet above all. Swapping the active person today means a full sign-out and a full password login, which punishes the most common movement in the house: a child checking their jobs after a parent planned the week. The product decision (grilled 2026-08-07) is a switch-user sheet: profiles already known to the device switch quickly, protected ones ask for a 4-digit PIN β admins must have one, members may opt in, children never need one. "Sign out" stays a full logout.
The constraint that shapes everything: a 4-digit PIN must never become
a network-facing credential. 10,000 combinations is not a password; a
PIN may only ever unlock a session that already lives on the device.
ADR-0017 gives each device session a refresh-token chain, but the web
client can hold exactly one β the single httpOnly eagraiclainne_refresh
cookie. Multiple resident profiles need multiple chains per device, held
somewhere page scripts cannot read (ADR-0017 already rejected
localStorage). This is an auth and service-shape change β ADR territory.
Decision
-
Parked chains, one cookie per profile.
eagraiclainne_refreshkeeps its meaning: the single ACTIVE chain. Each resident-but-inactive profile's chain rides its own cookie,eagraiclainne_parked_<uid>β same path scope (/api.core.v1.UserService/), httpOnly, Secure, SameSite=Strict, Max-Age = the 90-day idle expiry. Parked cookies are never presented for refresh, so a parked chain idles out exactly like an absent device and that profile falls back to password β no new expiry machinery. Because cookie names are client-controlled in principle, the server never trusts the<uid>in the name: the token record found by hash must carry the sameuser_uid, or the switch is refused with the standard opaque verdict. -
SwitchProfileis public-with-possession. LikeRefreshSession, it is served before authentication: the proof is possession of the target's parked chain, plus the PIN when the target has one. It must be β the sheet also lives on the sign-in screen, where no access token exists. The exchange itself is a full ADR-0017 rotation of the target chain (revoked/grace/idle/CanSignInchecks under row locks), so racing switches resolve exactly like racing refreshes. In one response the outgoing chain's cookie is re-emitted aseagraiclainne_parked_<outgoing_uid>(a rename, not a rotation), the target chain rotates intoeagraiclainne_refresh, a fresh access JWT rides the session cookie, and the target's old parked cookie clears. Bearer clients (Android) passtarget_refresh_tokenexplicitly and keep their own parked-token map; the server does no cookie work for them. -
PIN hash on the User, miss-state on the chain.
pin_hash(bcrypt, exactly four digits enforced at intake) joins theSanitizeInterceptorscrub map besidepassword_hash. The interceptor gains its first derive step: it sets the response-onlyhas_pinfrompin_hashbefore clearing it, so every response tree that carries a User states PIN presence without any handler remembering to. Failed attempts are chain state:pin_misses,pin_locked_atandpin_attempted_atlive on the session records and are copied to the successor on every rotation, likelabel. The fifth consecutive miss locks the chain for PIN switching β refused before the hash comparison, so a locked chain yields no oracle β and only a fresh password login clears it, because the fresh chain simply replaces the locked one. Attempts inside ~2 seconds of the last are refused outright, blunting scripted hammering. -
Admins must; the fail-safe is revocation. Switching to an admin with no PIN refuses with
FailedPreconditionand the stable codeADMIN_PIN_REQUIREDβ the client maps it to the set-a-PIN prompt. Switching or logging in away from a PIN-less admin revokes their chain instead of parking it: an unprotected admin session never sits resident on a shared device. Children and PIN-less members park and switch freely β possession of the parked chain is their proof. -
PINs are cleared, never read.
SetPinis strictly self-service (no uid field,ChangePassword's design) and proves the current password; an empty pin clears the caller's own.ClearPin(user_uid)is ADMIN-only recovery. Both mutate the User throughsvc.Mutateβ mutation trail, audit entry and live announce, like every credential change. Matrix entries:SetPin[Admin, Member] (children never hold PINs),ClearPin[Admin];SwitchProfilejoins the public-endpoint exemption list with its reason recorded in the completeness test. -
"Remove me from this device" gets one server assist.
LogoutRequestgrowsforget_user_uid: when set, Logout revokes the chain ineagraiclainne_parked_<uid>and clears that cookie β possession is the authority, the same proof plain Logout accepts β without touching the active session. Everything else about forgetting a profile (prefs, offline cache, picker entry) stays client-side. -
Switching is logged, not audited. Session bookkeeping stays outside the
svcseams (ADR-0017 Β§9): switches emit structured logs and aCountProfileSwitch(outcome)telemetry counter, no audit rows. The argument for auditing ("who acted as whom" is household history) loses to the counterargument that switch events would put chain uids in the audit log and that every action taken after a switch is already audited under the acting user.
Consequences
- A parked profile untouched for 90 days silently falls back to password β cookie and chain expire together, and the sheet's row keeps working, it just asks for more.
- The sanitize interceptor is no longer purely subtractive:
has_pinis derived residue. The scrub map's guarantee ("no secret leaves") is unchanged; the new guarantee ("presence is always stated") is the same structural kind. - Cookie count grows by one per resident profile. Browsers allow ~180 per host at 4KB each; a 43-character token per profile keeps even an implausibly large household far under both bounds.
- The web's offline no-PIN switch (client-side swap) leaves the cookies
belonging to the previous user until reconnect. The client must
complete the real
SwitchProfileexchange before any authenticated traffic once back online β a client obligation this ADR names so the web wave implements it as a gate, not a hope. - PIN lock state is per chain, not per account: a locked kitchen tablet does not lock the same member's phone. That is the intended blast radius.
Alternatives considered
- One JSON map cookie for all parked tokens. Rejected: 4KB ceiling, whole-map rewrites on every change, no per-entry clearing, and one parse bug away from cross-profile leakage.
- Server-side parked-token store keyed by a device-id cookie. Rejected: invents a second credential (the device id) with chain-level power, duplicates chain state the session table already holds, and adds a table for what cookies already do.
- localStorage for parked tokens. Rejected in ADR-0017 already: XSS reads it; httpOnly cookies exist precisely to keep tokens away from page scripts.
- PIN lock state on the User record. Rejected: locks every device at once (wrong blast radius), and resetting it would need its own mutation path; chain state dies with the chain for free.
- A separate PIN-attempts table. Rejected: attempts are facts about a chain, and the chain's records already rotate, lock and expire with exactly the right lifetime.
Additional Notes
Surface tiers (system rule 1): web and Android carry the full switch flow; MCP and CLI deliberately do not β machine surfaces authenticate with API keys and have no shared-tablet problem. The gap is recorded here and in the landing commits.