1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
|
#+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.
|