fix(models): refuse an append that would destroy an unreadable structured field (BUG-2627, part 3) - #1164
Merged
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part 3 of BUG-2627. Ships first by design — see Ordering below.
The defect
AppendImplementationNote/AppendDecisionLogEntryrebuilt their entry slice fromExtract*, then assigned it over the field key unconditionally. When the stored value was somethingExtract*could not decode,Extract*returnedniland 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:
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:nullif Extract(...) == nil { refuse }passes every refusal test and breaks every first append. SoassertStructuredFieldAppendabletests 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 jsonis read-only and does surface the raw value, so it's the one action safe to suggest. A test asserts the message never namespad 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:
present && Extract(...) == nil— the plausible-wrong guardempty_list/explicit_nullcontrol legs (it passes all refusal tests)fields != ""assertion (anerrors.Ischeck alone passes it)pad item notefieldsMapfreshgithub_pr) testAll 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 issuesgo test ./...— exit 0, 25 packagesmake test-pg— exit 0, 3276 tests. Run, not waived: no SQL changed, but this is a shared writer the server handlers reachCodex rounds
--fieldwriters remain a bypass — that is part 2. Nit: test coverage didn't support the claimed matrix → fixed.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