Repo: coding-hermes/boardctl · Defect commit: f447943 (tests documented the gap but did not fix it) · Files changed: internal/board/validate.go, cmd/boardctl/qacorruptiontest.go, docs/dogfood/qa-corruption.md
boardctl validate silently passes corrupt fixtures.jsonlRepo: coding-hermes/boardctl · Defect commit: f447943 (tests documented the gap but did not fix it) · Files changed: internal/board/validate.go, cmd/boardctl/qa_corruption_test.go, docs/dogfood/qa-corruption.md
A board whose only corrupted file is fixtures.jsonl (first row truncated mid-JSON) reported green:
$ boardctl -C <scratch> validate
board: /tmp/.../.coding-hermes/board (topology A)
rows: 1 tasks, 0 events, 0 fixtures, header parsed
RESULT: OK (0 warning(s))
exit=0
validateFixtures discarded the return value of IterParsed and then re-checked the outer ReadJSONLLines error, which is always nil at that point (the file itself was read fine — only a line failed to parse):
// internal/board/validate.go (defective)
lines, err := ReadJSONLLines(path) // err == nil; file opened OK
if err != nil { ... return }
...
IterParsed(lines, func(row *Row, idx int, _ []byte) error { ... }) // return value DROPPED
if err != nil { // re-checks the nil OUTER error
rep.Add("error", "fixtures.jsonl: %v", err)
}
The parse failure from IterParsed was never observed, so Report.Fixtures stayed 0 and HasErrors() was false → RESULT: OK, exit 0.
The sibling validators were correct and exposed the asymmetry:
| Validator | Iterate call | Error reported? |
|---|---|---|
validateTasks |
ierr := IterParsed(...) |
yes |
validateEvents |
ierr := IterParsed(...) |
yes |
validateFixtures |
IterParsed(...) (discarded) |
no |
This is the general hazard: a helper that reads and parses, where the parse error is only checked against the outer read error — the outer error is already nil by construction, so a line-level failure is unmasked.
internal/board/validate.go — capture the iterate error and itemize it exactly like the passing siblings:
- IterParsed(lines, func(row *Row, idx int, _ []byte) error {
+ ierr := IterParsed(lines, func(row *Row, idx int, _ []byte) error {
count++
...
})
- if err != nil {
- rep.Add("error", "fixtures.jsonl: %v", err)
+ // Capture IterParsed's error: the outer ReadJSONLLines err is already
+ // nil here (it only covers file-level read failures), so re-checking it
+ // would silently swallow any line-level parse failure in fixtures.jsonl —
+ // exactly the blind spot that let a truncated fixtures row validate OK.
+ // The sibling validateTasks/validateEvents paths report the same way.
+ if ierr != nil {
+ rep.Add("error", "fixtures.jsonl: %v", ierr)
}
rep.Fixtures = count
No other production code changes.
cmd/boardctl/qa_corruption_test.go — the fixtures case moves from “known gap, only testable through the reader” to a first-class validate case:
TestQACorruption:
go
{"fixtures_jsonl_truncated_row", "fixtures.jsonl"},
The table asserts: exactly one file changed, exit 1, diagnostic names the target file, RESULT: FAIL, no dagger.db mention, zero writes, and clean restore.TestQACorruptionFixturesDetectedViaReader now asserts detection through both validate (the fixed path) and show (the reader path), keeping the zero-write / restore / decoy contract.KNOWN GAP comment was replaced with the fixed-path description.docs/dogfood/qa-corruption.md — the “Known gap (not fixed)” section was replaced with “Fix applied”, including the before/after code and live evidence.
1. RED — revert only the fixed line, watch the fixtures tests fail:
# temporarily restore the discarded return value / outer-error check
sed -i 's/ierr := IterParsed/I IterParsed/' internal/board/validate.go # illustrative; then swap if ierr -> if err
go test ./cmd/boardctl -run 'TestQACorruption' -count=1
Observed (reverting only the captured error):
--- FAIL: TestQACorruption (0.01s)
--- FAIL: TestQACorruption/fixtures_jsonl_truncated_row (0.00s)
--- FAIL: TestQACorruptionFixturesDetectedViaReader (0.00s)
FAIL github.com/coding-hermes/boardctl/cmd/boardctl
Note that tasks_jsonl_truncated_row, events_jsonl_truncated_row, and header_board_jsonl_truncated_row still passed — the failure localized to the one optional-file function.
2. GREEN — restore the fix and run the gates:
go build ./...
go vet ./...
go test ./... -count=1 -short
make fmt-check
make version-check
Observed:
--- PASS: TestQACorruption (0.01s)
--- PASS: TestQACorruption/tasks_jsonl_truncated_row
--- PASS: TestQACorruption/events_jsonl_truncated_row
--- PASS: TestQACorruption/header_board_jsonl_truncated_row
--- PASS: TestQACorruption/fixtures_jsonl_truncated_row
--- PASS: TestQACorruptionFixturesDetectedViaReader
--- PASS: TestQACorruptionCleanBaseline
--- PASS: TestQACorruptionNoWriteAssertionDetectsWrites
ok github.com/coding-hermes/boardctl/cmd/boardctl
ok github.com/coding-hermes/boardctl/internal/board
ok github.com/coding-hermes/boardctl/internal/fmtcheck
ok github.com/coding-hermes/boardctl/internal/render
ok github.com/coding-hermes/boardctl/internal/versioncheck
ALL GATES GREEN
3. Live-binary check (only fixtures.jsonl corrupted):
$ boardctl -C <scratch> validate
board: /tmp/.../.coding-hermes/board (topology A)
rows: 1 tasks, 0 events, 0 fixtures, header parsed
[error] fixtures.jsonl: line 1: EOF
RESULT: FAIL (1 error(s), 0 warning(s))
boardctl: validation failed # exit 1
Restoring the original bytes returns RESULT: OK, exit 0; the dagger.db decoy is never mentioned and stays byte-identical.
ReadJSONLLines + IterParsed), the parse error is a distinct value that must be assigned and reported. Re-checking the read error after an outer success always yields nil and swallows corruption.IterParsed( with no := on the left) whenever adding a new optional file. The correct pattern is the one already used by validateTasks/validateEvents:
go
ierr := IterParsed(lines, ...)
if ierr != nil { rep.Add("error", "<file>: %v", ierr) }t.TempDir board, assert exit != 0 and the file name in the diagnostic — the sibling that fails the assertion localizes the defect.# Evidence - Problem class: go-jsonl-optional-file-parse-error-swallowed - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-17T08:11:41.608Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Symptom: a Go validator that checks several optional JSONL files reports OK/exit 0 for a corrupt file. Reproduction: boardctl (Go CLI over tasks.jsonl/events.jsonl/board.jsonl/fixtures.jsonl) validate printed 'rows: 0 fixtures' and 'RESULT: OK' exit 0 while fixtures.jsonl's first row was truncated mid-JSON. Root cause: validateFixtures did `lines, err := ReadJSONLLines(path)` then `IterParsed(lines, func(row *Row, idx int, _ []byte) error {...})` WITHOUT assigning IterParsed's return value; the follow-up `if err != nil` re-checked the OUTER ReadJSONLLines error, which is nil at that point. Sibling validators validateTasks/validateEvents both did `ierr := IterParsed(...)` and `rep.Add(\"error\", ...)` on it, so only the optional-file path was blind. Debugging recipe that found it: write a test that corrupts exactly ONE named file on a disposable t.TempDir board and asserts exit!=0 plus the target file name in the diagnostic - the third sibling file failing the assertion while the others passed localized the defect to one function in seconds; then compare the three sibling call sites by grep. Secondary lesson: a reader/parse helper whose error is only checked at the outer call level lets a line-level parse failure through when the outer call already succeeded. Fix: capture the iterate call's error and add an itemized error finding naming the file, exactly like the passing siblings. Verify by RED-reverting only the fixed line and watching the new subtest fail.", "environment": "", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-jsonl-optional-file-parse-error-swallowed", "provider": "openrouter", "solved_at": "2026-09-17T08:11:41.608Z", "version": ""}