#+TITLE: pearl — Multi-Account Support Spec #+AUTHOR: Craig Jennings #+DATE: 2026-05-24 #+STARTUP: showall * Status *Reviews incorporated through round 3; rubric =Ready* (Craig, 2026-05-25).* The original "resolve globals on switch, downstream oblivious" model leaked across accounts in an async codebase; this spec replaces it with an explicit account-context layer — dispatch-time snapshots, an implementation-boundary rule for where context is captured/applied, per-buffer =#+LINEAR-ACCOUNT= ownership with wrong/unmarked-buffer guards, a runtime (not persisted) active-account, a mode-line indicator, and an exact startup rule. The five safety calls (finish-into-snapshot, wrong-buffer refuse, unmarked-mutation refuse, saved-query =:account= guard, optional =:url=) are *adopted as final v1 decisions* — each the safer default and round-3-endorsed. Modified recommendations are in Review Dispositions. Lets one Emacs talk to more than one Linear workspace — a work account and a personal account — and switch between them safely. 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," and — the danger the reviewer surfaced — nothing stops a work command from running under personal credentials or a work fetch from landing in the personal file. * Current state - =pearl-api-key= is a plaintext defcustom, optionally loaded from =LINEAR_API_KEY=. =pearl--headers= reads it directly and errors when unset. - =pearl-org-file-path= defaults to =gtd/linear.org=; one file holds the active view. - *Requests and writers read the globals late, not at dispatch.* =pearl--graphql-request-async= reads the key/url when the request starts, and =pearl--update-org-from-issues= reads =pearl-org-file-path= at *callback/render* time — so a global changed mid-flight is read by the in-flight callback. This is the async-leak surface. - The caches are module-level =defvar=s; =pearl-clear-cache= resets them all. Nothing scopes them to a workspace. - =pearl--cache-viewer= caches "who am I" — inherently per-account. - Rendered files carry =#+LINEAR-SOURCE= / run-at / filter / count / truncated, but *no account identity*. * Proposed design ** The account model A new defcustom =pearl-accounts=: an alist of named accounts, each a plist of per-workspace settings. #+begin_src elisp (setq pearl-accounts '(("work" :api-key-source (:auth-source :host "api.linear.app" :user "work") :org-file "~/org/work-linear.org" :default-team-id "TEAM_WORK") ("personal" :api-key-source (:env "LINEAR_PERSONAL_API_KEY") :org-file "~/org/personal-linear.org" :default-team-id nil))) #+end_src =:api-key-source= is how the key is *found*, not the key itself — a concrete tagged plist: =(:auth-source :host H :user U)=, =(:env "VAR")=, or =(:literal "lin_...")= as an escape hatch. =:org-file= and the optional =:url= round it out. =pearl-default-account= is a defcustom (the user's durable preference for the startup account). =pearl-active-account= is *runtime state*, a plain =defvar= — never a persisted defcustom: the active account is which one you're on right now, and =pearl-switch-account= sets it with =setq=, never =customize-save-variable=. Persisting "active" would make the last switch stick across sessions in a surprising way and fight =pearl-default-account=. ** Account-context layer (the core of the safety model) Rather than mutating globals and hoping downstream is oblivious, account state flows through an explicit context: - =pearl--resolve-account= (NAME) → a context plist =(:name :api-key :org-file :default-team-id :url)=, with =:api-key= resolved from =:api-key-source= and =:org-file= expanded. Errors clearly when the account or its key is missing. - =pearl--current-account-context= returns the active account's context (resolving =pearl-default-account= on first need; see Startup). - =pearl--with-account-context= (CONTEXT &rest body) dynamically binds the existing globals (=pearl-api-key=, =pearl-org-file-path=, =pearl-default-team-id=, =pearl-graphql-url=) to the context's values for BODY. Most downstream function *bodies* stay unchanged — they still read those globals — but now they read them under a controlled binding rather than a mutable global. (This replaces the draft's "downstream commands are unchanged" claim, which the review correctly rejected.) ** Account context and async safety (HP1) Every API operation *snapshots* its account context at dispatch time. The snapshot is the =pearl--resolve-account= plist captured before the request fires. Render and mutate callbacks act against the *snapshot's* org file / key, never the current global. *v1 decision (confirm):* if the active account changed before a callback completes, the callback still finishes into its snapshot — writing the work result to the work file, with a status message naming the account ("Updated work: 24 issues"). It does *not* write to whatever file is now current, and it does *not* silently drop the result the user asked for. (The reviewer offered finish-into-snapshot or drop-stale; finish-into-snapshot is chosen because the fetch was an explicit user request whose data belongs to its own account's file regardless of a later switch, and round 2 endorsed it as the right default.) *Implementation boundary (where the snapshot is captured and applied).* So a single missed render path can't reintroduce the leak: - every public API/file command resolves =pearl--current-account-context= once at entry; - async callbacks that render or mutate local state run *inside* =pearl--with-account-context= bound to that captured context — binding at dispatch alone isn't enough, because the late reads (e.g. =pearl--update-org-from-issues= reading =pearl-org-file-path=) happen in the callback body; - shared render functions take an explicit context or target-file argument where practical, especially =pearl--update-org-from-issues=, rather than relying on the dynamic binding reaching them; - raw =let= bindings of =pearl-api-key= / =pearl-org-file-path= scattered through command bodies are avoided — the macro and the explicit args are the only two paths, so the safety model stays auditable. ** Account file ownership (HP2) Rendered files carry =#+LINEAR-ACCOUNT: = in the header. Every command that hits the API or writes the file calls a shared guard first, =pearl--require-account-context= (or similar): - buffer account == active account → proceed; - buffer account is configured but *not* active → *refuse* (v1, not auto-switch — auto-switch hides a state transition right before a remote mutation) with a message naming *both* accounts: "This file belongs to work; active account is personal. Run =M-x pearl-switch-account= first." - *no account marker* while =pearl-accounts= is non-nil → non-mutating render/list commands may proceed (they don't risk wrong-account writes the way a mutation does), but *mutating* commands refuse: "No LINEAR-ACCOUNT in this file; refresh it under an account first." One refresh stamps ownership. (With =pearl-accounts= nil, an unmarked file is just legacy single-account behavior — no guard.) The mode-line indicator tells you the active account; this guard enforces it. Visibility alone is insufficient — the file/command context needs enforcement. ** Active-account indicator (HP3, v1 requirement) A visible current-account indicator ships in v1, not as polish. The *v1 requirement* is a global mode-line lighter (=Pearl[work]=) — global, and easy to test after =pearl-switch-account=; a buffer-local header-line on account files is optional polish on top. The user must be able to tell the active account before running a mutating command. Pushing a work edit while thinking you're on personal is the failure mode this whole feature exists to prevent. ** Switching accounts =pearl-switch-account= (interactive): =completing-read= over =pearl-accounts= names, then: 1. Resolve the account's context (=pearl--resolve-account=). 2. Set =pearl-active-account= and install the context into the globals (the binding the rest of the package reads). 3. *Clear the account-scoped caches* via =pearl--clear-account-scoped-state= (below). 4. Update the indicator and optionally surface the account's org file. ** Credentials (MP1) Keys resolve through =:api-key-source= at context-resolution time. =auth-source= (=~/.authinfo.gpg=) is the documented default; =:env= and =:literal= are supported. Hard rules: a resolved key is assigned with =setq= (or a dynamic binding) only — *never* =customize-save-variable= — so secrets don't land in =custom.el=; and debug/setup output must never log the key (a missing-key error names the account, e.g. "No API key for account work", without dumping the lookup). =pearl-load-api-key-from-env= stays legacy-only. With =pearl-accounts= nil it works as today (loads =LINEAR_API_KEY= into the global). With accounts configured it refuses ("Accounts are configured; put =:api-key-source (:env \"...\")= in =pearl-accounts=") so it can't bypass the resolver and overwrite the active account's key in memory. ** Cache isolation (MP3) Clear-on-switch for v1 (keyed-per-account caches are vNext). =pearl--clear-account-scoped-state= is the single helper that clears every account-scoped cache — the five Linear lookups (=teams=, =states=, =team-collections=, =views=, =viewer=) plus any future account-derived state (e.g. the workflow-state-derived TODO keyword caches, if that feature lands). =pearl-switch-account= calls this helper rather than re-implementing cache semantics, so a newly added account-scoped cache is registered in one place. A test asserts every cache the helper names is nil after a switch. ** Saved queries (MP2) v1 keeps =pearl-saved-queries= a single shared list, but a query entry may carry an optional =:account= field alongside =:filter= / =:sort= / =:order=: #+begin_src elisp ("My work bugs" :account "work" :filter (:team "ENG" :label "bug" :assignee :me)) #+end_src =pearl-run-saved-query= checks =:account= *before* any name-to-id filter compilation or network call: if =:account= names a different account than the active one, it refuses immediately ("Saved query 'My work bugs' belongs to work; switch accounts first"). The filter's team/state/label names resolve against the active workspace, so "Backlog" can silently mean different things or nothing across accounts — the guard catches that before a wrong-account fetch. The README warns that saved queries without =:account= are shared and resolved against whatever account is active. (Full account-scoping is vNext.) ** Startup / default resolution (HP4) Exactly one rule: - =pearl-accounts= nil → the legacy globals are used unchanged (single-account, today's behavior). - =pearl-accounts= non-nil and =pearl-active-account= nil → the first command that needs account context resolves =pearl-default-account=; if no default is configured, it errors with a message telling the user to set one or run =pearl-switch-account=. - =pearl-switch-account= and =pearl--current-account-context= are the only paths that install/return account settings. ** What's account-scoped Everything that hits the API or writes the file: list/view/saved-query fetches, the sync/save commands, field setters, comment commands, =new-issue=, the caches, and the viewer identity (comment-edit permission is per-account). All of it flows through the context layer: snapshot at dispatch, bind for the body, guard the buffer. * Proposed v1 decisions (this feature) 1. =pearl-accounts= defcustom (name → plist: =:api-key-source=, =:org-file=, =:default-team-id=, optional =:url=); =pearl-default-account= defcustom; =pearl-active-account= a runtime =defvar=, set with =setq=, never persisted. 2. An account-context layer (=pearl--resolve-account= → plist, =pearl--with-account-context=, =pearl--current-account-context=); downstream bodies stay mostly unchanged behind it, with the implementation-boundary rule (entry resolves context; callbacks run inside the context macro; shared render fns take an explicit target where late reads are dangerous). 3. Dispatch-time context snapshots; a callback finishes into its snapshot's file/context even if the account switched mid-flight, messaging the account. 4. =#+LINEAR-ACCOUNT:= header on rendered files + a shared buffer guard: refuse a command from another account's file (naming both accounts); from an unmarked file under accounts, render/list may proceed but mutations refuse until a refresh stamps ownership. 5. A mode-line lighter is the v1 active-account indicator (header-line optional). 6. Credentials via a tagged =:api-key-source= plist (auth-source default, env + literal supported); resolved keys never persisted via Customize, never logged; =pearl-load-api-key-from-env= is legacy-only and refuses once accounts are configured. 7. Clear-on-switch via =pearl--clear-account-scoped-state= (keyed caches vNext). 8. Saved queries shared with an optional =:account= guard checked before filter compilation + a README warning. 9. Exact startup rule; passive migration. Decisions 3, 4, and 8 (and the unmarked-mutation refusal in 4) were the safety calls; they are now *final* — adopted as the safer defaults, endorsed across the reviews. * Resolved safety decisions The five safety calls are final (adopted as the safer defaults, round-3-endorsed): 1. *Async-stale callback*: finish into the snapshot's file with an account-named message (not drop). 2. *Wrong-account buffer*: refuse with a message naming both accounts (not auto-switch). 3. *Unmarked file under accounts*: render/list proceed; mutations refuse until a refresh stamps =#+LINEAR-ACCOUNT=. 4. *Saved queries*: shared list + optional =:account= guard checked before filter compilation (not fully account-scoped in v1). 5. *Endpoint* =:url=: optional-but-supported through the same resolver. The vNext alternatives (drop-stale, auto-switch, fully account-scoped queries) stay in vNext if a real need appears. * Files touched - =pearl.el=: the =pearl-accounts= / =pearl-active-account= / =pearl-default-account= defcustoms; the context layer (=pearl--resolve-account=, =pearl--current-account-context=, =pearl--with-account-context=); the =:api-key-source= resolver; =pearl-switch-account= + =pearl--clear-account-scoped-state=; =#+LINEAR-ACCOUNT= emission in =pearl--build-org-content= and the =pearl--require-account-context= buffer guard wired into the API/file commands; dispatch-time context capture in the async entry points + render/mutate callbacks; the mode-line/header indicator; passive legacy fallback. - =docs/=: this spec. - =README.org=: a multi-account section (auth-source entries, example =pearl-accounts=, the saved-query warning, the indicator). * Test plan - =pearl--resolve-account=: a named account's plist resolves the expected key / org-file (expanded) / default-team / optional url; an unknown name errors. - =:api-key-source= resolver: stubbed auth-source yields the key; =:env= reads the variable; a missing entry errors naming the account ("No API key for account work") without leaking lookup internals; the resolved key is never persisted through Customize. - *Async safety (HP1):* dispatch a list fetch under "work", switch to "personal" before invoking the captured callback, assert the result writes the *work* org file (or is dropped, per the chosen rule). - *Buffer guard (HP2):* render a work file with =#+LINEAR-ACCOUNT: work=, set active to personal, assert mutating commands (=refresh-current-view=, =save-issue=, a field setter, a comment command, =delete-current-issue=) refuse *before* any API call. - *Startup (HP4):* a command run with accounts configured but no active account resolves =pearl-default-account=, or errors when none is set. - =pearl--clear-account-scoped-state=: every cache it names is nil after =switch-account=; the viewer cache invalidates so comment-edit permission re-resolves. - Back-compat: =pearl-accounts= nil + legacy =pearl-api-key= → unchanged single-account behavior. - =pearl-active-account= is runtime state — set by =switch-account= with =setq=, not persisted through Customize. - =pearl-load-api-key-from-env= refuses when =pearl-accounts= is non-nil; works as legacy when nil. - Unmarked buffer + accounts enabled: a render/list command proceeds, a mutating command refuses ("No LINEAR-ACCOUNT…"). - Saved query with =:account= refuses under a different active account *before* any filter compilation or resolver/network call. - Indicator: the mode-line lighter updates after =pearl-switch-account=. - Killed-buffer / lost-marker safety on a render callback that completes after the buffer is gone. * Migration (MP4) Passive. With =pearl-accounts= unset, the package behaves exactly as today off the legacy globals. When accounts are configured, the README shows how to create a default account. The credential move to auth-source is opt-in (the =:literal= / env forms keep working). Interactive first-run seeding is vNext, not v1. * Review Dispositions *Round 1 (Codex, 2026-05-25).* Rubric =Not ready=. The four blocking findings (HP1 async cross-account writes, HP2 wrong-account buffer commands, HP3 indicator-is-v1, HP4 startup resolution) are accepted and drove the new account-context layer, the dispatch-time snapshot rule, the =#+LINEAR-ACCOUNT= buffer guard, the required indicator, and the exact startup rule. MP1 (concrete credential plist, setq-only, no logging) and MP4 (passive migration) accepted as written. The architecture, UX, robustness, and test-strategy observations are folded into the body and test plan. Two were modified: - *MP2 (saved queries) — modified to a choice.* The reviewer offered account-scoped lists, an optional =:account= guard, or document-and-warn. Chose the optional =:account= guard plus a shared default and a README warning: it keeps the v1 saved-query store unchanged (one list) while giving a refusal path for queries that must not cross accounts, where full account-scoping would add a persistence/UX layer not yet justified for two workspaces. - *MP3 (cache clearing) — modified the test framing.* Accepted the helper (=pearl--clear-account-scoped-state= as the single source of truth, listing every account-scoped cache including future derived state). Softened the reviewer's "a test must fail when a new account-scoped cache is added without invalidation" — you can't test for an unwritten future cache directly. Instead the helper centralizes the list so a new cache is registered in one place, and a test asserts each cache the helper names is nil after a switch. Also, the reviewer's three open questions and the HP1 finish-vs-drop choice are baked as proposed v1 decisions (finish-into-snapshot, refuse-on-mismatch, =:account= guard) and surfaced under Open Questions for Craig's confirmation rather than left unresolved. Everything else accepted as written. *Round 2 (Codex, 2026-05-25).* Rubric moved to =Ready with caveats=. All findings accepted as written, no modifications or rejects: HP1 (the implementation-boundary rule — entry resolves context, callbacks run inside the context macro, shared render fns take an explicit target), HP2 (the precise unmarked-buffer rule — render/list proceed, mutations refuse until a refresh stamps ownership), HP3 (=pearl-active-account= is a runtime =defvar=, not a persisted defcustom — a genuine correction to the round-1 draft), MP1 (=pearl-load-api-key-from-env= legacy-only, refuses under accounts), MP2 (=:account= lives in the saved-query entry and is checked before filter compilation, with an example), MP3 (mode-line lighter is the v1 indicator, header-line optional), plus the UX refusal-message-names-both-accounts and the added tests. The remaining caveats are the safety calls, now endorsed by the reviewer. *Round 3 (Codex, 2026-05-25).* Rubric =Ready with caveats=; no new findings — MP1-MP5 were each "confirm decision X" on the five safety calls, all endorsed as the safer defaults. Resolved by adopting all five as final v1 decisions (Craig granted the latitude to adopt the review's recommendations directly). Rubric -> =Ready=. * vNext / out of scope - Per-account keyed caches (warm switch-back), if clear-on-switch proves annoying. - Auto-switch (instead of refuse) from a visited account file. - Showing both accounts at once (split buffers / frames) — the model is one active account at a time. - Fully account-scoped =pearl-saved-queries=. - Interactive migration/seeding from the legacy single-account globals. - Auto-detecting the account from the active file on visit.