Skip to content

fix(models): refuse an append that would destroy an unreadable structured field (BUG-2627, part 3) - #1164

Merged
xarmian merged 4 commits into
mainfrom
fix/2627-append-guard
Aug 19, 2026
Merged

fix(models): refuse an append that would destroy an unreadable structured field (BUG-2627, part 3)#1164
xarmian merged 4 commits into
mainfrom
fix/2627-append-guard

Conversation

@xarmian

@xarmian xarmian commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Part 3 of BUG-2627. Ships first by design — see Ordering below.

The defect

AppendImplementationNote / AppendDecisionLogEntry rebuilt their entry slice from Extract*, then assigned it over the field key unconditionally. When the stored value was something Extract* could not decode, Extract* returned nil and the assign overwrote that value with a one-element slice — reporting success.

Observed live, not hypothesised. A scratch item corrupted to the real-world shape, carrying two notes:

pad item note TASK-2668 "third note appended via the remedy path"
→ "Added implementation note to TASK-2668"     ← success

read back → one note. note-A and note-B are GONE.

The design point

The guard cannot key on Extract* returning nil — the obvious shape, and the wrong one. Extract* returns nil for four different reasons and only one is a defect:

stored value correct behaviour
1 key absent proceed — the first append on an item
2 empty array proceed — well-formed, just empty
3 explicit JSON null proceed — carries no entries
4 undecodable value refuse

if Extract(...) == nil { refuse } passes every refusal test and breaks every first append. So assertStructuredFieldAppendable tests decodability against the raw value in the fields map, which is the only check separating (4) from (1)–(3).

The error message

Deliberately does not name an append path. Per PATTE-135 a suggested remedy has to work in the state where the message appears, and every append path is precisely what's being refused. pad item show --format json is read-only and does surface the raw value, so it's the one action safe to suggest. A test asserts the message never names pad item note.

That requirement came from running the trace rather than reasoning about it — which is also what produced the ordering below.

Ordering

3 → 1 → 2. Load-bearing, not stylistic: part 2's error message names a remedy that destroys affected rows until this guard exists. Shipping part 2 first would weaponize its own error message.

Verification

Every mutant was run, and the killing assertion recorded:

mutant killed by
guard removed the refusal legs
present && Extract(...) == nil — the plausible-wrong guard the empty_list / explicit_null control legs (it passes all refusal tests)
returns mutated fields alongside the error the fields != "" assertion (an errors.Is check alone passes it)
message names pad item note the message assertion
prepend instead of append the ordering assertion
rebuild fieldsMap fresh the sibling-field (github_pr) test
wrong-but-compiling type parameter, both directions the type-discriminating cases

All mutants removed afterwards, verified by grep.

End-to-end against a binary built from this branch: the trace that destroyed two notes now exits non-zero and both notes read back byte-identical; a healthy item still takes a first note and appends onto an existing one.

Gates

  • make lint — 0 issues
  • go test ./... — exit 0, 25 packages
  • make test-pg — exit 0, 3276 tests. Run, not waived: no SQL changed, but this is a shared writer the server handlers reach
  • web checks — not applicable, zero web files
  • Codex CLI — CLEAN at round 3 (3 rounds, findings below)

Codex rounds

  • R1 — P1: item moves drop the reserved arrays entirely. Independently verified and reproduced live, then filed as BUG-2674 rather than fixed here: different mechanism, different door, and it destroys valid data. P2: generic --field writers remain a bypass — that is part 2. Nit: test coverage didn't support the claimed matrix → fixed.
  • R2 — a real hole, not a coverage complaint: the guard is generic, so a wrong-but-compiling type parameter passes every test while permitting an append the extractor reads as empty. Both directions now pinned.
  • R3 — CLEAN. Three nit-level accuracy notes on my commit messages; the one that mattered (an undercount that also sat in the code comment) is fixed where a maintainer reads it.

Known, deliberate non-goals

--field implementation_notes=... still reaches the fields patch and overwrites — that's part 2. Item moves still drop these fields — that's BUG-2674.

https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

…ured field (BUG-2627, part 3)

AppendImplementationNote and AppendDecisionLogEntry rebuilt their entry slice
from Extract*, then assigned it over the field key UNCONDITIONALLY. When the
stored value was something Extract* could not decode, Extract* returned nil and
the assign overwrote that value with a one-element slice -- reporting success.

Observed live, not hypothesised: an item whose implementation_notes held a
JSON-ENCODED STRING lost its stored notes to a single `pad item note` call, with
no warning on any surface. Reproduced on a scratch item with two notes; both
were gone and the command printed "Added implementation note".

The guard cannot key on Extract* returning nil, which is the obvious shape and
the wrong one. Extract* returns nil for three different reasons:

  1. the key is absent -- the first append on an item. Must proceed.
  2. the key holds an empty array -- well-formed, just empty (Extract* has an
     explicit len == 0 -> nil). Must proceed.
  3. the key holds a value that does not decode -- the defect. Must refuse.

`if Extract(...) == nil { refuse }` passes every refusal test and breaks every
first append. So assertStructuredFieldAppendable tests decodability against the
raw value in the fields map, which is the only check that separates (3) from (1)
and (2). Applied to both helpers; ErrStructuredFieldUnreadable is exported so
callers can match on it.

The refusal message deliberately does NOT name an append path. Per PATTE-135 a
suggested remedy has to work in the state where the message appears, and every
append path is precisely what is being refused; `pad item show --format json` is
read-only and does surface the raw value, so it is the one action safe to
suggest. A test asserts the message never names `pad item note`.

Tests are mutation-verified per assertion. Each mutant was run and the killing
assertion recorded: guard removed -> the refusal legs; the plausible-wrong
`Extract(...) == nil` guard -> the empty-list and explicit-null CONTROL legs
(it passes all three refusal tests, so without those controls the wrong
implementation ships green); returning mutated fields alongside the error -> the
`fields != ""` assertion, which an errors.Is check alone would not catch;
message naming an append path -> the message assertion.

Verified end to end against a binary built from this branch: the trace that
destroyed two notes now exits non-zero and both notes read back byte-identical,
while a healthy item still takes a first note and appends onto an existing one.

This is part 3 of BUG-2627 and ships FIRST by design. Parts 1 (repair the
affected row) and 2 (refuse --field for structured keys at the CLI) follow,
because part 2's error message names a remedy that destroys affected rows until
this guard exists.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
…627, part 3)

Round 1 accepted the guard condition and the error path, and was right that the
tests did not carry the bite the commit message claimed. All four gaps closed,
each mutation-verified rather than assumed:

- decision_log had no control legs of its own. The two helpers carry INDEPENDENT
  guard calls, so coverage on the notes side says nothing about the log side --
  an Extract-nil guard on AppendDecisionLogEntry passed every existing test.
  Mutant run: it now fails the empty-list and explicit-null legs.
- No ordering assertion, so a helper that PREPENDED satisfied every length
  check. Mutant run: prepending now fails "existing entry is preserved".
- The non-string refusal shapes (wrong element type, list of strings, bare
  number, incompatible nested value) were untested, and the object case asserted
  only the error, not the empty fields return.
- No test covered a sibling reserved field surviving a successful append.
  Mutant run: rebuilding fieldsMap fresh instead of mutating the parsed one now
  fails, where it previously left every notes assertion green. github_pr is the
  witness because it shares the fields blob.

Codex also reported two findings that are NOT fixed here, deliberately:

P1 -- moving an item drops implementation_notes / decision_log / github_pr
entirely, because items.MigrateFields drops any key absent from the target
schema and these are reserved metadata that no schema declares. Verified by
reading migrate.go and then reproduced live: a well-formed note written through
`pad item note` was destroyed by `pad item move`, silently, with a success
message. That is worse than the defect this part guards -- it destroys VALID
data on a routine operation -- but it is a different mechanism at a different
door, so it is filed as BUG-2674 rather than folded in.

P2 -- generic field writers (--field implementation_notes=...) still reach the
fields patch and overwrite. That is BUG-2627 part 2, which ships after part 1 by
the ordering already recorded on the item.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
…iling swap (BUG-2627, part 3)

Codex round 2, and it is a real hole rather than a coverage complaint. The guard
is generic, so instantiating it with the WRONG entry type still compiles:

    assertStructuredFieldAppendable[ItemImplementationNote](m, ItemFieldDecisionLog)

Every test written so far passes under that swap. The two structs disagree only
on shapes nothing exercised: `{"decision":{"nested":"object"}}` is ACCEPTED by
ItemImplementationNote (unknown key, ignored by encoding/json) and REJECTED by
ItemDecisionLogEntry (Decision is a string). So the guard would permit an append
that ExtractItemDecisionLog then reads as empty -- silently destroying the stored
entry. That is precisely the guard/extractor divergence this change exists to
prevent, reintroduced one type parameter away.

Both directions are now pinned, each mutation-verified:

- decision-log cases only ItemDecisionLogEntry rejects (a `decision` holding an
  object, a `rationale` holding a list). Mutant run: the notes type parameter on
  the log guard fails both.
- the mirror for notes (`summary` holding an object, `details` holding a list).
  Mutant run: the log type parameter on the notes guard fails both, plus the
  pre-existing incompatible-nested-value case.

Correcting the previous commit message: it said round 1's four gaps were "all
closed", which was overstated -- the malformed-entry matrix still ran only
against AppendImplementationNote, which is how this hole survived it.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
…in the type-parameter warning where it is read (BUG-2627, part 3)

Codex round 3 returned CLEAN with three nit-level accuracy notes against my
commit messages. Two are already self-corrected in a later message; the third
matters because the SAME undercount sits in the code comment, which is the
artifact a maintainer actually reads.

- Extract* returns nil for FOUR reasons, not three: absent key, empty array,
  explicit JSON null, and an undecodable value. The code always handled null
  (its own branch), the comment just did not count it.
- The type-parameter hazard round 2 found now lives in the function's doc
  comment rather than only in a test name. A wrong-but-compiling instantiation
  is silently DESTRUCTIVE, not merely wrong, and the next person to add a third
  structured field will reach for this function without reading the tests first.

Comment-only; no behaviour change.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
@xarmian
xarmian merged commit 6a2910d into main Aug 19, 2026
7 checks passed
@xarmian
xarmian deleted the fix/2627-append-guard branch August 19, 2026 17:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant