Files
pad/internal/store
xarmian ae793e6fa6 fix(server,store,items): coerce field values to their declared types server-side (BUG-2850)
The write doors disagreed about what `key=value` means. The CLI has coerced
by schema type since BUG-1125, and local stdio MCP inherits that by shelling
out to the binary — but the remote /mcp transport builds its field map in
ingestFieldKVP with `dst[key] = val`, so every value arrives as a string.
validateFieldType then correctly refuses a string for a declared number or
json field, and the net effect was that an MCP agent on that transport could
not write those fields AT ALL: every attempt a 400, not a mis-typed value.

Measured before writing anything (repro table on BUG-2850's trail): CLI and
stdio MCP store 42 and an array; the HTTP door 400s on both; an UNDECLARED
key is stored as a string on every door.

items.CoerceFields(fields, schema) converts strings to the declared type —
number via ParseFloat (NaN/±Inf refused, because json.Marshal cannot encode
them and the ignored downstream error would silently drop the whole payload),
json/multi_select via Unmarshal, checkbox via ParseBool — and is applied
immediately before every Validate* call.

Three deliberate non-behaviours, each with a test:
- A value that will not parse is left as the string for the validator, so the
  existing "must be a number" error still fires. Coercion invents no error
  path, and cannot turn a currently-PASSING write into a failure.
- Non-string values pass through untouched; an int stays an int.
- Text-typed fields holding "42" stay strings. Coercing anything that parses
  would retype real data while fixing the bug.

Not folded into ValidateFields, though that would be the single call site: a
function named Validate that mutates its input is a trap, and two callers
re-marshal the map they pass.

THE POPULATION IS 8 CALL SITES, and finding them took two sweeps. The first
was scoped to internal/server and found 7; the copy path validates in
internal/store (items_cross_workspace_copy.go), which only a repo-wide sweep
sees. The preflight and the store-side copy now carry cross-references to
each other: the preflight exists to PREDICT the copy, they live in different
packages, and that is exactly how they would drift unnoticed.

The undeclared-key half of BUG-2850 is untouched and marked as a decision
point in CoerceFields — refuse/warn/keep is with Dave. A test pins today's
keep behaviour so the ruling lands as a deliberate change.

The CLI's parseFieldFlag deliberately STAYS: it is why two of four doors are
correct today, and removing it alongside its replacement would put all four
at risk of one mistake. Retiring it is a follow-up.

Claude-Session: https://claude.ai/code/session_011Q4b1iHtJtSyMs7BA2ySxo
2026-09-02 23:30:26 +00:00
..