#+TITLE: pearl — Multi-Account Support Spec #+AUTHOR: Craig Jennings #+DATE: 2026-05-24 #+STARTUP: showall * Status *DRAFT — design proposal; nothing in =pearl.el= has changed.* Open questions for Craig at the end. Lets one Emacs talk to more than one Linear workspace — a work account and a personal account — and switch between them without re-customizing. Raised by Craig: "I have a work account and a personal account. I would like to choose which account I'm working on easily and switch back and forth between the two fairly straightforwardly." * Problem Everything that identifies a workspace is a single global value today: - =pearl-api-key= — one key. - =pearl-graphql-url= — one endpoint (always the same for Linear, but conceptually per-account). - =pearl-org-file-path= — one active file. - =pearl-default-team-id= — one team. - the lookup caches (=pearl--cache-teams=, =-states=, =-team-collections=, =-views=, =-viewer=) — global, and *workspace-specific*: a personal account's teams and a work account's teams must never bleed together. To work the other account you'd re-customize the key (and team, and file) by hand and clear the cache. There's no notion of "which account am I on." * Current state - =pearl-api-key= is a plaintext defcustom, optionally loaded from =LINEAR_API_KEY= via =pearl-load-api-key-from-env=. =pearl--headers= reads it directly and errors when unset. - =pearl-org-file-path= defaults to =gtd/linear.org=; one file holds the active view. - The caches are module-level =defvar=s; =pearl-clear-cache= resets them all. Nothing scopes them to a workspace, so switching keys without clearing the cache would serve stale (wrong-account) teams/views/viewer. - =pearl--cache-viewer= caches "who am I" — inherently per-account. * Proposed design ** The account model A new defcustom =pearl-accounts=: an alist of named accounts, each a plist of the per-workspace settings. #+begin_src elisp (setq pearl-accounts '(("work" :api-key-source (auth-source "api.linear.app" "work") :org-file "~/org/work-linear.org" :default-team-id "TEAM_WORK") ("personal" :api-key-source (auth-source "api.linear.app" "personal") :org-file "~/org/personal-linear.org" :default-team-id nil))) #+end_src Each entry carries what's currently global: the credential, the org file, the default team, and (rarely needed) the endpoint. =:api-key-source= is how the key is *found*, not the key itself (see Credentials below). =pearl-active-account= holds the current account name. =pearl-default-account= picks the one used at startup. ** Switching accounts =pearl-switch-account= (interactive): =completing-read= over =pearl-accounts= names, then: 1. Set =pearl-active-account=. 2. Resolve and bind the active account's settings — the key, the org file, the default team — so the existing code keeps reading =pearl-api-key= / =pearl-org-file-path= / =pearl-default-team-id= unchanged (they become "the active account's values"). 3. *Clear the lookup caches* — they're workspace-specific; a switch must invalidate teams/states/collections/views/viewer so the next lookup refetches against the new workspace. 4. Optionally surface the new account's org file. The cleanest implementation keeps the rest of the package oblivious: a single resolver sets the three global-looking variables from the active account, and everything downstream (=pearl--headers=, =pearl--update-org-from-issues=, =new-issue=) works as-is. The accounts layer is a front-end over the existing globals. ** Credentials Storing two API keys in plaintext customize is the wrong default. *Proposed: resolve keys through =auth-source=* (so =~/.authinfo.gpg= holds them), keyed per account. =:api-key-source= names the host + user to look up. A literal =:api-key "lin_..."= form stays supported as an escape hatch (and for the env-var path), but the documented default is encrypted auth-source. This also fixes a latent issue with the single-key model — the key sits in customize today. ** Cache isolation Two options (open question 1): - *Clear on switch* (simpler): the caches stay global =defvar=s; =switch-account= clears them. Correct as long as every switch goes through the command. Minimal change. - *Keyed by account* (stricter): the caches become per-account maps, so switching back to "work" reuses its warm cache instead of refetching. More code, better ergonomics for frequent flippers. Lean: clear-on-switch for v1 (it reuses =pearl-clear-cache=), keyed caches as a vNext nicety. ** The active-file question Each account gets its own =:org-file= (proposed), so "work" issues and "personal" issues never share a buffer. Switching accounts switches which file is active. The alternative — one file with a per-account section — fights the active-file model (one view at a time) and risks cross-account =LINEAR-ID= collisions in the same buffer. Per-account files keep the existing single-active-view model intact, just parameterized by account. ** What's account-scoped Everything that hits the API or writes the file: list/view/saved-query fetches, all the sync/save commands, field setters, comment commands, =new-issue=, the caches, the viewer identity (for comment-edit permission — "your own comments" is per-account). The account is resolved once at switch time into the globals the commands already read, so no command needs to know about accounts individually. * Proposed v1 decisions (this feature) 1. =pearl-accounts= alist (name → plist of key-source / org-file / default-team / optional url); =pearl-active-account= + =pearl-default-account=. 2. =pearl-switch-account= sets the active account, resolves its settings into the existing global variables, and clears the caches. 3. Credentials resolve through =auth-source= by default; a literal key stays supported. 4. One org file per account (=:org-file=); switching switches the active file. 5. The accounts layer is a front-end over the current globals — downstream commands are unchanged. * Files touched - =pearl.el=: =pearl-accounts= / =pearl-active-account= / =pearl-default-account= defcustoms; =pearl-switch-account= + a =pearl--resolve-account= helper that binds the globals; an =auth-source= key resolver; a one-time migration of the legacy single-key config; a transient/keymap entry for switching. - =docs/=: this spec. - =README.org=: a multi-account setup section (auth-source entries, example =pearl-accounts=). * Test plan - =pearl--resolve-account=: a named account's plist sets the expected key / org-file / default-team; an unknown name errors. - =switch-account= clears all five caches (assert each is nil after). - auth-source resolver: a stubbed =auth-source-search= yields the key; a missing entry gives a clear error naming the account, not a generic "key not set". - back-compat: with =pearl-accounts= nil and the legacy =pearl-api-key= set, everything works exactly as before (single-account path). - viewer cache: switching accounts invalidates =pearl--cache-viewer= so comment-edit permission re-resolves against the new account. * Migration Back-compatible. With =pearl-accounts= unset, the package behaves exactly as today off the legacy =pearl-api-key= / =pearl-org-file-path= / =pearl-default-team-id=. First-run-with-accounts can offer to seed a single "default" account from the legacy values. The credential move to auth-source is opt-in: the literal-key form keeps working, so no one is forced to migrate secrets to adopt accounts. * Open questions for Craig 1. *Cache strategy*: clear-on-switch (simple, refetches after every flip) or per-account keyed caches (warm on switch-back, more code)? How often do you actually flip — a few times a day, or constantly? 2. *Credential default*: auth-source / =~/.authinfo.gpg= as the documented path, with literal-key as the escape hatch — good? Or do you keep keys somewhere else (a password manager, env vars per account)? 3. *Active file*: one file per account (proposed) versus a single file you re-point — any reason you'd want both accounts' issues reachable without switching? 4. *Switch surface*: a plain =M-x pearl-switch-account=, plus a mode-line indicator of the current account? The indicator matters most — pushing a work edit while you think you're on personal (or vice versa) is the failure mode worth guarding against. 5. *Endpoint*: is =graphql-url= ever actually different per account (self-hosted / EU region), or is per-account =:url= over-engineering for two linear.app workspaces? * vNext / out of scope - Per-account keyed caches (if clear-on-switch proves annoying). - Showing both accounts at once (split buffers / frames) — explicitly out; the model is one active account at a time. - Per-account =pearl-saved-queries= (v1 shares the saved-query list across accounts; revisit if names collide or filters don't translate). - Auto-detecting the account from the active file when you visit it.