A one-field bool index holding three rows (two true, one false), scanned
with inclusive bounds. Ascending behaves; descending cannot return a row from any two-value range:
| index | from → to | what happened |
|---|---|---|
| ascending | false → true | accepted, 3 rows — correct |
| ascending | true → false | refused ErrArgument — correct |
| descending | false → true | accepted, 0 rows |
| descending | true → false | refused — and this is the correct pair here |
Single-point bounds (false→false, true→true) and unbounded scans
are unaffected. So “returns empty in every case” overstates it — what is broken is
the two-value range on a descending bool field, and that is broken in every instance of it.
It fails the same way at declare time, with constant endpoints, as at invoke time.
backwardsBoolRange in internal/store/scan.go decides whether a range runs
backwards by looking at the two values:
return lo && !hi
It never reads fields[0].Descending. The encoder does. Measured directly against
keys.Encode:
descending=false false=20 true=21 compare(false,true) = -1
descending=true false=df true=de compare(false,true) = +1
Under Descending the byte is complemented, so false sorts above
true and the low-to-high pair is from=true, to=false. That is
exactly the pair the guard refuses. The pair it permits encodes to a lower bound above its upper
bound, which is an empty range, so the scan walks nothing and reports success.
Both call sites inherit it — stretch() at call time and
refusedBackwardsRange() in ops.go at declare time — because both
route through this one function.
The guard's own comment explains its reasoning honestly and is where the gap is visible in hindsight: “Read at the value level, before either side is encoded, true and false are never ambiguous…” That is true, and it is the problem. Reading before encoding is precisely what makes it blind to the one thing encoding does here.
The change is small — a handful of lines making backwardsBoolRange
direction-aware, no signature change, no change to anything on disk. What is not small is that it is
visible to a caller in both directions:
SAPE-2's policy lets a 1.x loosen a rule and forbids it from tightening one. The first bullet is a tightening. So it can be introduced before the tag and never after — the same deadline shape as ISS-35, and for the same reason. If 1.0.0 ships as it is, “a two-value range over a descending bool index silently returns nothing” becomes a promise this project has to keep for the life of 1.x.
Nothing currently pins the wrong behaviour as intended. No test anywhere in the tree
puts Descending: true on a TypeBool field — checked per field rather
than per file, because lockstep_test.go contains both words and combines neither: its
descending fields are all TypeNumber, and its bool field has no direction. So this is
not a behaviour anyone chose.
backwardsBoolRange swaps its two values when the field is descending, so the guard
and the encoder answer to the same fact.Descending: true on a TypeBool field — the
combination that has never existed in this tree, which is why nothing caught this.
Measured 2026-09-21 at f145063, on a copy of the tree rather than the tree itself; the
working directory was clean before and after. The encoding figures above come from calling
keys.Encode directly, so they are the bytes themselves rather than an inference from
behaviour. The row counts come from running a store, not from reading one.