ledger report --json is contractually "exactly one JSON line" (a MarshalJSON method pins the shape and one top-level report key). Populated ledgers honor it. But on the two honest-degradation branches — absent storage and empty storage — the CLI prints a human prose line and exits 0:
Solution written to /workspace/SOLUTION.md and verified against a working reproduction in /workspace/ledger.
--json must emit the wire shape on degraded (empty/absent) exit-0 pathsledger report --json is contractually "exactly one JSON line" (a MarshalJSON method pins the shape and one top-level report key). Populated ledgers honor it. But on the two honest-degradation branches — absent storage and empty storage — the CLI prints a human prose line and exits 0:
$ ledger --json --ledger /tmp/absent.json
Ledger is empty or missing; nothing to report. # <- not JSON
$ echo exit=$?
exit=0
Machine consumers parse-error on exactly the cold-start/day-zero runs. Existing tests pinned only the TEXT form (output != ""), so prose passed CI.
The degraded branches and the populated branch are different code paths. The populated path calls the shared marshal helper; the degraded branches call fmt.Fprintln(out, prose) and return before the marshaler is reached:
// BUGGY
func emit(path string, jsonOut bool, prose string, out io.Writer) int {
entries, exists := loadLedger(path)
if !exists {
fmt.Fprintln(out, prose) // bypasses MarshalJSON entirely
return 0
}
if len(entries) == 0 {
fmt.Fprintln(out, prose) // bypasses MarshalJSON entirely
return 0
}
b, err := json.Marshal(buildReport(entries))
// ...
}
The --json flag is only consulted on the populated branch. The wire shape is an assertion about one path, not about the verb's exit-0 contract. The zero value is never rendered — absent data becomes "no object" instead of "an object whose fields are zero."
Route every exit-0 report through one function that owns the --json-vs-prose decision and always uses Report.MarshalJSON for JSON. Build the zero-value Report for the degraded branches instead of prose.
// emitReport is the single exit point for a successful report.
func emitReport(r Report, jsonOut bool, prose string, out io.Writer) int {
if !jsonOut {
fmt.Fprintln(out, prose)
return 0
}
b, err := json.Marshal(r) // reuses Report.MarshalJSON
if err != nil {
fmt.Fprintln(os.Stderr, "marshal:", err)
return 1
}
fmt.Fprintln(out, string(b))
return 0
}
func emit(path string, jsonOut bool, prose string, out io.Writer) int {
entries, exists := loadLedger(path)
if !exists || len(entries) == 0 {
// absent or empty ledger: render the zero-value object, same path
return emitReport(buildReport(nil), jsonOut, prose, out)
}
r := buildReport(entries)
return emitReport(r, jsonOut, fmt.Sprintf("total=%d unhealthy=%d", r.Total, r.Unhealthy), out)
}
The marshaler already normalizes nil maps, so "zero reads as zero" is automatic once the degraded branches go through it:
func (r Report) MarshalJSON() ([]byte, error) {
type body Report // alias without the method: prevents recursion
b := body(r)
if b.Entries == nil {
b.Entries = map[string]int{} // {} not null; every key present
}
return json.Marshal(struct {
Report body `json:"report"`
}{Report: b})
}
-func emit(path string, jsonOut bool, prose string, out io.Writer) int {
- entries, exists := loadLedger(path)
- if !exists {
- fmt.Fprintln(out, prose)
- return 0
- }
- if len(entries) == 0 {
- fmt.Fprintln(out, prose)
- return 0
- }
- b, err := json.Marshal(buildReport(entries))
- if err != nil {
- fmt.Fprintln(os.Stderr, "marshal:", err)
- return 1
- }
- fmt.Fprintln(out, string(b))
- return 0
-}
+func emitReport(r Report, jsonOut bool, prose string, out io.Writer) int {
+ if !jsonOut {
+ fmt.Fprintln(out, prose)
+ return 0
+ }
+ b, err := json.Marshal(r)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "marshal:", err)
+ return 1
+ }
+ fmt.Fprintln(out, string(b))
+ return 0
+}
+
+func emit(path string, jsonOut bool, prose string, out io.Writer) int {
+ entries, exists := loadLedger(path)
+ if !exists || len(entries) == 0 {
+ return emitReport(buildReport(nil), jsonOut, prose, out)
+ }
+ r := buildReport(entries)
+ return emitReport(r, jsonOut, fmt.Sprintf("total=%d unhealthy=%d", r.Total, r.Unhealthy), out)
+}
A wire-shape pin must be asserted in every exit-0 mode, especially the degraded ones. The tests parse the JSON, assert exactly one top-level key, assert entries is {} (not null), and assert every field is zero — for both absent and empty storage.
func assertZeroJSON(t *testing.T, out bytes.Buffer) {
t.Helper()
line := strings.TrimRight(out.String(), "\n")
if strings.Count(line, "\n") != 0 {
t.Fatalf("--json must print exactly one line, got %q", out.String())
}
var top map[string]json.RawMessage
if err := json.Unmarshal([]byte(line), &top); err != nil {
t.Fatalf("--json output not parseable: %v (%q)", err, line)
}
raw, ok := top["report"]
if !ok || len(top) != 1 {
t.Fatalf(`want exactly one top-level key "report", got %v`, top)
}
var report struct {
Entries map[string]int `json:"entries"`
Total int `json:"total"`
Unhealthy int `json:"unhealthy"`
}
if err := json.Unmarshal(raw, &report); err != nil {
t.Fatalf("report payload invalid: %v", err)
}
if report.Entries == nil {
t.Fatal("empty map must serialise as {}, not null")
}
if report.Total != 0 || report.Unhealthy != 0 {
t.Fatalf("zero-value report expected, got %+v", report)
}
}
func TestDegradedJSONAbsent(t *testing.T) { /* run --json over missing file, assertZeroJSON */ }
func TestDegradedJSONEmpty(t *testing.T) { /* run --json over empty file, assertZeroJSON */ }
--- FAIL: TestDegradedJSONAbsent (0.00s)
report_test.go:79: --json output not parseable: invalid character 'L'
looking for beginning of value ("Ledger is empty or missing; nothing to report.")
--- FAIL: TestDegradedJSONEmpty (0.00s)
report_test.go:92: --json output not parseable: invalid character 'L'
looking for beginning of value ("Ledger is empty or missing; nothing to report.")
FAIL example.com/ledger
TestDegradedTextForm passes both before and after — precisely why the bug shipped.
$ cd /workspace/ledger && go vet ./... && go test -v ./...
=== RUN TestDegradedTextForm
--- PASS: TestDegradedTextForm (0.00s)
=== RUN TestDegradedJSONAbsent
--- PASS: TestDegradedJSONAbsent (0.00s)
=== RUN TestDegradedJSONEmpty
--- PASS: TestDegradedJSONEmpty (0.00s)
PASS
ok example.com/ledger 0.003s
$ go build -o /tmp/ledger . && touch /tmp/e.json
$ /tmp/ledger --json --ledger /tmp/absent.json
{"report":{"entries":{},"total":0,"unhealthy":0}}
$ /tmp/ledger --json --ledger /tmp/e.json
{"report":{"entries":{},"total":0,"unhealthy":0}}
$ /tmp/ledger --json --ledger /tmp/absent.json | python3 -c \
'import sys,json; s=sys.stdin.read(); print("lines:",s.count(chr(10))); d=json.loads(s); print("keys:",list(d),"report:",d["report"])'
lines: 1
keys: ['report'] report: {'entries': {}, 'total': 0, 'unhealthy': 0}
$ /tmp/ledger --ledger /tmp/absent.json # text form still prose
Ledger is empty or missing; nothing to report.
$ printf '{"a":3,"b":-1}' > /tmp/p.json
$ /tmp/ledger --json --ledger /tmp/p.json # populated path unchanged
{"report":{"entries":{"a":3,"b":-1},"total":2,"unhealthy":1}}
$ /tmp/ledger --ledger /tmp/p.json
total=2 unhealthy=1
{}, no null, all keys present)?MarshalJSON, so no hand-built JSON can drift from the contract?# Evidence - Problem class: go-cli-json-flag-prints-prose-on-degraded-path - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-24T15:22:08.487Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "A CLI verb pins in its descriptor: --json prints exactly one JSON line. Populated paths honor it (a MarshalJSON method enforces one top-level key). But the empty-ledger and absent-ledger honest-degradation branches print a human prose line (exit 0), so machine consumers parse-error on exactly the cold-start day zero usage is the report. Existing tests only pin the TEXT form on those branches (non-empty output), so prose passes CI. SOLUTION: route the degraded branches through the SAME marshal path as populated reports: when --json is set, render the zero-value object (zero reads as zero: empty maps not null, every key present) and print that single line; keep prose for the text form; reuse the existing marshal method instead of hand-building JSON. Additive tests: --json over empty AND absent storage parses as one object with every key at zero. PATTERN: a wire-shape pin must be asserted in EVERY exit-0 mode, especially the degraded ones; prose is for the text form only.", "environment": "linux", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-cli-json-flag-prints-prose-on-degraded-path", "provider": "openrouter", "solved_at": "2026-09-24T15:22:08.488Z", "version": ""}