Commit Graph

2 Commits

Author SHA1 Message Date
xarmian bdfb8a989c fix(server): a request that repeats fields_patch or field_overrides is refused, where it silently dropped the first copy (BUG-3219) (#1548)
BUG-3202 (#1545) decodes fields_patch from its raw member and
field_overrides through models.FieldValues, to keep number literals. A
plain map member MERGED a repeated occurrence; a raw member keeps the
LAST, so a request repeating either answered 200 having discarded part
of the write.

A repeat is now refused with a 400 naming the member, and nothing is
written:

- models.RefuseRepeatedMember scans an object's top-level members and
  matches names by encoding/json's own rule (exact, or its case fold),
  so fields_patch followed by FIELDS_PATCH is a repeat. A test pins the
  matcher against encoding/json itself.
- ItemUpdate.UnmarshalJSON refuses a repeated fields_patch; the move
  request (now a named type) and the copy/preflight request refuse a
  repeated field_overrides. The three handlers answer with the message
  unwrapped; the copy door no longer flattens it to "Invalid JSON body".
- FieldValues refuses a second non-null decode itself, as the backstop
  for a future struct that forgets the scan.

Out of scope: repeated keys INSIDE one fields object (BUG-2896), and
members that were already raw before #1545 (fields, tags), which kept
the last occurrence before and after.
2026-09-25 18:23:31 -04:00
xarmian 25d76af0ba fix(fields): field writes keep every number's literal, so an integer above 2^53 is no longer rounded by a write that never named it (BUG-3202) (#1545)
* fix(fields): field writes keep every number's literal, so an integer above 2^53 is no longer rounded by a write that never named it (BUG-3202)

Every door that writes an item's fields decoded the blob into
map[string]any, turning each JSON number into a float64, so the re-encode
rounded integers above 2^53 (9007199254740993 stored as ...992). The stored
blob was decoded that way too, so a fields_patch to `status` rewrote every
large integer in the row.

- models.DecodeFieldsJSON / DecodeJSONKeepingNumbers decode with UseNumber.
  They accept and refuse exactly what json.Unmarshal does (trailing data,
  out-of-float64-range numbers); only the representation changes.
- Used at every fields decode that feeds a write: create, full-fields
  update, the fields object on the wire, fields_patch (request and store
  merge), the carried-blank-relation normalisation, the Append/Set metadata
  helpers, move, bulk move, cross-workspace copy and its preflight, and the
  move/copy field_overrides (models.FieldValues).
- items.validateFieldType accepts a json.Number that parses as a finite
  number. items.coerceValue and the CLI's parseFieldFlag keep a numeric
  string that is a JSON number literal as that literal.
- Read-only decoders are unchanged (display, hydration, guards).

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m

* fix(fields): compare field values by value, so an equal number in another spelling is no change and two integers that share a float64 are (BUG-3202)

Field writes now keep a number's literal, so one value can be stored as
different text (1e3 vs 1000). Three places compared field values:

- item.updated's change detector compared the fields blob's TEXT whenever
  it was not masking a string done key: an equal-value rewrite, or a
  key-reorder-only rewrite, emitted item.updated.
- the activity `changes` diff and the BUG-3163 carry check compared
  float64s: equal spellings were fine, but two different integers above
  2^53 compared equal, so such a change went unrecorded and a changed
  reserved value passed as carried.

models.CanonicalJSONNumbers gives every number one exact canonical
spelling. All three decode keeping literals and compare canonical forms.

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-25 14:22:09 -04:00