aboutsummaryrefslogtreecommitdiff
path: root/docs/multi-state-filter-spec.org
blob: 8bf6ac4310c641b2efa83c5b1dacadd52984d072 (plain)
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
#+TITLE: Spec: selecting multiple statuses in a filter
#+AUTHOR: Craig Jennings
#+DATE: 2026-06-01

* Status

*Ready.* Codex review incorporated (2026-06-01, =Ready with caveats=); the one medium finding (reconcile the views-spec/README representability boundary when =:state= turns plural-capable) is folded into phase 3. See *Review dispositions*. Triggered by the 2026-06-01 conversation after the local/Linear views work shipped: the interactive filter builder lets you pick exactly one workflow state, so a filter like "Todo or In Review" can't be built. Linear's API supports it (=state.name.in=), and Pearl's filter model is one step away. This closes the named-states half of the views spec's vNext "plural authoring keys" item ([[file:local-and-linear-views-spec.org][local-and-linear-views-spec.org]], Out of scope).

* Problem

A workflow state ("status" in Linear's UI) is the field people most want to filter on by *set* -- "show me everything in Todo, In Progress, or In Review." Pearl can't express that:

- =pearl--read-filter-interactively='s State prompt is a single =completing-read=, so it picks one state name.
- The =:state= authoring key compiles to =state.name.eq= -- a single name.
- The only ways to match several states today are =:state-type= (by type bucket: backlog / unstarted / started / completed / canceled, not by individual name) and =:open t= (the broad not-closed predicate). Neither lets you hand-pick, say, "Todo and In Review but not In Progress."

So the most common multi-value filter a user wants is the one Pearl's builder can't make.

* Non-goals

- *Not* OR across different dimensions. Filters stay AND-only between dimensions (state AND label AND assignee); this is OR *within* the state dimension only, which Linear expresses as =state.name.in=.
- *Not* multi-value on the other singular keys (=:project=, =:assignee-id=, =:team=, =:cycle=, =:label-id=). Those remain singular; their plural keys are a separate vNext item.
- *Not* negation ("not In Progress"). Still out of scope.
- *Not* exposing =:state-type= in the builder. It already accepts a list in the DSL; surfacing the type buckets in the UI is a separate, smaller question.

* Proposed design

*Make =:state= polymorphic -- a name or a list of names -- exactly as =:state-type= already is.* This adds the capability with no new authoring key and a surface symmetric with the sibling key.

** Authoring model

- =(:state "In Progress")= -- a string -- compiles to =state.name.eq= (unchanged).
- =(:state ("Todo" "In Review"))= -- a list -- compiles to =state.name.in= [names].

=:state= and =:state-type= stay mutually exclusive in practice (you filter by name *or* by type), and the existing precedence holds: =:state= over =:state-type= over =:open=. No new key, no precedence change.

** Compiler (=pearl--compile-state-filter=)

The =:state= branch gains the same one-or-list shape =:state-type= has:

#+begin_src emacs-lisp
  (state (list (cons "name"
                     (if (listp state) (pearl--in state) (pearl--eq state)))))
#+end_src

** Interactive builder (=pearl--read-filter-interactively=)

The State prompt becomes a =completing-read-multiple= over the team's fetched state names (mirroring how the Labels prompt already works), with the =[ Any. ]= sentinel filtered out of the result. The result maps to the authoring value:

- none / only the sentinel -> no =:state= constraint.
- exactly one -> a string, =(:state "X")= (keeps the common single-state filter as a scalar).
- two or more -> a list, =(:state ("X" "Y"))=.

Single-to-string keeps the simple case identical to today and keeps round-trips clean.

** Validation (=pearl--validate-issue-filter=)

Today =:state= is validated in the singular-key group that only rejects an empty string. With =:state= now accepting a list, it moves to the same validation =:labels= already gets: a string, or a list of non-empty strings; an empty name anywhere (=(:state ("" "Todo"))=) signals a `user-error' at the command boundary, so a bad filter never reaches the compiler or the network.

** Reverse-compile (copy-down, =pearl--rc-match-state=)

Copy-down currently refuses a multi-value =state.name.in= (the singular-key rule). With =:state= now plural-capable, =state.name= reverse-compiles like =:state-type=:

- =state.name.eq= -> =(:state "X")=.
- =state.name.in= with one element -> =(:state "X")= (normalized to a scalar).
- =state.name.in= with several -> =(:state ("X" "Y"))=.

So a Linear view filtering on a set of named states copies down cleanly instead of refusing.

* Current state

The four filter-side readers of the =:state= authoring key:

- =pearl--compile-state-filter= -- singular =state.name.eq=.
- =pearl--read-filter-interactively= -- single-select State prompt.
- =pearl--rc-match-state= (reverse-compile) -- treats =state.name.in= [many] as an unrepresentable multi-value refusal.
- =pearl--validate-issue-filter= -- validates =:state= as a single non-empty string.

All four change. =:state-type= is already one-or-list (the precedent this spec follows), and =:open t= is the broad not-closed predicate.

*Unaffected:* the issue's *own* workflow state is a different use of the symbol =:state= -- =pearl--normalize-state=, the renderer and sorter (=(plist-get issue :state)=), and the save model's edited-state push all read an issue's state, not a filter key, and need no change.

* Migration

None. =(:state "X")= entries keep working (a string still compiles to =state.name.eq=). The change is purely additive: a list value becomes legal. The =:state= docstring updates from "(name)" to "(a name or a list of names)".

* Acceptance criteria

- The builder's State prompt is multi-select; picking two states yields =(:state ("A" "B"))=, picking one yields =(:state "A")=, picking none yields no =:state= key.
- =(:state ("A" "B"))= compiles to =state.name.in= ["A" "B"]; =(:state "A")= still compiles to =state.name.eq=.
- A list-valued =:state= keeps its precedence: it still beats =:state-type= and =:open= in =pearl--compile-state-filter=.
- A local view with a multi-state =:state= runs and returns issues in any of the named states.
- Copy-down reverse-compiles =state.name.in= [many] to =(:state (list))= and [one] to =(:state "X")=; it no longer refuses a multi-named-state Linear view.
- Round-trip holds: =(:state ("A" "B"))= -> build -> reverse -> =(:state ("A" "B"))=.
- Validation: =(:state ("A" "B"))= is accepted; =(:state ("" "B"))= (an empty name) signals a `user-error'; =(:state "A")= still validates.
- The =#+LINEAR-SOURCE= header round-trips a multi-state filter: a buffer rendered from =(:state ("A" "B"))= writes and re-reads that list intact, so refresh re-runs the same filter.
- Existing single-state behavior and tests are unchanged.
- Full ERT suite green, =make lint= and byte-compile clean.

* Implementation phases (commits)

1. *Compiler + reverse-compile + validator* -- =:state= accepts a list (=state.name.in=); =pearl--rc-match-state= inverts =state.name.in= to a scalar or list and drops the multi-name refusal; =pearl--validate-issue-filter= validates =:state= as a string or a list of non-empty strings. Round-trip, compile, and validation tests. (=feat:=)
2. *Builder multi-select* -- the State prompt becomes =completing-read-multiple=; one-to-string, many-to-list mapping. Builder tests. (=feat:=)
3. *Docs + boundary reconciliation* -- README's filter section and the =:state= docstring note multi-state selection; tick the named-states item off the views spec's vNext plural-keys list. *And reconcile the representability boundary the views work documented as singular-only:* update =local-and-linear-views-spec.org='s reverse-compile dimension table, the singular-vs-plural rule, the refusal list, and the acceptance criteria, plus the README copy-down paragraph, so =state.name.in= with several names is a plural-capable exception alongside =labels.some.name.in= (=:labels=) and =state.type.in= (=:state-type=). This must land with phase 1 (the code that drops the refusal), not before -- until the reverse-compile changes, those statements are still true. (=docs:=)

* Out of scope (vNext)

- Plural keys for the other singular dimensions (=:projects=, =:assignees=, =:cycles=, =:team-keys=, =:label-ids=) -- the rest of the views spec's plural-keys item.
- Exposing =:state-type= (the type buckets) in the builder.
- Negation and cross-dimension OR.

* Implementation tasks (drop-in for todo.org)

#+begin_src org
,** TODO [#B] Multi-state filter — compiler, reverse-compile, validator :feature:
=:state= accepts a list (=state.name.in=); =pearl--rc-match-state= inverts =state.name.in= to a scalar (one) or list (many) and drops the multi-name refusal; =pearl--validate-issue-filter= validates =:state= as a string or a list of non-empty strings; a list-valued =:state= keeps precedence over =:state-type= / =:open=. Spec: [[file:docs/multi-state-filter-spec.org]] (phase 1).

,** TODO [#B] Multi-state filter — builder multi-select :feature:
The State prompt becomes =completing-read-multiple= with the =[ Any. ]= sentinel filtered out and one-to-string, many-to-list mapping. Spec: [[file:docs/multi-state-filter-spec.org]] (phase 2).

,** TODO [#B] Multi-state filter — docs + boundary reconciliation :docs:
README filter section + =:state= docstring document multi-state selection; the local/Linear views spec's reverse-compile dimension table, singular-vs-plural rule, refusal list, acceptance, and README copy-down paragraph add =state.name.in=[NAMES...] as a plural-capable exception; the named-states vNext item is ticked off. Lands with phase 1. Spec: [[file:docs/multi-state-filter-spec.org]] (phase 3).

,** TODO [#B] Multi-state filter — test surface :test:
Unit: =(:state ("A" "B"))= → =state.name.in=, =(:state "A")= → =eq=, list keeps precedence over =:open=, validator accepts list / rejects empty-name; reverse-compile round-trip + direct =state.name.in= JSON fixtures (one→scalar, many→list); builder/assemble-filter one→string, many→list, sentinel→no key; source-header round-trip of =:filter (:state ("A" "B"))=. Integration: a local view with =(:state ("A" "B"))= runs the normal path. E2e/manual: build a two-state filter, run it, copy down a multi-named-state Linear view, confirm refresh preserves the =#+LINEAR-SOURCE= filter. Spec: [[file:docs/multi-state-filter-spec.org]] (Acceptance criteria).
#+end_src

* Review dispositions

The Codex review (=Ready with caveats=) had no high-priority findings and one medium, accepted:

- *MP1 (reconcile the representability boundary) — accepted.* The views work documented multi-value =in= on singular keys as refusing, with only =:labels= and =:state-type= whitelisted. Making =:state= plural-capable changes that, so phase 3 now explicitly updates the views spec's dimension table, singular-vs-plural rule, refusal list, and acceptance, plus the README copy-down paragraph -- and the spec notes this must land with phase 1 (the code that drops the refusal), since until then the old boundary text is still accurate.

The test-strategy recommendations were folded into the acceptance criteria and the test-surface task (precedence-with-list, direct =state.name.in= JSON fixtures, the source-header round-trip). Everything else was accepted as written.

* Review and iteration history

** 2026-06-01 Mon @ 23:10:17 -0500 — Codex — reviewer

- *What changed or was recommended:* Reviewed the spec against the current filter compiler, reverse-compiler, validator, interactive builder, source-header serialization, tests, README, related views spec, and task tracking. Verdict: =Ready with caveats=. No high-priority blockers. One medium-priority caveat: phase 3 must update the local/Linear views spec and README representability boundary so =state.name.in= with multiple names is no longer documented as an unrepresentable singular-key filter.
- *Why:* The implementation surface is small and well specified, but the new plural-capable =:state= rule intentionally changes the boundary just established by the views work. Keeping the companion docs/spec in sync prevents future copy-down behavior from contradicting its documented refusal rules.
- *Artifacts:* =multi-state-filter-spec-review.org= (consumed and deleted by the response pass below).

** 2026-06-01 Mon @ 23:13:11 -0500 — Claude Code (pearl) — responder

- *What changed:* Accepted the one medium finding (MP1) and folded it into phase 3: the docs phase now reconciles the local/Linear views spec's representability boundary and the README copy-down paragraph so =state.name.in= [many] is a plural-capable exception, with the note that it lands with phase 1 (not before, since the refusal is still accurate until the code changes). Folded the test-strategy recommendations into acceptance and the test-surface task (list keeps precedence over =:open=, direct =state.name.in= JSON fixtures, source-header round-trip), added the drop-in task block, and raised status to Ready.
- *Why:* The finding was a real coherence risk -- the just-shipped views work documents singular keys as refusing multi-value =in=, and this spec intentionally moves =:state= out of that set. Naming the boundary reconciliation in phase 3 keeps the docs from contradicting the code.
- *Artifacts:* Review file deleted on completion.