From fca93ea29b7df1de3585f6229e8501c5d2be32d3 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Wed, 19 Aug 2026 07:22:20 -0700 Subject: [PATCH 1/3] fix(cli): announce shortened projection values instead of clipping silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An over-budget list projection shortens string values and marks them with "...", on the reasoning that a marked value is distinguishable from a genuinely short one. That holds for a reader. It does not hold for the consumer `--json` exists to serve: a jq filter or exact match runs over the value itself and never sees the marker, so a clipped title turns a matching query into an empty result that looks exactly like "nothing matched". The cost lands on whoever wrote the filter, who re-runs variations of a query that was correct all along. boundProjectedOutput now returns a note naming the clipped fields, and the three list call sites print it on stderr next to the existing default- projection note. Behaviour is otherwise unchanged: the same values are shortened, with the same fair per-field cap and the same marker. The list-overflow error also gains what the detail path already had — the largest fields by aggregate byte size. Finding the oversized field previously meant re-running the query once per field until one of them was dropped; the error now names it outright. Both paths share one helper. --- internal/cli/alert_event.go | 4 +- internal/cli/fieldproject.go | 139 ++++++++++++++++++++++-------- internal/cli/fieldproject_test.go | 88 +++++++++++++++++-- internal/cli/incident.go | 12 ++- 4 files changed, 195 insertions(+), 48 deletions(-) diff --git a/internal/cli/alert_event.go b/internal/cli/alert_event.go index fc23434..893b31a 100644 --- a/internal/cli/alert_event.go +++ b/internal/cli/alert_event.go @@ -97,9 +97,11 @@ func newAlertEventListCmd() *cobra.Command { if err != nil { return err } - if err := boundProjectedOutput(proj, compactListOutputLimit); err != nil { + note, err := boundProjectedOutput(proj, compactListOutputLimit) + if err != nil { return err } + noteProjectionShortening(cmd.ErrOrStderr(), note) return ctx.PrintList(proj, nil, len(result.Items), page, int(result.Total)) } diff --git a/internal/cli/fieldproject.go b/internal/cli/fieldproject.go index c8bd01e..a38b025 100644 --- a/internal/cli/fieldproject.go +++ b/internal/cli/fieldproject.go @@ -80,6 +80,18 @@ func noteDefaultProjection(w io.Writer, fields []string) { strings.Join(fields, ",")) } +// noteProjectionShortening tells the caller, on stderr, that some values came +// back clipped. Without it a shortened value is only visible to a reader, not +// to the jq filter or exact match a --json consumer runs over it, so a query +// that silently matches nothing looks like an empty result rather than a +// truncated one. +func noteProjectionShortening(w io.Writer, note string) { + if note == "" { + return + } + _, _ = fmt.Fprintln(w, note) +} + // boundProjectedOutput keeps the new agent-oriented projections below their // command budget without changing the selected keys. List rows (many small // records) are shortened fairly when they overflow the budget, with @@ -88,44 +100,49 @@ func noteDefaultProjection(w io.Writer, fields []string) { // a genuinely short value, so silently shortening it would hand the caller // wrong data instead of a compact one. If a detail projection doesn't fit, // the command fails with an error instead. -func boundProjectedOutput(data any, maxBytes int) error { +// +// It returns a caller-printable note (empty when nothing was shortened) that +// names the clipped fields, so the caller can announce the loss on stderr — +// the "..." marker is only visible to something that reads the value, never +// to the filter a --json consumer runs over it. +func boundProjectedOutput(data any, maxBytes int) (string, error) { switch value := data.(type) { case map[string]any: - return boundProjectedDetail(value, maxBytes) + return "", boundProjectedDetail(value, maxBytes) case []map[string]any: return boundProjectedList(value, maxBytes) default: - return fmt.Errorf("internal error: unsupported projected output %T", data) + return "", fmt.Errorf("internal error: unsupported projected output %T", data) } } -// boundProjectedDetail rejects an oversized single-object projection instead -// of truncating it, naming the largest fields so the caller can fix the -// request in one pass: drop some of them from --fields, or drop --fields -// entirely for the full, unbounded detail. -func boundProjectedDetail(row map[string]any, maxBytes int) error { - encoded, err := marshalStructured(row) - if err != nil { - return err - } - if len(encoded)+1 < maxBytes { - return nil +// largestProjectedFields names the up to three fields carrying the most bytes +// in a projection, so an over-budget request can be narrowed in one pass +// instead of one re-run per field. Sizes are summed per field across every +// row, which is what makes it meaningful for a list: the field responsible +// for the overflow is the one that is big in aggregate, not in any one row. +// Ties break on name so the same oversized request always names the same +// fields, despite Go's randomized map iteration order. +func largestProjectedFields(rows []map[string]any) (string, error) { + totals := map[string]int{} + for _, row := range rows { + for key, value := range row { + encoded, err := marshalStructured(map[string]any{key: value}) + if err != nil { + return "", err + } + totals[key] += len(encoded) + } } type fieldSize struct { name string size int } - sizes := make([]fieldSize, 0, len(row)) - for key, value := range row { - fieldEncoded, err := marshalStructured(map[string]any{key: value}) - if err != nil { - return err - } - sizes = append(sizes, fieldSize{key, len(fieldEncoded)}) + sizes := make([]fieldSize, 0, len(totals)) + for name, size := range totals { + sizes = append(sizes, fieldSize{name, size}) } - // Ties break on name so the same oversized request always names the same - // fields, despite Go's randomized map iteration order. sort.Slice(sizes, func(i, j int) bool { if sizes[i].size != sizes[j].size { return sizes[i].size > sizes[j].size @@ -139,8 +156,28 @@ func boundProjectedDetail(row map[string]any, maxBytes int) error { for i, f := range sizes { largest[i] = fmt.Sprintf("%s (%d bytes)", f.name, f.size) } + return strings.Join(largest, ", "), nil +} + +// boundProjectedDetail rejects an oversized single-object projection instead +// of truncating it, naming the largest fields so the caller can fix the +// request in one pass: drop some of them from --fields, or drop --fields +// entirely for the full, unbounded detail. +func boundProjectedDetail(row map[string]any, maxBytes int) error { + encoded, err := marshalStructured(row) + if err != nil { + return err + } + if len(encoded)+1 < maxBytes { + return nil + } + + largest, err := largestProjectedFields([]map[string]any{row}) + if err != nil { + return err + } return fmt.Errorf("projected detail is %d bytes, exceeds the %d-byte limit; largest fields: %s; request fewer --fields, or omit --fields for the full, unbounded detail", - len(encoded), maxBytes, strings.Join(largest, ", ")) + len(encoded), maxBytes, largest) } // boundProjectedList shortens a list projection's string values fairly when @@ -153,14 +190,25 @@ func boundProjectedDetail(row map[string]any, maxBytes int) error { // marker itself disappear, so a shortened value is always distinguishable // from a genuinely short one; if no cap at or above that floor fits, the // command fails with a small error instead of emitting values that look -// real but aren't. -func boundProjectedList(rows []map[string]any, maxBytes int) error { +// real but aren't. Whatever it clips, it reports back in the returned note. +func boundProjectedList(rows []map[string]any, maxBytes int) (string, error) { encoded, err := marshalStructured(rows) if err != nil { - return err + return "", err } if len(encoded)+1 < maxBytes { - return nil + return "", nil + } + + // The overflow error names the fields responsible, exactly as the detail + // path does, so the request can be narrowed in one pass. + tooBig := func() (string, error) { + largest, err := largestProjectedFields(rows) + if err != nil { + return "", err + } + return "", fmt.Errorf("projected list is %d bytes across %d rows, exceeds the %d-byte limit; largest fields: %s; request fewer rows (--limit) or fewer --fields", + len(encoded), len(rows), maxBytes, largest) } maxLen := 0 @@ -172,7 +220,7 @@ func boundProjectedList(rows []map[string]any, maxBytes int) error { } } if maxLen == 0 { - return fmt.Errorf("structured projection exceeds %d-byte limit; request fewer rows or fields", maxBytes) + return tooBig() } fits := func(limit int) (bool, error) { @@ -202,12 +250,12 @@ func boundProjectedList(rows []map[string]any, maxBytes int) error { // reintroduce. const minMarkedTruncationCap = 4 if maxLen <= minMarkedTruncationCap { - return fmt.Errorf("structured projection exceeds %d-byte limit; request fewer rows or fields", maxBytes) + return tooBig() } if ok, err := fits(minMarkedTruncationCap); err != nil { - return err + return "", err } else if !ok { - return fmt.Errorf("structured projection exceeds %d-byte limit; request fewer rows or fields", maxBytes) + return tooBig() } // Binary search for the largest cap that still fits: fits(limit) is true @@ -219,7 +267,7 @@ func boundProjectedList(rows []map[string]any, maxBytes int) error { mid := lo + (hi-lo+1)/2 ok, err := fits(mid) if err != nil { - return err + return "", err } if ok { lo = mid @@ -228,14 +276,33 @@ func boundProjectedList(rows []map[string]any, maxBytes int) error { } } + shortened, total := 0, 0 + fields := map[string]bool{} for _, row := range rows { for key, value := range row { - if text, ok := value.(string); ok { - row[key] = truncateUTF8Bytes(text, lo) + text, ok := value.(string) + if !ok { + continue + } + total++ + clipped := truncateUTF8Bytes(text, lo) + if clipped != text { + shortened++ + fields[key] = true } + row[key] = clipped } } - return nil + if shortened == 0 { + return "", nil + } + names := make([]string, 0, len(fields)) + for name := range fields { + names = append(names, name) + } + sort.Strings(names) + return fmt.Sprintf("note: %d of %d string values were shortened to fit the %d-byte limit and now end with \"...\" (fields: %s); matching or filtering on those fields will miss — narrow --fields or --limit for untruncated values", + shortened, total, maxBytes, strings.Join(names, ", ")), nil } func truncateUTF8Bytes(value string, maxBytes int) string { diff --git a/internal/cli/fieldproject_test.go b/internal/cli/fieldproject_test.go index c82c048..0429c89 100644 --- a/internal/cli/fieldproject_test.go +++ b/internal/cli/fieldproject_test.go @@ -42,7 +42,7 @@ func TestBoundProjectedOutputCapsStructuredFormats(t *testing.T) { "title": strings.Repeat("数据库故障", 2000), }} - if err := boundProjectedOutput(rows, 512); err != nil { + if _, err := boundProjectedOutput(rows, 512); err != nil { t.Fatalf("bound projected output: %v", err) } encoded, err := marshalStructured(rows) @@ -68,8 +68,8 @@ func TestBoundProjectedOutputRejectsIrreducibleMetadata(t *testing.T) { rows[i] = map[string]any{"count": i} } - err := boundProjectedOutput(rows, 512) - if err == nil || !strings.Contains(err.Error(), "request fewer rows or fields") { + _, err := boundProjectedOutput(rows, 512) + if err == nil || !strings.Contains(err.Error(), "request fewer rows") { t.Fatalf("irreducible output error = %v, want bounded guidance", err) } } @@ -92,7 +92,7 @@ func TestBoundProjectedOutputDetailWithinBudgetLeavesValuesUnchanged(t *testing. "progress": "Triggered", } - if err := boundProjectedOutput(row, compactDetailOutputLimit); err != nil { + if _, err := boundProjectedOutput(row, compactDetailOutputLimit); err != nil { t.Fatalf("bound projected output: %v", err) } if !reflect.DeepEqual(row, want) { @@ -119,7 +119,7 @@ func TestBoundProjectedOutputDetailOversizedErrorsWithoutMutating(t *testing.T) "root_cause": strings.Repeat("disk exhaustion details ", 3000), } - err := boundProjectedOutput(row, 512) + _, err := boundProjectedOutput(row, 512) if err == nil { t.Fatal("expected an error for an oversized detail projection, got nil") } @@ -151,7 +151,7 @@ func TestBoundProjectedOutputDetailErrorIsDeterministic(t *testing.T) { "delta": strings.Repeat("d", 400), "echo": strings.Repeat("e", 400), } - err := boundProjectedOutput(row, 512) + _, err := boundProjectedOutput(row, 512) if err == nil { t.Fatal("expected an error for an oversized detail projection, got nil") } @@ -196,7 +196,7 @@ func TestIncidentListStructuredDefaultUsesCompactProjection(t *testing.T) { row["title"] = strings.Repeat("数据库故障", 5000) stub.data = map[string]any{"items": []any{row}, "total": 1} - out, _, err := execCommandSplit("incident", "list", "--output-format", format) + out, stderrText, err := execCommandSplit("incident", "list", "--output-format", format) if err != nil { t.Fatalf("execCommandSplit: %v", err) } @@ -206,6 +206,11 @@ func TestIncidentListStructuredDefaultUsesCompactProjection(t *testing.T) { if !utf8.ValidString(out) || !strings.Contains(out, "...") { t.Fatalf("bounded %s incident list must retain valid UTF-8 and show truncation", format) } + // The clipped value must be announced, not just marked: a --json + // consumer filters on the value and never sees the "..." itself. + if !strings.Contains(stderrText, "were shortened to fit") || !strings.Contains(stderrText, "title") { + t.Errorf("shortened %s incident list should announce the clipped field on stderr, got:\n%s", format, stderrText) + } }) } @@ -800,7 +805,7 @@ func TestBoundProjectedListNeverEmitsUnmarkedTruncation(t *testing.T) { originals[i] = clone } - if err := boundProjectedOutput(rows, compactListOutputLimit); err != nil { + if _, err := boundProjectedOutput(rows, compactListOutputLimit); err != nil { t.Fatalf("bound: %v", err) } @@ -847,3 +852,70 @@ func TestStructuredFieldsEmptyErrors(t *testing.T) { }) } } + +// TestBoundProjectedListAnnouncesShortening pins that a list projection which +// had to clip values says so on the caller's side. The "..." marker alone is +// only visible to something that READS the value; a --json consumer runs a jq +// filter or an exact match over it, where a clipped string produces an empty +// result that is indistinguishable from "nothing matched" — the expensive +// failure this note exists to prevent. +func TestBoundProjectedListAnnouncesShortening(t *testing.T) { + for _, format := range []string{"json", "toon"} { + t.Run(format, func(t *testing.T) { + saveAndResetGlobals(t) + flagOutputFormat = format + rows := []map[string]any{{ + "incident_id": "inc-1", + "title": strings.Repeat("payment-gateway timeout ", 200), + }} + + note, err := boundProjectedOutput(rows, 512) + if err != nil { + t.Fatalf("bound projected output: %v", err) + } + if note == "" { + t.Fatalf("shortened projection returned no note; caller cannot tell values were clipped") + } + if !strings.Contains(note, "title") { + t.Fatalf("note = %q, want it to name the shortened field (title)", note) + } + }) + } +} + +// TestBoundProjectedListNoNoteWhenNothingShortened keeps the note honest: a +// projection that fits must not claim anything was clipped. +func TestBoundProjectedListNoNoteWhenNothingShortened(t *testing.T) { + saveAndResetGlobals(t) + flagOutputFormat = "json" + rows := []map[string]any{{"incident_id": "inc-1", "title": "disk full"}} + + note, err := boundProjectedOutput(rows, 512) + if err != nil { + t.Fatalf("bound projected output: %v", err) + } + if note != "" { + t.Fatalf("fitting projection returned note %q, want none", note) + } +} + +// TestBoundProjectedListErrorNamesLargestFields pins that a list projection +// which cannot fit at all says WHICH fields are responsible, exactly as the +// detail path already does. Without it the only way to find the oversized +// field is to re-run the query once per field. +func TestBoundProjectedListErrorNamesLargestFields(t *testing.T) { + saveAndResetGlobals(t) + flagOutputFormat = "json" + rows := make([]map[string]any, 200) + for i := range rows { + rows[i] = map[string]any{"count": i, "score": i * 2} + } + + _, err := boundProjectedOutput(rows, 512) + if err == nil { + t.Fatalf("irreducible projection = nil error, want refusal") + } + if !strings.Contains(err.Error(), "largest fields:") { + t.Fatalf("list overflow error = %q, want it to name the largest fields", err) + } +} diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 921a2a9..cfea325 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -127,9 +127,11 @@ func newIncidentListCmd() *cobra.Command { if err != nil { return err } - if err := boundProjectedOutput(proj, compactListOutputLimit); err != nil { + note, err := boundProjectedOutput(proj, compactListOutputLimit) + if err != nil { return err } + noteProjectionShortening(cmd.ErrOrStderr(), note) return ctx.PrintList(proj, nil, len(result.Items), page, int(result.Total)) } @@ -614,9 +616,11 @@ func newIncidentSimilarCmd() *cobra.Command { if err != nil { return err } - if err := boundProjectedOutput(proj, compactListOutputLimit); err != nil { + note, err := boundProjectedOutput(proj, compactListOutputLimit) + if err != nil { return err } + noteProjectionShortening(cmd.ErrOrStderr(), note) return ctx.Printer.Print(proj, nil) } @@ -1584,9 +1588,11 @@ func newIncidentDetailCmd() *cobra.Command { if err != nil { return err } - if err := boundProjectedOutput(proj[0], compactDetailOutputLimit); err != nil { + note, err := boundProjectedOutput(proj[0], compactDetailOutputLimit) + if err != nil { return err } + noteProjectionShortening(cmd.ErrOrStderr(), note) return ctx.Printer.Print(proj[0], nil) } return ctx.Printer.Print(result, nil) From 33c9c3e05409f2f92cd82c8230894ac0b0fa0295 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Wed, 19 Aug 2026 07:28:25 -0700 Subject: [PATCH 2/3] docs(skills): point title search at --query instead of local jq matching The stderr note added in this branch tells a caller when list values were clipped, but the cheaper answer is not to match on them at all: incident list already takes --query, a server-side full-text search over title/labels/content that is unaffected by whatever the projection did to the rendered value. Filtering a page of list output locally is the pattern that runs into clipped titles in the first place, and it also costs a page walk the server could have skipped. Say so on the card, next to the existing --fields advice that encourages projecting list scans down. --- skills/flashduty/reference/incident.md | 1 + 1 file changed, 1 insertion(+) diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index deabacc..d480d03 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -494,6 +494,7 @@ Update a work item - **`similar` only works on channel-backed incidents** (those with a real `channel_id`). Manually created incidents with no channel return HTTP 400 "Channel not found" — this is expected, not transient. Fall back to `incident list --query ""` for text search. - **`update` vs `reset`**: `update ` edits title/description/severity/custom fields. `reset ` additionally supports `--impact`, `--root-cause`, `--resolution` (the AI narrative fields). Use `reset` for post-incident write-back. - **If `list` returns a `total`, use it instead of page-walking.** For "how many incidents are Triggered / Processing / Closed", run one filtered `incident list --progress ...` per bucket and read the returned `total`. Do not fetch page 1/2/3 just to derive counts the server already computed. +- **Search with `--query`, don't substring-match `title` from list output.** A `--fields` list projection may come back with long values clipped to fit its byte budget (a stderr note names the fields when it happens), so a local `jq test()` / `contains()` over `title` can miss rows that really do match, and an empty result is indistinguishable from a genuine non-match. `--query` is a server-side full-text search over title/labels/content — correct regardless of projection, and cheaper than pulling pages to filter locally. (It also resolves a 24-char `incident_id` or 6-char `num` to a direct lookup.) - **Use `--fields` to keep list scans compact.** When the goal is to identify matching incidents or collect IDs/numbers/titles, project only the needed columns first, then fetch one target incident with `detail` / `alerts` / `timeline`. - **`list` window cap**: `--since`/`--until` window must be < 31 days; `--limit` max 100. Empty result is authoritative — do not widen filters or retry. - **`merge` is irreversible**: source incidents are absorbed into target permanently. Always list and confirm both IDs before running. From 4ed699f63e63a5b8d67c1dd9e7e342c1d8033867 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Wed, 19 Aug 2026 07:33:18 -0700 Subject: [PATCH 3/3] docs(skills): sync the projection-budget cards to the new error and note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cards quoted behaviour this branch changes. incident.md reproduced the old flat error string verbatim ("request fewer rows or fields"), which no longer appears in the output, and described the list path as failing without naming anything — it now names the largest fields, like detail always has. Both incident.md and alert.md also documented the "..." marker as the only sign that a value was shortened. That is what made the marker a trap: it is visible when you read a row, not when you filter one. Say that the stderr note exists and name what it is for. --- skills/flashduty/reference/alert.md | 2 +- skills/flashduty/reference/incident.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/skills/flashduty/reference/alert.md b/skills/flashduty/reference/alert.md index 7feef9f..af2a11b 100644 --- a/skills/flashduty/reference/alert.md +++ b/skills/flashduty/reference/alert.md @@ -38,7 +38,7 @@ fduty alert feed --output-format toon fduty alert-event list --channel --since 1h --limit 30 --output-format toon ``` -Structured `alert-event list` output stays below 16 KiB. A trailing `...` means a long retained string was shortened. In json/toon mode rows default to the compact projection `event_id,alert_id,event_severity,event_status,event_time,title` (a stderr note says so when it applies); any other response field is one `--fields` away — a key missing from the output means it wasn't selected, not that the server omits it. +Structured `alert-event list` output stays below 16 KiB. A trailing `...` means a long retained string was shortened, and a stderr note names the clipped fields — heed it before matching on those values, because the clipped text is what a `jq` filter sees. In json/toon mode rows default to the compact projection `event_id,alert_id,event_severity,event_status,event_time,title` (a stderr note says so when it applies); any other response field is one `--fields` away — a key missing from the output means it wasn't selected, not that the server omits it. ## Hot flow — merge noisy alerts into an existing incident diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index d480d03..d82cc1d 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -74,11 +74,11 @@ fduty incident comment "$ID" --comment-file "$COMMENT_FILE" fduty incident resolve --root-cause "DB primary failover delay" --resolution "Failover completed; latency normal." ``` -Projected `similar` lists stay below 16 KiB; a trailing `...` in a list row means a long retained string was shortened. `detail --fields` is different: it never shortens values — the projection must fit within 8 KiB as requested or the command fails and names the largest fields, so drop some fields (or drop `--fields` for the full unbounded detail) and retry. +Projected `similar` lists stay below 16 KiB; a trailing `...` in a list row means a long retained string was shortened, and a stderr note names the fields that were clipped. `detail --fields` is different: it never shortens values — the projection must fit within 8 KiB as requested or the command fails and names the largest fields, so drop some fields (or drop `--fields` for the full unbounded detail) and retry. `comment` never accepts the text as a command-line argument — only `--comment-file ` (or `--comment-file -` to read stdin), so backticks/`$()`/quotes inside the comment are inert. The command also reads back every target's timeline after writing and exits non-zero unless it finds an entry matching what it sent, so `Commented on ...` is proof of content fidelity, not just acceptance — no separate manual read-back is needed. Leading and trailing whitespace is stripped before sending (the server strips it too, so this is what gets stored); everything else, including interior blank lines, is preserved exactly. -> `incident list --output-format json|toon` defaults to the compact row projection `incident_id,title,incident_severity,progress,start_time,channel_id`. Pass `--fields incident_id,title,channel_id,start_time` when you need different list columns; use `incident detail ` / `incident get ` for full incident records. Any list-response field — including `labels` — is selectable this way (a key missing from the output means it wasn't selected, NOT that the server omits it; the command prints a stderr note when the default projection applies). The one exception is `alerts`: neither list nor detail responses ever fill it — use `incident alerts ` for an incident's alerts. Wide fields over many rows can exceed the 16 KiB structured-output bound and the command errors with "request fewer rows or fields" — lower `--limit`/page through, or use `insight` aggregates for distributions instead of dumping labels row by row. +> `incident list --output-format json|toon` defaults to the compact row projection `incident_id,title,incident_severity,progress,start_time,channel_id`. Pass `--fields incident_id,title,channel_id,start_time` when you need different list columns; use `incident detail ` / `incident get ` for full incident records. Any list-response field — including `labels` — is selectable this way (a key missing from the output means it wasn't selected, NOT that the server omits it; the command prints a stderr note when the default projection applies). The one exception is `alerts`: neither list nor detail responses ever fill it — use `incident alerts ` for an incident's alerts. Wide fields over many rows can exceed the 16 KiB structured-output bound; the command then errors and names the largest fields by aggregate size, so lower `--limit`, drop the field it names, or use `insight` aggregates for distributions instead of dumping labels row by row. Before it errors it tries to fit the rows by shortening long string values — when it does, a stderr note says how many values were clipped and in which fields. ## Hot flow — full fault analysis (read-only summary)