Files
pad/internal/items
xarmian ee0d945863 fix(cli,mcp): one --field key=value entry means one thing at every door (BUG-2870) (#1283)
* feat(items): one shared parse for a --field key=value entry (BUG-2870)

Six sites parsed that entry independently — item create, list, update, move
and copy in cmd/pad, plus ingestFieldKVP on the remote /mcp door — in four
spellings, and they disagreed about what it meant. The CLI sites used both
halves verbatim, so `--field " effort=l"` stored an undeclared field named
" effort" and left the declared `effort` untouched; the remote door trimmed
both halves and wrote `effort`. Same call, two stored keys, decided by which
transport the caller was on.

This is the helper only; the call sites move over in the commits that follow.

Two rules, deliberately asymmetric, per the day-60 ruling:

- a KEY whose trimmed form differs from what was written is REFUSED at every
  door, rather than silently retargeted to a different field;
- a VALUE is carried VERBATIM at every door, because trimming reinterprets a
  caller's bytes and on a text field the space is content. A padded value
  against a typed field is refused one layer down by validation, naming the
  field — measured, not assumed.

ErrFieldEntryMalformed is returned rather than handled because the six sites
deliberately disagree about a malformed entry (four skip it, copy hard-errors)
and unifying that is a separate decision.

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

* fix(cli,mcp): all six --field parse sites go through the one helper (BUG-2870)

item create, list, update, move and copy in cmd/pad, plus ingestFieldKVP on
the remote /mcp door, now call items.SplitFieldEntry instead of each rolling
its own split. A padded key is refused at every door; a value reaches every
door verbatim.

Two sites keep something specific to them, both documented in place:

- `item list` is a READ filter, and it takes the same key rule deliberately:
  a padded key there filters on a field nobody declared and returns empty,
  which is indistinguishable from "no rows match".
- `item move` gets KEY normalisation only. Its values stay strings because
  the server types a declared field on that path too, so a clean
  `--field n=3` already stores the number 3 — measured before the change.

Each site keeps its historical disposition toward a MALFORMED entry (four
skip silently, copy hard-errors), which is why the helper classifies that
case rather than deciding it.

NOT YET EVIDENCE: ./internal/mcp, ./cmd/pad and ./internal/items all pass,
and that green does not show the divergence closed — the three BUG-2850
pinned tests exercise the catalog conflict pass, which never reaches
ingestFieldKVP. The door-level test and the re-grounding of that pass are
the next commits.

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

* test(cli,mcp): pin the door-parity claim at both doors (BUG-2870)

Nothing in the suite asserted what the remote door STORED for a padded entry
— the three BUG-2850 tests that cite its trimming all exercise the catalog
conflict pass, which never reaches ingestFieldKVP. So the previous commit's
green was not evidence for the thing it changed.

Three files now hold the claim: internal/items pins the rule, internal/mcp
pins the remote door, cmd/pad pins the CLI door, and each cites the other
two. Padded key refused at both; padded value carried verbatim at both; a
refusal aborts the call rather than dropping one entry, and on the CLI it
happens before any request reaches the server.

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

* fix(mcp): re-ground the conflict pass on the new door behaviour (BUG-2870)

The pass's rules were derived from ingestFieldKVP trimming, so changing the
door without changing the layer built on it would have been the same
one-door lapse a level up.

- parseFieldArray splits through items.SplitFieldEntry: a padded key is
  REFUSED before dispatch on both transports, and values are indexed RAW,
  because raw is now what both doors write.
- Both comparison sites compare raw for the same reason. The round-19
  "COMPARED TRIMMED" rule is superseded and its comment says so.
- detectFieldConflicts PROPAGATES the parse refusal instead of returning nil.
  It swallowed it as "the caller owns this error surface", which was true
  when the only possible error was a shape error — reshapeItemFields returns
  early with no `fields` object, so on the no-`fields` path (this bug's path)
  nobody owned it and a padded entry turned back into a success.
- A padded entry is refused in the pass rather than skipped. Skipping dropped
  it from conflict detection entirely, turning four existing refusals into
  successes.

The last two were caught by the BUG-2850 tests, not by reasoning: the first
shape of this commit passed a full package build and turned four guards off.

Seven tests still fail. They assert the OLD door behaviour and are the
specification being changed; each gets read on its own next, and is either
kept because the behaviour survives or replaced by a test stating the new
behaviour that cites the old name.

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

* test(mcp): restate the seven BUG-2850 pins on the new rule (BUG-2870)

Each was read on its own and either kept or replaced; every replacement
names the test it replaces and why the old assertion was right at the time,
so the deletion is traceable rather than a green that appeared.

- padded value is not a conflict → IS a disagreement now that no door trims
  (" done" and "done" are two values), with an equal-values control leg.
- padded entries still caught (hierarchy) → refused EARLIER, by the padded-key
  rule, before the alias pass observes both keys. The alias guard keeps its
  three unpadded cases, which is what stops this being a hole.
- PaddedEqualDuplicateIsCanonicalized → IsRefused, plus a canonical control
  that still emits --field exactly once.
- MixedCanonicalAndPaddedDuplicatesCollapse → Refused. The round-8 finding
  survives: one canonical entry still does not make its padded sibling
  harmless, it is refused rather than swallowed.
- PaddedEntryAloneIsUntouched → IsRefused. That test pinned a DEFERRAL, in
  its own words "BUG-2870's business, not this PR's". This is that business.
- "fields carries the key — canonicalized, so accepted" → still refused,
  since nothing canonicalizes now; the per-key question it defended is still
  tested by the two legs beside it, and a canonical control was added.
- ReEmittedValueKeepsItsWhitespace → the re-emission path is gone, so it
  becomes a refusal test that also asserts the ADVISED form is accepted with
  its value untouched. The property it defended is pinned at both doors.

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

* test(server): pin that a move override is typed server-side (BUG-2870)

The fact the ruling turned on, and the easiest one in this unit to lose: it
is invisible from cmd/pad, where moveCmd plainly sends a string.

- a declared number field given the STRING "3" through field_overrides ends
  up as the NUMBER 3, which is why move needs the shared KEY parse and no
  client-side typing;
- a padded " 3" is REFUSED with a 400 and the item does not move, which is
  the answer the remote door will now give too instead of trimming and
  succeeding.

t.Parallel per CONVE-2086 — both build their own server through testServer,
so each has its own database, limiter and bus.

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

* chore(mcp): bump tool surface to 0.30 and sync the docs the guards enforce (BUG-2870)

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

* refactor(mcp): remove the canonicalization the door change made unreachable (BUG-2870)

Two mechanisms existed to make a padded entry reach both doors as the same
write: the nonCanonical conflict guard (round 16) and the re-emission path
that rewrote a padded entry to canonical form (rounds 7/8). Both are dead
now — items.SplitFieldEntry refuses a padded key, so every entry that parses
satisfies `entry == key + "=" + value` BY CONSTRUCTION.

Removing each changed no test. That is consistent with "dead" and with
"untested" alike, so the construction argument above is what settles it —
recorded in the comments that replace them, along with what the removed
guard was defending and where that premise is enforced now.

Rewriting a caller's key was also the behaviour this bug is about, applied
by us rather than by a door: canonicalization silently changed the key the
caller wrote. Refusing says so instead.

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

* docs(mcp): put the trimming narrations in the past tense (BUG-2870, CONVE-23)

Six comments described the old door behaviour in the present tense ("HTTP
trims and writes effort"), which reads as a claim about the code as it
stands. The rounds they narrate still explain why the surrounding rules
exist, so they are re-tensed rather than deleted.

Two references were checked and left alone because they are still true:
ingestFieldKVP does still store every field value as a STRING (coerce.go's
BUG-2850 note, and the github_pr hint in dispatch_http.go). This change
stopped it TRIMMING, not stringifying.

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

* docs: sync CLAUDE.md to tool surface 0.30 (BUG-2870)

The drift guards cover instructions.md and README.md but not this file, and
its own 0.27 entry records the consequence: 'This entry was missing from
CLAUDE.md — the 0.27 unit swept instructions.md and README.md and not this
file.' The unit that makes a version line stale is the unit that owes it.

Both markers updated, and the entry states the two behaviour changes in the
terms they were ruled: /mcp refuses what it silently accepted, and the
swallowed parseFieldArray refusal that was landing four refusals as
successes on the no-fields path.

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

* fix(mcp): finish the removal, and correct a claim I made twice (BUG-2870)

Codex round 1: no P1/P2, two nits, both real.

1. The re-emission removal was incomplete. `reEmitFields` and the branch
   that appended its entries survived with nothing populating the map, and
   two comments still described canonical re-emission as something this code
   does. Unreachable, but my own commit message had said the path was
   removed, so the code contradicted the claim. Removed, and the round-16/17
   paragraphs that decided WHEN to canonicalize go with it — they answered a
   question that no longer arises.

2. "The only behaviour change is /mcp refusing what it silently accepted" is
   WRONG, and it was in version.go, README.md and CLAUDE.md. Every door
   refuses a padded key now; they were merely accepting it differently —
   /mcp trimmed it and wrote the declared field, the CLI stored a ghost field
   beside it. What is /mcp-only is the VALUE half. Corrected in all three,
   with the correction itself recorded in the version.go entry so the next
   reader sees the claim was checked rather than a sentence that quietly
   changed shape.

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

* docs(mcp): rename the predicate to the question it asks (BUG-2870)

Codex round 2: no P1/P2, three nits, all naming and prose.

- `canonicalized` is renamed `coveredByFieldsObject`. Nothing canonicalizes
  anything any more, and the only thing that predicate ever asked was
  whether the `fields` object carries THIS key — it kept the old name only
  because the guard it used to feed had been removed a commit earlier.
- parseFieldKVP's doc said invalid entries are skipped silently. True of a
  MALFORMED entry, false of a padded key, which now aborts the call.
- Three test comments still described re-emission as live, and version.go
  described this door's trimming in the present tense.

Nothing in these two rounds was a defect in the change itself; both rounds
found prose describing a version of the code that stopped existing partway
through the unit, which is the failure mode a re-grounding pass invites.

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

* docs(mcp): last of the prose that outlived the code (BUG-2870)

Codex round 3: no P1/P2, prose only.

- the predicate's own comment still asked 'will anything canonicalize THIS
  key'; it asks whether the fields object carries the key, and always did;
- two test comments described re-emission and trimmed comparison as current.
  Both tests are kept — what they pin is narrower now and still worth
  pinning — with the change in what they mean written down.

Deliberately NOT changed: the comments and replacement-test names that cite
the OLD test names. Codex reads them as stale terminology; they are the
traceability the restatement commit was asked for, so a reader can find what
each replacement replaced.

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

* docs(items): the coercion note names what the door does now (BUG-2870)

Codex round 4. The paragraph described ingestFieldKVP as doing
`dst[key] = val` unconditionally. Its CLAIM — every value arrives at the
server as a string — is still true and is the reason this file exists; the
description of the line is not, since that door now parses through
items.SplitFieldEntry. Restated so the still-true part is not carried by a
sentence a reader can falsify.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
2026-09-07 15:33:05 -04:00
..