Skip to content

fix(cli): never truncate incident detail --fields values - #142

Merged
ysyneu merged 1 commit into
mainfrom
fix/detail-projection-main
Aug 11, 2026
Merged

fix(cli): never truncate incident detail --fields values#142
ysyneu merged 1 commit into
mainfrom
fix/detail-projection-main

Conversation

@ysyneu

@ysyneu ysyneu commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Ports the fix reviewed in #141 onto the release lineage. main carries the identical buggy boundProjectedOutput, so the defect is live in the released CLI; the Go changes here are byte-identical to the reviewed commit and cherry-picked cleanly.

Problem

boundProjectedOutput received two different shapes and applied one budget strategy to both:

  • list projections ([]map[string]any, 16 KiB) — many small rows
  • the single-object projection behind incident detail --fields (map[string]any, 8 KiB)

On overflow it shortened every string in place, then halved the per-field limit and retried.

For a list that is defensible. For a single object it silently corrupts data, in three compounding ways:

  1. The per-field limit is a global budget divided by the number of string slots, so a short field is shortened because a different field in the same object is long.
  2. Each round re-reads the already-shortened value, so the shortening compounds: 27 → 13 → 6 → 3 → 1 → 0 bytes.
  3. truncateUTF8Bytes stops appending the ... marker once the limit is <= 3, and returns "" at <= 0. So a 24-character id can arrive as "6" and "Warning" as "W"indistinguishable from a genuinely short value.

The loop's fieldLimit == 0 error exit is unreachable: by the time the limit reaches 0 every string is empty, so the payload always fits and the command exits 0 with hollowed-out fields. Nothing in the output says anything was dropped.

incident detail --fields is the only call site that passes a single object; the other three pass lists.

Change

Split the two shapes behind the existing entry point.

  • Lists keep the existing shorten-and-mark behavior byte for byte (boundProjectedList).
  • Detail projections are never modified (boundProjectedDetail). If the projection does not fit, the command fails and names the largest fields with their sizes, so the caller can fix the request in one pass.

Ties in that ranking break on field name, so the same oversized request never names different fields between runs (Go's map iteration order is randomized).

Why error rather than drop keys or shrink

Omitting a key is indistinguishable from a null one to a jq consumer — that reintroduces exactly the class of bug being removed. And there is already a strictly better escape hatch: omitting --fields returns the full, unbounded detail, i.e. more data, not less. So the failure is always actionable, which is what the error text says.

Tests

  • in-budget detail projection comes back byte-for-byte identical
  • oversized detail projection errors and leaves the input map untouched (reflect.DeepEqual against an independently built expectation — the regression guard)
  • end-to-end incident detail --fields oversized case errors, naming the budget, a largest field, and --fields as the remedy
  • error text is stable across runs when several fields tie on size
  • TestIncidentDetailFieldsProjection previously asserted the truncate-and-succeed path; it now uses realistic in-budget values and asserts a retained value is unaltered. Its structural assertions (key set, excluded fields, size bound) are unchanged.

Verified red before green: reverting only the implementation fails both oversized guards; reverting only the tie-break fails the determinism test.

make check (gofmt/gci, golangci-lint 0 issues, go test -race ./..., build) and make check-cards both pass on this lineage.

Docs

--fields flag help and the hand-written note in skills/flashduty/reference/incident.md now distinguish similar (list — still shortens, marked with ...) from detail --fields (never shortens; fails and names the largest fields). The <!-- GENERATED --> fence is untouched — the edited line sits well before it.

boundProjectedOutput applied one budget strategy to both shapes it
receives: list projections (many small rows) and the single-object
projection behind `incident detail --fields`. On overflow it shortened
every string in place, then halved the per-field limit and retried.

On a single object that silently corrupts data. The limit is a global
budget divided by the number of string slots, so short fields are
punished for long ones sharing the object, and every round re-reads the
already-shortened value, compounding it. Once the limit drops below 4,
truncateUTF8Bytes stops appending the "..." marker, so a 24-character
id can arrive as "6" and "Warning" as "W" -- indistinguishable from a
genuinely short value. The loop's fieldLimit == 0 error exit is
unreachable: by then every string is empty, so the payload always fits
and the command exits 0 with hollowed-out fields.

Split the two shapes. Lists keep the existing shorten-and-mark behavior
byte for byte. A detail projection is now never modified: if it does not
fit, the command fails and names the largest fields with their sizes, so
the caller can drop some --fields, or omit --fields for the full,
unbounded detail -- which returns more data, not less, so the error is
always actionable. Dropping keys instead was rejected: an absent key is
indistinguishable from a null one to a jq consumer, which reintroduces
the same class of bug this removes.

Ties in the largest-field ranking break on name, so the same oversized
request never names different fields between runs.
@ysyneu
ysyneu merged commit 24fd846 into main Aug 11, 2026
12 checks passed
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