aboutsummaryrefslogtreecommitdiff
path: root/docs/multi-state-filter-spec.org
diff options
context:
space:
mode:
Diffstat (limited to 'docs/multi-state-filter-spec.org')
-rw-r--r--docs/multi-state-filter-spec.org148
1 files changed, 148 insertions, 0 deletions
diff --git a/docs/multi-state-filter-spec.org b/docs/multi-state-filter-spec.org
new file mode 100644
index 0000000..8bf6ac4
--- /dev/null
+++ b/docs/multi-state-filter-spec.org
@@ -0,0 +1,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.