← 1.0.0 scope ISS-39

The guard reads the values; the encoder reads the direction. They disagree.

Status
Open
Found while
measuring which items on the old debt list still describe a real defect
Triage
Needs a decision — the fix is a tightening, so it has a deadline
Component
server

What was measured

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:

indexfrom → towhat happened
ascendingfalse → trueaccepted, 3 rows — correct
ascendingtrue → falserefused ErrArgument — correct
descendingfalse → trueaccepted, 0 rows
descendingtrue → falserefused — 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.

Why

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.

Why this is a decision rather than a fix

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.

What closing it would mean

Conditions

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.