From 5ef986c880fa6fdfafe3f11943524119065bd044 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Mon, 1 Jun 2026 23:33:46 -0500 Subject: docs: spec for selecting multiple statuses in a filter I wrote a spec for letting a filter match several workflow states at once. The builder picks one state today, so "Todo or In Review" can't be built. The design makes :state polymorphic, a name or a list of names, mirroring how :state-type already works, so (:state ("Todo" "In Review")) compiles to state.name.in. It covers the four filter-side readers (compiler, builder, validator, reverse-compile), reconciles the views spec's representability boundary, and notes the issue's own state field is a separate, unaffected use. Codex review incorporated. Status Ready. --- docs/multi-state-filter-spec.org | 148 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 docs/multi-state-filter-spec.org (limited to 'docs/multi-state-filter-spec.org') 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. -- cgit v1.2.3