#+TITLE: Spec: local views and Linear views #+AUTHOR: Craig Jennings #+DATE: 2026-06-01 * Status *Draft — awaiting Craig's review.* Triggered by the 2026-06-01 conversation that started as "how do I create a saved query?" and surfaced that the saved-query operations are scattered inconsistently across the verb-first keymap: creation is hidden inside the filter builder and filed under fetch, deletion has a proper home, and editing doesn't exist at all. The conversation converged on a cleaner mental model — one familiar noun, =view=, qualified by where it lives: a *local view* (private, on your machine) and a *Linear view* (published, shared, the =CustomView= Linear users already know). This spec captures that model and the rename + reshape needed to adopt it. This is the successor framing to =docs/saved-query-sync-spec.org= (which shipped the local→Linear sync) and =docs/issue-sources-spec.org= (which unified the fetch surface behind =pearl-pick-source=). It does not redo their work; it renames and re-homes it so the whole lifecycle speaks one vocabulary. *2026-06-01 addition:* bidirectional copy. Craig asked for both directions as first-class operations — *copy down* (a Linear view → a new, editable, renamable local view) and *copy up* (a local view → a Linear view). Copy-down is the piece the first draft had deferred to vNext; it is now in scope, which pulls the =IssueFilter= reverse-compile (and its representability boundary) into the main design. * Problem Pearl already has the machinery for named, reusable filters and for pushing them up to Linear. What it lacks is a coherent *name* and a coherent *command shape*, and the gap shows up the moment a user goes looking for an operation by the noun: - The thing is called a "saved query" — a term Pearl invented. Linear has no "query" and no "saved query." A Linear user arrives knowing "filter" (the conditions) and "view" (the saved, shareable thing), and finds neither word. - The command that *creates* a saved query is named =pearl-list-issues-filtered= ("build a filter"), lives under the *fetch* group (=C-; L f f=), and saves only as a yes/no afterthought at the end of the builder. The *create* group (=C-; L c=) holds "create issue" and "create comment" but no "create saved query." A user looking in the obvious place finds nothing. - "saved query" appears under *delete* (=C-; L k q=) but not under *create*. It is a deletable noun that is not a creatable one — the asymmetry that sends people hunting. - There is no *edit* at all. The edit group covers description / state / assignee / labels / comment (all issue-level); a saved query's filter, sort, or order can only be changed by delete-and-recreate or by hand-editing =pearl-saved-queries= in =init.el=. - "view" is overloaded. =pearl-run-view= ("custom view", =C-; L f v=) runs a Linear =CustomView=; the transient has a "View" column for open-in-browser; and "viewing" a saved query is actually "fetch saved query". The word collides three ways with no home for the saved-filter concept. The CRUD lifecycle currently reads C(hidden, misfiled) R(fetch) U(absent) D(delete) — three of four verbs land where a user would not predict. * Non-goals - *Not* a rewrite of the sync engine. =customViewCreate/Update/Delete=, the collision (Replace/Rename/Cancel) flow, the scope/shared prompt, and the =:linear-view-*= tracking metadata all stay as built in =saved-query-sync-spec.org=. They get renamed, not redesigned. - *Not* a change to how issues render or save. This spec touches the source/view surface only. - *Not* OR-logic filters. Local views stay AND-only in v1, same as saved queries today; OR lives in a Linear Custom View authored on Linear's side. - *Not* syncing =:sort= / =:order= up to Linear. Still unsupported by =CustomViewCreateInput= (probe-confirmed 2026-05-28); sort/order stay local-only conveniences. * The model One noun — *view* — with a qualifier that names where it lives. The qualifier is the only real difference: visibility and location. | Concept | Name | Linear's own term | |--------------------+---------------+-------------------------------------------| | The conditions | filter | filter (=IssueFilter=) | | Saved, on disk | local view | (no analog — a local filter you named) | | Published, shared | Linear view | View (=CustomView=, wraps an =IssueFilter=) | The lifecycle is git-shaped, which is the analogy that makes it self-explanatory: | Pearl | git | |--------------------------------------------+-------------------------------------------| | local view | local branch | | Linear view | remote branch | | copy up (local view → Linear view) | push — creates a tracked Linear mirror | | copy down (Linear view → local view) | branch off — a new, independent local view | | the =:linear-view-id= link | the upstream tracking ref (copy-up only) | A view can have a local copy, a Linear copy, or both. Pearl already stores exactly this: a =pearl-saved-queries= entry with no =:linear-view-id= is local-only; one *with* a =:linear-view-id= is a local view that has been published and now tracks a Linear view. The data model is already git-shaped — this spec names it. *Copy is the verb for moving a view between the two stores, in both directions, and the two directions are deliberately asymmetric:* - *Copy up* (local view → Linear view) is *publish* — it creates a Linear view and keeps the =:linear-view-id= link, so editing the local view and copying up again *updates the same Linear view in place* rather than spawning duplicates. This is the existing sync machinery. - *Copy down* (Linear view → local view) is a *fork* — it duplicates the Linear view's filter into a new, independent local view you then own, edit, and rename freely. It does *not* track the source: you copied it down precisely to diverge from it, and once you rename it, a live link would only mislead. Provenance may be recorded for reference (a =:copied-from-view-id=), but it is not a sync link. The asymmetry follows from one principle: *the local view is the editable source of truth.* Copy-up keeps a Linear mirror synced to that source; copy-down forks a Linear view into a *new* local source you take ownership of. If, after copying down and editing, you want it back on Linear, you copy up — which creates a new, separately tracked Linear view. Two facts the model has to keep honest: 1. *=filter= is not retired.* It stays the conditions-building step (Linear's "Filter" button) and the on-disk authoring plist. You *filter* to assemble conditions; when you name and keep the result, it is a *local view*. An unsaved run is an *ad-hoc filter* — not yet any kind of view. 2. *Not every Linear view has a local view behind it.* A =CustomView= authored in Linear's web UI, or a teammate's favorite, is a Linear view with no local tracking copy — a remote branch with no local branch. Pearl runs those via the picker today; the model accommodates them as Linear-only views. * Current state (what exists, what's missing) ** Exists - =pearl-saved-queries= (defcustom, alist =(NAME . SPEC)=) — the local-view store. SPEC carries =:filter=, optional =:sort= / =:order=, and (when published) =:linear-view-id=, =:linear-view-team-id=, =:linear-view-shared=, =:linear-view-url=. - =pearl-list-issues-filtered= (=C-; L f f=) — builds an ad-hoc filter interactively, runs it, and offers to save it (=pearl--save-query=, persisted via =customize-save-variable=). This is the de-facto *create* path. - =pearl-run-saved-query= (=C-; L f q=) — runs a named local view's stored filter. - =pearl-delete-saved-query= (=C-; L k q=) — deletes a local view, optionally its linked Linear view too. - =pearl-sync-saved-query-to-linear= (=C-; L f S=) — publishes a local view as a =CustomView=; first sync prompts scope + visibility, re-sync updates in place, name collisions prompt Replace/Rename/Cancel; stamps the =:linear-view-*= keys back. - =pearl-publish-current-source= (=C-; L f P=) — reads the buffer's =#+LINEAR-SOURCE=; if it names a local view, publishes that one. - =pearl-run-view= (=C-; L f v=, "custom view") — runs a Linear =CustomView= by id. - =pearl-pick-source= (=C-; L f s=) — the unified runner: lists Linear favorites first, then local views, each tagged (=[saved]=, =[saved → SCOPE]=, =[KIND]=). Already spans the local/Linear split and dispatches synced entries through the view branch. - =pearl--saved-query-scope-label= / =pearl--pick-source-candidates= — render the kind/scope labels. ** Missing - No *create* command under the create group — creation is a side effect of the filter builder. - No *edit* command at all — a local view's filter/name/sort/order can't be changed in Emacs. - No *copy down* — a Linear view authored on Linear's side can be *run* but not copied into an editable local view (requires reverse-compiling =IssueFilter= back into Pearl's authoring plist; the compiler is currently one-way). - The vocabulary: nothing says "view"; everything says "saved query". * Proposed design ** Vocabulary Adopt =filter= / =local view= / =Linear view= everywhere user-facing: command names, labels, prompts, transient groups, README, docstrings. "saved query" disappears from the surface (kept only as obsolete aliases, below). ** Rename map User-facing renames, each with a back-compat alias (see Migration): | Today | Proposed | Kind | |------------------------------------+--------------------------------+------------------------------| | =pearl-saved-queries= (defcustom) | =pearl-local-views= | =define-obsolete-variable-alias= | | =pearl-run-saved-query= | =pearl-run-local-view= | =define-obsolete-function-alias= | | =pearl-delete-saved-query= | =pearl-delete-local-view= | =define-obsolete-function-alias= | | =pearl-sync-saved-query-to-linear= | =pearl-publish-local-view= | =define-obsolete-function-alias= | | =pearl-run-view= | =pearl-run-linear-view= | =define-obsolete-function-alias= | | =pearl-publish-current-source= | =pearl-publish-current-view= | =define-obsolete-function-alias= | New commands: | New | What it does | |------------------------------+------------------------------------------------------------------------------| | =pearl-create-local-view= | Build a filter interactively and save it as a named local view (create slot) | | =pearl-edit-local-view= | Edit an existing local view: re-run the builder pre-seeded with its filter, and adjust name / sort / order | | =pearl-copy-view-from-linear= | *Copy down.* Pick a Linear view (custom view / favorite), reverse-compile its filter, and save it as a new, independent, editable local view (prompts for a name) | | =pearl-copy-view-to-linear= | *Copy up.* Publish a local view as a Linear view (the existing sync machinery, framed as copy; re-copy updates the tracked view in place) | =pearl-copy-view-to-linear= is the same operation as =pearl-publish-local-view= in the rename map above — copy-up *is* publish. The spec carries both names so the copy/copy symmetry is visible; Open question 7 settles which is the user-facing primary and which is the alias. =pearl-list-issues-filtered= stays as the *ad-hoc, save-less* filter run (rename optional; see Open question 2). =pearl-create-local-view= becomes the deliberate create path; it may share the builder internals with =pearl-list-issues-filtered= and =pearl-edit-local-view=. ** Command surface, by verb Every view operation hangs off the *view* noun under the verb where a user would look: - *create* a local view — =pearl-create-local-view= - *edit* a local view — =pearl-edit-local-view= - *run* a view (local or Linear) — =pearl-pick-source= (unified) plus direct =pearl-run-local-view= / =pearl-run-linear-view= - *delete* a local view — =pearl-delete-local-view= - *copy up* a local view → Linear view (publish) — =pearl-copy-view-to-linear= / =pearl-publish-local-view= (and =pearl-publish-current-view=) - *copy down* a Linear view → local view (fork) — =pearl-copy-view-from-linear= ** Keymap reshape (proposed — keys open to review) Goal: the =view= noun reachable under each verb, with a consistent sub-key. The =f= (fetch) group's existing keys collide if both local and Linear view runs want =v=, so one proposal: | Chord | Command | |-------------+-------------------------------| | =C-; L c v= | =pearl-create-local-view= | | =C-; L e v= | =pearl-edit-local-view= | | =C-; L k v= | =pearl-delete-local-view= | | =C-; L f s= | =pearl-pick-source= (run any view) | | =C-; L f l= | =pearl-run-local-view= | | =C-; L f v= | =pearl-run-linear-view= | | =C-; L f p= | =pearl-copy-view-to-linear= (publish) | | =C-; L f P= | =pearl-publish-current-view= | | =C-; L f d= | =pearl-copy-view-from-linear= (copy down) | The old =q= (saved query) bindings stay live via the obsolete aliases during the deprecation window; the help labels switch to the new vocabulary immediately. Exact key letters are the most reviewable detail here — the principle (one noun, present under every applicable verb) is the load-bearing part. ** Transient reshape The =pearl-menu= "Workspace" section currently splits Fetch / View / Setup. Reword so the run/publish/create/edit/delete of views read as one family. A "Views" sub-group listing create / edit / run local / run Linear / publish / delete keeps the noun together, while ad-hoc filtering and the by-dimension fetches stay in Fetch. ** Picker labels Reword =pearl--pick-source-candidates= / =pearl--saved-query-scope-label= so the local/Linear split is legible at a glance (exact wording is Open question 6): - local-only: =[local] NAME= - published local: =[local → Linear:SCOPE] NAME= (shows the tracking link and where it lives) - native Linear view / favorite: =[linear] TITLE= or =[KIND] TITLE= This directly answers the "which of my views are public?" question Craig wanted to see — the label *is* the public/private indicator. ** Copy up (local view → Linear view) This is the existing publish/sync path, unchanged in behavior. =pearl-copy-view-to-linear= picks a local view, creates a =CustomView= on first copy (prompting scope + visibility), and on a re-copy updates the tracked view in place via =customViewUpdate= against the stored =:linear-view-id=. Name collisions in the target scope prompt Replace/Rename/Cancel. The only change here is vocabulary: "sync" becomes "copy up", and the command is presented as the symmetric partner of copy-down. No new engine work. ** Copy down (Linear view → local view) This is the one direction that needs new engine work, and Craig wants the result *editable and renamable* — so an opaque, run-only copy is not enough. The local view it produces has to carry a real authoring filter that =pearl-edit-local-view= can re-open in the builder. *The reverse-compile.* Pearl's filter compiler is one-way today: authoring plist → =IssueFilter= JSON (=pearl--build-issue-filter=). Copy-down needs the inverse — read a Linear view's =filterData= (an =IssueFilter=) and reconstruct the authoring plist. The compiler's output is a flat AND of a fixed dimension set, each with one operator shape: | Authoring key | =IssueFilter= shape Pearl emits / inverts | |--------------------------------+---------------------------------------------------| | =:assignee :me= | =assignee.isMe = true= | | =:assignee= EMAIL | =assignee.email.eq= | | =:assignee-id= ID | =assignee.id.eq= | | =:state= ... | =state= (per =pearl--compile-state-filter=) | | =:project= ID | =project.id.eq= | | =:team= KEY | =team.key.eq= | | =:labels= (NAMES) | =labels.some.name.in= | | =:label-id= ID | =labels.some.id.in= | | =:priority= ... | =priority.eq= | | =:cycle= ID | =cycle.id.eq= | The reverse-compile recognizes exactly these shapes and inverts them. Round-trip tests (authoring plist → =build-issue-filter= → reverse → identical plist) are the spine of the phase. *The representability boundary — and why copy-down refuses rather than guesses.* Linear's =IssueFilter= is far richer than Pearl's authoring model. A Linear view can express: - *OR logic and nested =and=/=or=* — Pearl's authoring filter is flat AND only. - *Operators Pearl doesn't emit* — =neq=, =nin=, =null=, date/number comparators (=gt=, =lt= on =createdAt=, =dueDate=, =estimate=), =labels.every= / =labels.none= (Pearl only does =some=). - *Dimensions Pearl doesn't model* — creator, subscribers, parent, project milestone, estimate, due date, and others. When a Linear view's filter contains anything outside the table above, copy-down *refuses with a clear message naming the unsupported construct* ("this Linear view uses OR logic / a due-date filter / a creator filter that Pearl's local views can't represent yet — run it directly with =pearl-run-linear-view= instead"). It does *not* silently drop the unsupported conditions: a local view that looks like the Linear view but quietly matches a different set of issues is the worst possible outcome — every refresh would diverge invisibly. Refuse-don't-guess is the rule. This bounds the feature honestly: copy-down works for any Linear view whose filter lives inside Pearl's authoring model (the common case — a team + state + label + assignee AND-filter), and refuses, legibly, for the richer ones. The refused set shrinks over time as the authoring model grows (OR support is its own future spec). *Source of the copy-down candidate.* Copy-down reuses the picker's Linear-view enumeration (custom views + favorites that resolve to views) so the source list is the same one =pearl-run-linear-view= / =pearl-pick-source= already build. The chosen view's =filterData= is fetched, reverse-compiled, and saved as a new local view under a prompted name (defaulting to the Linear view's name), with no =:linear-view-id= (it's a fork). Optional =:copied-from-view-id= provenance is recorded for reference only. * Migration - =pearl-saved-queries= is a public defcustom set in users' =init.el=. Rename to =pearl-local-views= with =define-obsolete-variable-alias 'pearl-saved-queries 'pearl-local-views "pearl 1.1"= so existing configs keep working and Customize forwards transparently. The alias reads and writes the same storage; no data migration of the alist contents is needed (the =:filter= / =:linear-view-*= shape is unchanged). - Each renamed command gets =define-obsolete-function-alias= so muscle memory and any user keybindings survive the deprecation window. - =#+LINEAR-SOURCE= file headers are unaffected — they record a source descriptor, not the command name. Internal source plists (=:type 'view= / =:type 'filter=) may be renamed in lockstep or left as-is; they are not user-facing. - README, =package-summary.md=, and the transient help strings switch to the new vocabulary in the same change. * Open questions (for Craig) 1. *Variable name:* =pearl-local-views= (recommended), =pearl-saved-views=, or keep =pearl-saved-queries= as the storage name and only relabel the UI? Recommend the rename with an obsolete-alias — the whole point is that the vocabulary is consistent end to end. 2. *Ad-hoc builder:* does =pearl-list-issues-filtered= keep its inline "save this filter?" prompt, or does creation move entirely to =pearl-create-local-view= (leaving =pearl-list-issues-filtered= as a pure save-less ad-hoc run)? Recommend a dedicated create command + keep the inline prompt too (two doors to the same create), unless you'd rather have exactly one create path. 3. *Copy-down representability — refuse or fall back?* When a Linear view's filter is outside Pearl's authoring model (OR logic, unmodeled dimension, comparator), the recommendation is *refuse with a clear message* and point the user at =pearl-run-linear-view=. The alternative is a fallback opaque, run-only local copy that =pearl-edit-local-view= would reject. Recommend refuse — a silently-lossy local view that diverges on every refresh is the worse failure. Confirm. 4. *Edit semantics:* re-run the builder pre-seeded with the stored filter (recommended, reuses the builder), or a field-by-field editor? Pre-seeded re-run is simpler and matches how filters are authored. 5. *Keymap letters* for local-view run vs Linear-view run in the =f= group (the =v= collision), and the copy-up / copy-down slots (=f p= / =f d= proposed). The table above is a proposal; your call on the letters. 6. *Picker label wording* — exact strings for local / published / native-Linear entries. 7. *Copy-up naming:* is =pearl-copy-view-to-linear= the user-facing primary (with =pearl-publish-local-view= the alias), or the reverse? "Copy up / copy down" is symmetric and matches how you described it; "publish" is the more conventional word for pushing something to a shared place. Recommend leading with =pearl-copy-view-to-linear= for symmetry and keeping =pearl-publish-local-view= as the alias. 8. *Copy-down tracking:* the recommendation is a *fork* — the new local view carries no =:linear-view-id=, only optional =:copied-from-view-id= provenance, so editing and renaming it never fights a sync link. The alternative is to track the source so copy-up later updates that same Linear view. Recommend fork (you copy down to diverge; copy-up afterward makes a new tracked Linear view). Confirm. * Acceptance criteria - Every view operation (create, edit, run, delete, publish) is reachable under the verb a user would predict, all hanging off the =view= noun. - =pearl-saved-queries= and every renamed command keep working via obsolete aliases; an existing =init.el= needs no edit. - The picker, transient, keymap help, prompts, README, and docstrings all speak =filter= / =local view= / =Linear view=; "saved query" appears only in the obsolete-alias declarations. - =pearl-create-local-view= and =pearl-edit-local-view= exist and round-trip a local view (create → edit → run → copy up → delete) with tests. - Copy down works: a representable Linear view reverse-compiles into an editable local view, and the round-trip (authoring plist → =build-issue-filter= → reverse-compile → identical plist) holds for every dimension in the table, with Normal/Boundary/Error tests. - Copy down refuses, with a message naming the unsupported construct, for a Linear view whose filter uses OR logic, an unmodeled dimension, or an operator outside the authoring set — and never produces a lossy local view. - Copy up (=pearl-copy-view-to-linear=) creates a tracked Linear view on first copy and updates it in place on re-copy, unchanged from the current sync behavior. - Full ERT suite green, =make lint= and byte-compile clean. * Implementation phases (commits) 1. *Rename + aliases* — rename the defcustom and the four commands, add obsolete aliases, reword docstrings. No behavior change. (=refactor:=) 2. *Create + edit* — =pearl-create-local-view=, =pearl-edit-local-view=, builder extraction shared with =pearl-list-issues-filtered=. Tests. (=feat:=) 3. *Keymap + transient reshape* — the view noun under each verb; help labels. Tests for the keymap. (=feat:=) 4. *Picker labels* — reword =pearl--pick-source-candidates= / scope-label; tests. (=refactor:= / =feat:=) 5. *Copy up* — present the existing publish/sync as =pearl-copy-view-to-linear= (alias over =pearl-publish-local-view=); transient + keymap slot; no behavior change. (=feat:= / =refactor:=) 6. *Copy down — the reverse-compile* — =pearl--reverse-compile-issue-filter= (inverts every shape in the dimension table; refuses outside it), =pearl-copy-view-from-linear= (fetch =filterData=, reverse-compile, save as a forked local view), keymap + transient slot. Round-trip + refusal tests are the load-bearing coverage. (=feat:=) 7. *README + package-summary* — document filter / local view / Linear view, the git-style lifecycle, the copy-up / copy-down directions and the representability boundary, and the full create/edit/run/copy/delete surface. (=docs:=) * Out of scope (vNext) - Project / initiative scope on publish / copy-up (still team/personal only, per =saved-query-sync-spec.org=). - OR-logic and the richer =IssueFilter= constructs in local views (operators, unmodeled dimensions). Expanding the authoring model is its own spec; until then, copy-down refuses the Linear views that use them rather than copying them lossily. Growing the authoring model shrinks the refused set. - An opaque, run-only copy-down for non-representable Linear views (rejected in favor of refuse-don't-guess; see Open question 3). - Syncing =:sort= / =:order= to Linear.