← 1.0.0 scope ISS-35

A number above 253 is stored as a different number, and nothing says so

Status
Open
Found while
planning the type system
Triage
Fix in 1.0.0
Component
server
Waiting on
a decision between two fixes — see below

What was measured

One collection, one insert declaring a field as number, four values sent through sapedb invoke against a real daemon, then read straight back out of the database file with sapedb dump:

  sent     9007199254740992   stored     9007199254740992   exact
  sent     9007199254740993   stored     9007199254740992   CHANGED
  sent  9223372036854775807   stored  9223372036854776000   CHANGED
  sent  1234567890123456789   stored  1234567890123456800   CHANGED

Every write answered changed 1 and exit 0. There is no refusal, no warning, and nothing in the log that would let anybody notice afterwards. The second line is the clearest: a value off by one, silently.

Why it happens

number is one type and it is float64. matches in internal/store/store.go accepts float64, float32, int and int64, but the value has already become a float long before it gets there: 38 call sites decode JSON with json.Unmarshal and exactly one uses UseNumber, so a JSON number becomes a float64 at the door.

The key encoding is built the same way. internal/keys writes tagNumber followed by the eight bytes of the float's bits, so an indexed number is float-shaped on disk as well as in memory. Exactness is not lost at the last moment; there is nowhere in the current path where it still exists.

Why this is Fix in 1.0.0

The triage question on this board is whether a thing loses data. This one does, and the values it loses are not exotic: a Snowflake or twitter-shaped id, an account number, a nanosecond timestamp, any integer identifier a caller did not think of as a float. A caller who stores an id and reads back a neighbouring id has been told nothing went wrong.

The deadline is not the same for both fixes

This is the part that decides the order, and it comes from SAPE-2's own policy rather than from anybody's preference. COMPATIBILITY.md section 3 lets a 1.x loosen a rule and forbids it from tightening one — a declaration valid in 1.0 may not be refused in 1.1.

So the question is not which fix is better. It is which fix has a deadline. The refusal does. If 1.0.0 ships without it, silent rounding becomes a promise this project has to keep.

Acceptance criteria

  1. A value that cannot survive the round trip is refused by name, saying what was sent and what would have been stored. The measurement is the four rows above: three refusals and one success, with the boundary at 253 stated rather than implied.
  2. The refusal reaches a client as its own code, not as failed — the shape ISS-21 already cost this project once.
  3. A test sends the exact four values above and watches three of them refused, and it is watched failing with the check removed.
  4. Whatever is decided is written into COMPATIBILITY.md under what a declaration means, because after the tag this is frozen either way — as a refusal, or as documented rounding.

Out of scope