diff --git a/CHANGELOG.md b/CHANGELOG.md index e9841be0d..914d05889 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Declared peer stores: a Darling MCP server that names its siblings instead of answering "unknown server"** ([#2339]) - tier 1 of the multi-store fix, disclosure only. With the fleet split across several boxes (one store each: SQL Server primaries on one, their readable replicas on another, PostgreSQL on a third) every box's MCP server answered over ITS store alone, so a server monitored by a sibling resolved as not-found - indistinguishable from a server nobody monitors, which with a deliberately-split fleet is now the normal case rather than an edge. An optional `peers` block in darling.json (`thisStoreCovers`, plus per-peer `name` / `covers` / optional `matches` name-substrings) is disclosed at the three places an agent forms its picture of the fleet: the MCP instructions gain a Fleet Coverage section above the tool census, `list_servers` gains `this_store_covers` + `peer_fleets` + a `peer_note` (its empty-registry answer is prose rather than JSON, and carries the peer list too - a store with nothing registered is a fresh or just-restarted box, the worst place to drop the disclosure), and the server-resolution miss appends "not monitored HERE - matches the declared coverage of " to the existing available-servers listing. There is NO credential, NO address and NO connectivity behind it: the service never contacts a peer and says so in every message, and the publish itself REFUSES a peers block whose text looks like a connection string or credential, since all of it is sent verbatim to every connected MCP client. That guard lives in the publish rather than only in config validation because the MCP host loads its own config and deliberately never validates it (its fail-closed checks are host-local), so validation alone would have left the one path that actually broadcasts uncovered; a refusal publishes nothing at all rather than the valid subset. An empty `peer_fleets` carries its own note - "this may be the only store, or nobody declared the siblings, and this server cannot tell those apart" - so absence never reads as "you are looking at the whole fleet". With nothing declared the PROSE surfaces are byte-for-byte unchanged (the instructions, the resolution miss, and the empty-registry sentence), with ONE deliberate exception: `list_servers`' JSON envelope carries `this_store_covers` / `peer_fleets` / `peer_note` on every response, declared or not, so a client comparing that tool's exact shape sees three new keys on upgrade. That is the point rather than an oversight - an empty `peer_fleets` means either "only store" or "nobody declared the siblings", and a conditional note would say nothing in exactly the case that produces the wrong conclusion. Lite has no peers concept and gets no twin. Federated cross-store reads stay unbuilt on purpose +### Changed +- **MCP tool results serialize compact instead of pretty-printed** ([#2350]) - the only consumer of a tool result is a language model, and indentation buys a model nothing. One property on the shared `McpHelpers.JsonOptions` in Common, so both SKUs move together, plus the two readers that carry their own options for the `/api/*` twins. Saving is payload-shaped - 23% of the bytes on a 15-field record array, 36% on a narrow one - and the TOKEN saving is smaller than the byte saving, because BPE tokenizers pack runs of spaces efficiently. The config files people hand-edit (`servers.json`, profiles, schedules) keep indenting, and a test pins that boundary in both directions. + ### Fixed - **Extended-length paths no longer slip past the install-location guards** ([#2348]) - `\\?\UNC\server\share` is the long spelling of a REAL share and `\\?\C:\Users\bob` of a REAL profile, but the wholesale `\\?\` exclusion waved both through undiagnosed, because skipping a check is not the same as passing it. Both implementations now strip the prefix BEFORE classifying, so the long spelling gets the same verdict as the short one, and `\\?\C:\PerformanceMonitorDarling` - an ordinary local root written the long way - is still correctly left alone. The shared decision table and the cross-language parity test hold the service and `install-darling.ps1` to it together. - **The store's scale test no longer asserts that TimescaleDB compresses more rows in more time** ([#2266] item 1, measured on a rig) - `ScaleTest_JobDurationGrowsWithVolume_...` required `d10 > d1` between two sub-second job durations, and it has failed on PR after PR whose diffs cannot reach it (`d1=970/d10=863`, then `d1=689/d10=689`). Fifteen consecutive runs of the exact sequence against TimescaleDB 2.29/PG17 settle what no amount of reasoning from CI logs could: the chunks compress perfectly (counts go 1, 2, 3; per-day rows are exactly 2000/50000/500000 every single time), so the earlier suspicion that both runs were compressing nothing is **refuted** - but a 10x volume increase buys only ~3.2x the duration, about **85 ms** of absolute signal, because compression cost is largely fixed per run. CI's baseline for the same pair is 690-970 ms, roughly twenty times that fixed cost, so the volume-dependent component there is ~10% of the measurement's own magnitude and sits inside the run-to-run variance of launching a background worker on Windows. That is a benchmark of somebody else's compression engine on shared hardware, and no threshold, ratio or volume rescues it: at ~0.19 ms per thousand rows it would take millions of rows per chunk to clear a variance nobody has measured on the platform that actually fails. The byte-identical pair was never as improbable as it looked either, because that pair is only ever read when the test FAILS, which selects for differences already near zero. **It is replaced by something strictly stronger, not weaker**: each measured run must have compressed the chunk its own seed created, and that chunk must hold exactly the seeded row count - exact counts instead of two timings. A negative control proves the difference rather than assuming it. Seed the 10x rows into a chunk that is not yet compression-eligible and the old assertion fails and the new ones fail too, naming `compressed=2`; but seed them into the **1x chunk** and the old assertion **passes 3/3 with a 6-8x ratio** while the fixture has quietly stopped producing two chunks at two volumes, and only the new assertions catch it (`rows=[2000,550000]`, `total=2`). So the shipped assertion was not merely flaky, it was blind to the fixture defect it was supposed to be guarding. What the product owns is asserted and unchanged: a real duration is measured, the V56 series records both readings in order, and the real evaluator fires the [#2136] cadence alert from a real reading. One gap closed on the way past - `d10 > 0` was never asserted, and `ReadJobDurationMsAsync` maps a NULL duration to 0, so a 10x run whose duration was unmeasurable satisfied the telemetry check as `0 == 0` and passed. The test is renamed to stop claiming what it no longer measures. @@ -2813,6 +2816,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#2340]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2340 [#2344]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2344 [#2348]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2348 +[#2350]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2350 [#2331]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2331 [#2181]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2181 [#2317]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2317 diff --git a/Darling/Darling.Tests/DarlingAgReaderTests.cs b/Darling/Darling.Tests/DarlingAgReaderTests.cs index 2b11a1df8..9c1fa4b47 100644 --- a/Darling/Darling.Tests/DarlingAgReaderTests.cs +++ b/Darling/Darling.Tests/DarlingAgReaderTests.cs @@ -388,8 +388,8 @@ public void SerializedShape_CarriesTheFieldsBothConsumersRead() /* Severities serialize as NAMES (the JsonStringEnumConverter), not integers — the browser maps the name to a CSS class, so a numeric enum would silently break every color. */ - Assert.Contains("\"severity\": \"Critical\"", json, StringComparison.Ordinal); - Assert.DoesNotContain("\"severity\": 3", json, StringComparison.Ordinal); + JsonAssert.Contains("\"severity\": \"Critical\"", json); + JsonAssert.DoesNotContain("\"severity\": 3", json); } /* ─────────────────────────── SQL dialect pins ─────────────────────────── */ diff --git a/Darling/Darling.Tests/DarlingFleetReaderTests.cs b/Darling/Darling.Tests/DarlingFleetReaderTests.cs index a7731f89f..7438fac7d 100644 --- a/Darling/Darling.Tests/DarlingFleetReaderTests.cs +++ b/Darling/Darling.Tests/DarlingFleetReaderTests.cs @@ -236,13 +236,13 @@ public void FleetServerCard_SerializesSnakeCase_WithStringBands() } /* Bands / severities serialize as strings, not ordinals — the frontend maps a name to a color. */ - Assert.Contains("\"band\": \"Critical\"", json, StringComparison.Ordinal); - Assert.Contains("\"cpu_severity\": \"Critical\"", json, StringComparison.Ordinal); - Assert.Contains("\"threads_severity\": \"Unknown\"", json, StringComparison.Ordinal); + JsonAssert.Contains("\"band\": \"Critical\"", json); + JsonAssert.Contains("\"cpu_severity\": \"Critical\"", json); + JsonAssert.Contains("\"threads_severity\": \"Unknown\"", json); /* naive-UTC instants carry no zone suffix (localized in the browser). */ - Assert.Contains("\"last_collection\": \"2026-07-18T03:30:00\"", json, StringComparison.Ordinal); - Assert.Contains("\"deadlock_last_seen\": \"2026-07-18T03:15:00\"", json, StringComparison.Ordinal); - Assert.DoesNotContain("\"cpu_severity\": 3", json, StringComparison.Ordinal); + JsonAssert.Contains("\"last_collection\": \"2026-07-18T03:30:00\"", json); + JsonAssert.Contains("\"deadlock_last_seen\": \"2026-07-18T03:15:00\"", json); + JsonAssert.DoesNotContain("\"cpu_severity\": 3", json); } [Fact] @@ -252,17 +252,17 @@ public void FleetServerCard_SerializesPerServerPlatform_ForComposerD4Greying() measure's appliesTo.azureSqlDb to auto-grey a measure that platform can't collect. */ var azure = new FleetServerCard { ServerId = 1, DisplayName = "az-db", ServerName = "az-db", EngineEdition = 5, IsAzureSqlDb = true }; var azureJson = JsonSerializer.Serialize(azure, DarlingFleetReader.JsonOptions); - Assert.Contains("\"engine_edition\": 5", azureJson, StringComparison.Ordinal); - Assert.Contains("\"is_azure_sql_db\": true", azureJson, StringComparison.Ordinal); - Assert.Contains("\"is_azure_mi\": false", azureJson, StringComparison.Ordinal); + JsonAssert.Contains("\"engine_edition\": 5", azureJson); + JsonAssert.Contains("\"is_azure_sql_db\": true", azureJson); + JsonAssert.Contains("\"is_azure_mi\": false", azureJson); /* A server that has not connected: null edition serializes as JSON null and both flags are false, so the frontend has no signal and keeps the measure badge rather than greying on a guess. */ var unknown = new FleetServerCard { ServerId = 2, DisplayName = "new", ServerName = "new" }; var unknownJson = JsonSerializer.Serialize(unknown, DarlingFleetReader.JsonOptions); - Assert.Contains("\"engine_edition\": null", unknownJson, StringComparison.Ordinal); - Assert.Contains("\"is_azure_sql_db\": false", unknownJson, StringComparison.Ordinal); - Assert.Contains("\"is_azure_mi\": false", unknownJson, StringComparison.Ordinal); + JsonAssert.Contains("\"engine_edition\": null", unknownJson); + JsonAssert.Contains("\"is_azure_sql_db\": false", unknownJson); + JsonAssert.Contains("\"is_azure_mi\": false", unknownJson); } [Fact] @@ -306,7 +306,7 @@ public void FleetOverviewResult_SerializesRollupShape() Assert.Contains(field, json, StringComparison.Ordinal); } - Assert.Contains("\"band_label\": \"Critical\"", json, StringComparison.Ordinal); + JsonAssert.Contains("\"band_label\": \"Critical\"", json); } } diff --git a/Darling/Darling.Tests/DarlingMcpConfigHistoryToolsTests.cs b/Darling/Darling.Tests/DarlingMcpConfigHistoryToolsTests.cs index 63ec2159a..d7011b338 100644 --- a/Darling/Darling.Tests/DarlingMcpConfigHistoryToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpConfigHistoryToolsTests.cs @@ -395,9 +395,9 @@ await DarlingMcpTestData.ExecAsync(connection, ct, var qsh = await DarlingMcpConfigHistoryTools.GetQueryStoreHealth(postgres, ServerName); DarlingMcpTestData.AssertEnvelope(qsh, ServerName, "databases"); - Assert.Contains("\"state_matches_desired\": false", qsh, StringComparison.Ordinal); + JsonAssert.Contains("\"state_matches_desired\": false", qsh); Assert.Contains("storage cap reached", qsh, StringComparison.Ordinal); - Assert.Contains("\"pct_of_cap\": 100", qsh, StringComparison.Ordinal); + JsonAssert.Contains("\"pct_of_cap\": 100", qsh); bodySucceeded = true; } diff --git a/Darling/Darling.Tests/DarlingMcpDefaultTraceToolsTests.cs b/Darling/Darling.Tests/DarlingMcpDefaultTraceToolsTests.cs index b7be825d8..314b015aa 100644 --- a/Darling/Darling.Tests/DarlingMcpDefaultTraceToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpDefaultTraceToolsTests.cs @@ -210,7 +210,7 @@ await DarlingMcpTestData.ExecAsync(connection, ct, Assert.Contains("AutoGrowShrink", json, StringComparison.Ordinal); Assert.Contains("SEVERE_MARKER", json, StringComparison.Ordinal); Assert.DoesNotContain("ROUTINE_MARKER", json, StringComparison.Ordinal); - Assert.Contains("\"total_events\": 2", json, StringComparison.Ordinal); + JsonAssert.Contains("\"total_events\": 2", json); /* Unknown server → the listing error; empty store → the miss. */ Assert.StartsWith("Could not resolve server.", await DarlingMcpDefaultTraceTools.GetDefaultTraceEvents(postgres, "darling-no-such-server"), StringComparison.Ordinal); diff --git a/Darling/Darling.Tests/JsonAssert.cs b/Darling/Darling.Tests/JsonAssert.cs new file mode 100644 index 000000000..77edb8fd9 --- /dev/null +++ b/Darling/Darling.Tests/JsonAssert.cs @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Text; +using Xunit; + +namespace Darling.Tests; + +/// +/// Substring assertions over serialized JSON that ignore LAYOUT (#2350). +/// +/// A test written as Assert.Contains("\"severity\": \"Critical\"", json) reads as a claim about +/// content — this field serialized with this value — but is actually a claim about formatting, because the space +/// after the colon exists only under WriteIndented. When MCP tool results went compact, eighteen such +/// assertions failed across four files without a single one of the things they were testing having changed. +/// +/// These helpers normalize both sides by dropping whitespace that sits BETWEEN tokens while preserving +/// whitespace INSIDE strings, so "a": "b c" and "a":"b c" compare equal and the two-space value in +/// "b c" survives. The assertion then means what it always looked like it meant. +/// +/// Deliberately not a full JSON parse: these are substring assertions on purpose — they check a field +/// serialized a particular way (an enum as its string name rather than its ordinal, a null that stayed null) +/// without pinning the shape of the whole envelope around it. +/// +internal static class JsonAssert +{ + /// xUnit's argument order (expected first) so call sites read the same as the assertion they replace. + internal static void Contains(string expectedFragment, string json) + { + Assert.Contains(StripInsignificantWhitespace(expectedFragment), StripInsignificantWhitespace(json), StringComparison.Ordinal); + } + + /// + internal static void DoesNotContain(string unexpectedFragment, string json) + { + Assert.DoesNotContain(StripInsignificantWhitespace(unexpectedFragment), StripInsignificantWhitespace(json), StringComparison.Ordinal); + } + + /// + /// Removes whitespace outside string literals. Tracks escaping so a \" inside a string does not end it + /// and a \\ before a quote does not escape it — get that wrong and the parser falls out of the string, + /// starts stripping real spaces from values, and the assertion silently starts comparing something else. + /// + internal static string StripInsignificantWhitespace(string json) + { + if (string.IsNullOrEmpty(json)) + { + return json ?? string.Empty; + } + + var builder = new StringBuilder(json.Length); + var inString = false; + var escaped = false; + + foreach (var c in json) + { + if (inString) + { + builder.Append(c); + + if (escaped) + { + escaped = false; + } + else if (c == '\\') + { + escaped = true; + } + else if (c == '"') + { + inString = false; + } + + continue; + } + + if (c == '"') + { + inString = true; + builder.Append(c); + continue; + } + + if (c is ' ' or '\t' or '\r' or '\n') + { + continue; + } + + builder.Append(c); + } + + return builder.ToString(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAgReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAgReader.cs index db5d483a0..b34ac08c6 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAgReader.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAgReader.cs @@ -57,12 +57,12 @@ namespace PerformanceMonitor.Darling.Service.Mcp; internal static class DarlingAgReader { /// Shared serializer options — snake_case field names come from the DTOs' [JsonPropertyName] - /// attributes, severities serialize as their string names, and the output is indented (the MCP tool - /// convention). ONE options object so /api/ag and get_ag_health serialize the identical - /// shape. + /// attributes, severities serialize as their string names, and the output is COMPACT (#2350 - the MCP tool + /// convention, since the reader on both ends is a parser rather than a person). ONE options object so + /// /api/ag and get_ag_health serialize the identical shape. public static readonly JsonSerializerOptions JsonOptions = new() { - WriteIndented = true, + WriteIndented = false, Converters = { new JsonStringEnumConverter() }, }; diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs index 804421a86..85ddfe5b3 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs @@ -43,11 +43,11 @@ internal static class DarlingFleetReader { /// Shared serializer options for the fleet DTOs — snake_case field names come from the DTOs' /// [JsonPropertyName] attributes, enum bands serialize as their string names, and the output is - /// indented (matching the MCP tool convention). ONE options object so the web endpoint and the MCP tool - /// serialize the identical shape. + /// COMPACT (#2350, matching the MCP tool convention). ONE options object so the web endpoint and the MCP + /// tool serialize the identical shape. public static readonly JsonSerializerOptions JsonOptions = new() { - WriteIndented = true, + WriteIndented = false, Converters = { new JsonStringEnumConverter() }, }; diff --git a/Lite.Tests/McpOutputCompactionTests.cs b/Lite.Tests/McpOutputCompactionTests.cs new file mode 100644 index 000000000..4b333f421 --- /dev/null +++ b/Lite.Tests/McpOutputCompactionTests.cs @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Text.Json; +using PerformanceMonitor.Common; +using Xunit; + +namespace Lite.Tests; + +/// +/// #2350: MCP tool results serialize COMPACT, and the config files people hand-edit do not. +/// +/// Both halves are pinned here because the change is one property on one shared object, and the risk is +/// entirely in scope rather than in mechanism — flipping WriteIndented on something that turns out to +/// write a config file would make servers.json a single unreadable line, which is the kind of damage +/// nobody notices until they open the file by hand at an awkward moment. +/// +/// The saving is real but should not be oversold: measured on a 15-field blocking-event shape it is +/// ~23% of the BYTES, and the token saving is smaller than that because BPE tokenizers pack runs of spaces +/// efficiently. It costs nothing, which is the argument — not the headline percentage. +/// +public class McpOutputCompactionTests +{ + private sealed record Row(int BlockedSessionId, string WaitType, int WaitMs); + + private static string SampleToolResult(int rows) => + JsonSerializer.Serialize( + new + { + server = "SQLPROD01", + total_events = rows, + events = Enumerable.Range(0, rows).Select(i => new Row(60 + i, "LCK_M_X", 1000 + i)).ToList(), + }, + McpHelpers.JsonOptions); + + /// The property itself, so a well-meaning "make the output readable" edit has to argue with a test. + [Fact] + public void McpJsonOptions_AreCompact() + { + Assert.False(McpHelpers.JsonOptions.WriteIndented); + } + + /// + /// The observable consequence, not just the flag: a record array serializes with no newline and no run of + /// indent spaces anywhere in it. + /// + [Fact] + public void AToolResult_CarriesNoLayoutWhitespace() + { + var json = SampleToolResult(30); + + Assert.DoesNotContain('\n', json); + Assert.DoesNotContain('\r', json); + Assert.DoesNotContain(" ", json, StringComparison.Ordinal); + } + + /// + /// Compaction must not change the DATA — the whole case for it is that the only consumer is a parser, so + /// the parsed value has to be identical to what the indented form produced. + /// + [Fact] + public void Compaction_ChangesLayoutOnly_NotContent() + { + var compact = SampleToolResult(10); + var indented = JsonSerializer.Serialize( + JsonSerializer.Deserialize(compact), + new JsonSerializerOptions { WriteIndented = true }); + + using var a = JsonDocument.Parse(compact); + using var b = JsonDocument.Parse(indented); + + Assert.Equal( + a.RootElement.GetProperty("events").GetArrayLength(), + b.RootElement.GetProperty("events").GetArrayLength()); + Assert.Equal( + a.RootElement.GetProperty("server").GetString(), + b.RootElement.GetProperty("server").GetString()); + + /* And it is genuinely smaller, which is the only reason to do it at all. */ + Assert.True(compact.Length < indented.Length, "compact output must be smaller than indented output"); + } + + /// + /// The boundary, pinned structurally the way this repo pins every invariant it cannot compile: the + /// managers that persist files a human opens keep indenting. Their options are private statics, so the + /// source is the assertable surface — the same idiom GridPayloadColumnOrderPinTests uses. + /// + [Theory] + [InlineData("Lite/Services/ServerManager.cs")] + [InlineData("Lite/Services/ProfileManager.cs")] + [InlineData("Lite/Services/ScheduleManager.cs")] + public void ConfigFileWriters_StayIndented(string relativePath) + { + var source = ParitySource.ReadFile(relativePath); + + Assert.Contains("WriteIndented = true", source, StringComparison.Ordinal); + } +} diff --git a/PerformanceMonitor.Common/Mcp/McpHelpers.cs b/PerformanceMonitor.Common/Mcp/McpHelpers.cs index bee0c8070..903032f82 100644 --- a/PerformanceMonitor.Common/Mcp/McpHelpers.cs +++ b/PerformanceMonitor.Common/Mcp/McpHelpers.cs @@ -27,9 +27,26 @@ internal static class McpHelpers public const int MaxTop = 1000; /// - /// Shared JSON serializer options with indented formatting. + /// Shared JSON serializer options for MCP tool results — compact, not indented (#2350). + /// + /// The only consumer of an MCP tool result is a language model, and indentation buys a model + /// nothing. It was costing roughly 23% of the bytes of a record-heavy result (measured on a 15-field + /// blocking-event shape: 2,977 → 2,297 at 10 rows, 29,082 → 22,462 at 100). The token saving is smaller + /// than the byte saving — BPE tokenizers pack runs of spaces efficiently — so this is not the 23% + /// win it looks like in bytes. It is still free, and it compounds where it matters: tool results are the + /// bulk of what fills an agent's context on a real incident, and the fleet-wide reads are the widest + /// results we return. + /// + /// Deliberately NOT applied to the config files (servers.json, profiles, schedules, alert state). + /// Those are read and hand-edited by people, and ServerManager/ProfileManager/ + /// ScheduleManager keep their own indented options for that reason. This object is MCP output only + /// — every one of its ~78 call sites serializes a tool result or the web endpoint twin of one. + /// + /// Nothing parses our output positionally: it is JSON to a JSON reader on both sides, and the tests + /// that touch this object assert field NAMES (there is no naming policy here, so snake_case comes from + /// [JsonPropertyName] attributes) rather than layout. /// - public static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + public static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false }; /// /// Truncates a string to the specified maximum length, adding a truncation suffix.