App reference

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

  1. Parked chains, one cookie per profile. eagraiclainne_refresh keeps 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 same user_uid, or the switch is refused with the standard opaque verdict.

  2. SwitchProfile is public-with-possession. Like RefreshSession, 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/CanSignIn checks under row locks), so racing switches resolve exactly like racing refreshes. In one response the outgoing chain's cookie is re-emitted as eagraiclainne_parked_<outgoing_uid> (a rename, not a rotation), the target chain rotates into eagraiclainne_refresh, a fresh access JWT rides the session cookie, and the target's old parked cookie clears. Bearer clients (Android) pass target_refresh_token explicitly and keep their own parked-token map; the server does no cookie work for them.

  3. PIN hash on the User, miss-state on the chain. pin_hash (bcrypt, exactly four digits enforced at intake) joins the SanitizeInterceptor scrub map beside password_hash. The interceptor gains its first derive step: it sets the response-only has_pin from pin_hash before 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_at and pin_attempted_at live on the session records and are copied to the successor on every rotation, like label. 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.

  4. Admins must; the fail-safe is revocation. Switching to an admin with no PIN refuses with FailedPrecondition and the stable code ADMIN_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.

  5. PINs are cleared, never read. SetPin is 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 through svc.Mutate β€” mutation trail, audit entry and live announce, like every credential change. Matrix entries: SetPin [Admin, Member] (children never hold PINs), ClearPin [Admin]; SwitchProfile joins the public-endpoint exemption list with its reason recorded in the completeness test.

  6. "Remove me from this device" gets one server assist. LogoutRequest grows forget_user_uid: when set, Logout revokes the chain in eagraiclainne_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.

  7. Switching is logged, not audited. Session bookkeeping stays outside the svc seams (ADR-0017 Β§9): switches emit structured logs and a CountProfileSwitch(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_pin is 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 SwitchProfile exchange 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.