Make Darling's MCP alert payload and its writer agree (#2417) - #2421
Conversation
get_alert_settings and update_alert_settings are the read and write halves of one file, and they had drifted apart in both directions at once. Six columns AlertSettingsReadRow reads were emitted to nobody. The reader was widened for them deliberately -- its own comment says so, "an MCP client could not see the V33 connection opt-ins or the V35 Availability Group family at all" -- but the payload never followed, so every get_alert_settings call pulled notify_ag_health, ag_lag_alert_seconds, ag_redo_queue_alert_kb, ag_disconnect_refire_minutes, notify_connection_down_at_startup and connection_refire_minutes out of the store and dropped them on the floor. The AG four are the ones that cost something: those are the settings whose misconfiguration is least visible anywhere else, so an agent asked why nothing alerted when a replica fell behind could not see the lag threshold, could not see whether AG notification was on at all, and had no way to tell "configured not to alert" apart from "failed to alert". In the other direction the payload emitted blocking.wait_threshold_seconds, which the blocking arm of the update switch did not accept. So handing a whole read payload back to the writer -- the round trip the tool's own description tells the caller to perform, read the settings, change one number, write them back -- was rejected with "Unknown field", naming a field the caller never chose to send. All six are now WRITABLE, not merely readable. Every column on this row is a knob the Viewer's Settings window already writes, so a read-only knob just moves an operator to a hand-typed UPDATE; and read-only would have needed an exemption entry in the new test, which is the same hand-maintained fact that let the six go missing. Every bound mirrors DarlingAlertSettings' clamp exactly -- Clamp(0,1440) on both re-fires, Clamp(0,86400) on the lag, Clamp(0L,1073741824L) on the redo queue, Math.Max(0,...) on the blocking wait -- because a bound that differs lets the tool accept a value the engine silently rewrites on read, which presents as the setting not sticking, and nothing says no. Zero stays in range everywhere: on all four numerics zero is the shipped configuration meaning that gate is off, and the redo queue ships at zero precisely because a healthy queue size is workload-specific. The redo queue is bound through a new AddLong rather than AddInt because it is the one bigint on the row and its ceiling only happens to fit in an int today. The wire keys are the house's, not transliterations of the column names. The AG family is a group like every other alert family, with its master switch named ag.enabled for the same reason cpu/blocking/pvs/database_state spell theirs that way -- inside this payload "enabled" always means this alert family is on, and notify_ag_health IS that switch rather than a second opt-in behind one. Its two thresholds take the <what>_threshold_<unit> spelling the neighbours already use (blocking.wait_threshold_seconds, poison_wait.threshold_ms, low_disk.threshold_gb) instead of the columns' older _alert_<unit> suffix, which appears nowhere on the wire. The two connection settings stay top-level beside notify_connection_changes rather than becoming a connection group: the master shipped as a top-level key and Lite emits it there too, so a group could only ever have held two of the three, and splitting one family across two levels of the document is worse for a reader than two extra top-level keys. Their spellings are the store's column names, which are also the keys Lite already reads out of settings.json, so Lite's payload can adopt them verbatim when it follows. The part worth keeping is the test. The existing ones pin individual keys, which is exactly why six missing keys and one unroundtrippable key both survived, so the new one is derived instead: it builds a settings row, runs it through the real BuildAlertSettingsPayload, serializes it through the same options the tool serializes with, and feeds the result to the real BuildAlertSettingsUpdate -- then asserts the set of columns that comes out equals the set of columns AlertSettingsSelectSql reads. One equality closes all three directions. A column read but not emitted is missing from the write set; a key emitted but refused shows up as a parse error naming itself; and a key emitted for a column nobody reads fails the direction no hand-maintained list would ever have noticed. There are no exemptions, deliberately -- if a genuinely read-only column arrives it gets exempted by name with its reason, rather than the equality being loosened. Darling.Tests targets net10.0-windows and cannot run on macOS, so the invariant was executed against the real compiled assemblies through a throwaway net10.0 harness. It passes on this branch and fails on dev twice over: with dev's file it stops at "Unknown field 'blocking.wait_threshold_seconds'", and with dev's file plus only the blocking arm added it names all six missing columns and nothing else. Common gains an InternalsVisibleTo for Darling.Tests so the test serializes through McpHelpers.JsonOptions itself rather than a look-alike; the two sibling test projects were already on that list. Nothing about the SELECT or the by-ordinal read changed -- every column was already being read, which is what made this a payload defect rather than a schema one, so AlertSettingsSelect_ColumnCount_MatchesTheOrdinalsRead is untouched and still holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reviewed the diff against Ordinal safety — walked all 58 columns in Clamp parity — cross-checked every new/newly-writable bound against
Injection/security — column names in the Lite/Darling parity — Lite's Test approach — the new derived round-trip test (build row → real No correctness, security, or parity issues found. One non-blocking observation: |
CHANGELOG: deliberately not in this PR, and why
It belongs under - **Darling's MCP alert payload and its writer agree again** ([#2417]) - `AlertSettingsReadRow` reads six columns `get_alert_settings` emitted to nobody (`notify_ag_health`, `ag_lag_alert_seconds`, `ag_redo_queue_alert_kb`, `ag_disconnect_refire_minutes`, `notify_connection_down_at_startup`, `connection_refire_minutes`), so they were pulled out of the store on every call and dropped on the floor - the reader had been widened for them deliberately and the payload never followed. The Availability Group four are the ones that cost something, because they are the settings whose misconfiguration is least visible anywhere else: an agent asked why nothing alerted when a replica fell behind could not see the lag threshold, could not see whether AG notification was on at all, and had no way to tell "configured not to alert" apart from "failed to alert". In the other direction `get_alert_settings` emitted `blocking.wait_threshold_seconds` and `update_alert_settings` rejected it, so handing a whole read payload back to the writer - the round trip the tool's own description tells the caller to perform - failed with `Unknown field`, naming a field the caller never chose to send. All seven are now readable AND writable: the AG family as an `ag` group whose master switch is `enabled` like every other family's and whose thresholds take the house `<what>_threshold_<unit>` spelling rather than the columns' older `_alert_<unit>` suffix, the two connection knobs top-level beside `notify_connection_changes` where their master switch already lives. Write bounds match `DarlingAlertSettings`' clamps exactly on the #2391 precedent, with zero staying valid on every numeric because zero is the shipped "this gate is off" value on all four. The guard is DERIVED rather than hand-pinned - the payload is run through the real writer and the resulting column set asserted EQUAL to the columns the reader SELECTs - which catches a column read but not emitted, a key emitted but not accepted, and a key emitted for a column nobody reads, none of which the previous per-key pins could do. Lite has the same six settings in `App` and no MCP surface for them either; that follows separately, since Darling had to move first.Plus the link-reference line at the foot of the file, in the block #2395 already adds to: [#2417]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2417 |
#2410 landed on dev between this branch's base and its first CI run, and it changed the rules: GetAlertSettings_ReportsEveryGroupDarlingDoes_SpelledDarlingsWay parses Darling's BuildAlertSettingsPayload and requires Lite's payload to carry every group and key it finds. So Darling's half of #2417 could not merge alone after all -- the Lite build went red naming exactly the three additions, missing group 'notify_connection_down_at_startup', 'connection_refire_minutes' and 'ag'. That test is doing precisely what it was written to do, and the answer is to make Lite follow now rather than to weaken it. Lite needs no new plumbing for six of the seven. App.NotifyConnectionDownAtStartup and App.ConnectionRefireMinutes have driven the connection-alert path since #1659, and App.NotifyAgHealth, App.AgLagAlertSeconds and App.AgRedoQueueAlertKb have driven all four AG conditions since #1726. Only the MCP surface was narrow, which is the same shape of gap #2394 fixed for the other nine groups. The seventh is a real asymmetry rather than an oversight, so it is exempted by name instead of faked. Darling's ag_disconnect_refire_minutes is #1696 / store V37, and Lite has no AG disconnect re-fire at ALL -- no static, no settings.json key, and no edge state that could re-announce a still-disconnected replica, because #1726 mirrored three of those four knobs. Emitting it as a constant 0 would tell an agent it can tune something Lite cannot, which is the reasoning the file already applies to self_alerts, here at member granularity instead of group. LiteOmittedMembers holds the one entry, the comparison skips exactly that key, and a new test asserts BOTH halves still hold -- that Darling still emits it, so the exemption cannot become dead weight hiding a Darling regression, and that Lite still does not, so it cannot mask a key that has since arrived. The set is pinned exactly, so a second exemption has to be justified in that test the way a second Lite-only group has to be justified in the smtp assertion. Lite.Tests cannot run on macOS, so the parity test's parser was ported to Python and validated against the test's OWN self-checks before being trusted -- the 15-40 group count, cpu/analysis/self_alerts present, smtp absent, and cpu's members exactly enabled/threshold_percent/mode. Run against dev's Lite payload it reproduces CI's failure message byte for byte; against this branch's it passes; and with the exemption removed it fails on ag.disconnect_refire_minutes and nothing else, which is what shows the exemption is both load-bearing and minimal. Darling's own round-trip invariant re-ran green after the merge. The gap itself is worth closing and is not closed here: a replica disconnected for a week is announced once on Lite where Darling can re-announce it. That is #1696's Lite half, a feature with its own static, settings row, box and edge state, and it does not belong in a payload-symmetry change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The omission is only honest if the reader can find out what is being omitted and whether anyone intends to fix it, so the exemption in Lite's payload and in the parity test both name #2426 rather than describing the gap and leaving it nowhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reviewed. This is a tightly-scoped, well-verified fix — I traced the full round trip (reader SELECT →
Didn't find correctness, security, or parity issues to flag. The derived round-trip test ( |
Closes #2417.
get_alert_settingsandupdate_alert_settingsare the read and write halves of one file, and they had drifted apart in both directions at once. Six columnsAlertSettingsReadRowreads were emitted to nobody, and one key the payload did emit was refused by the writer.The six are the V33 connection opt-ins and the V35/V37 Availability Group family. The reader was widened for them on purpose — its own comment says an MCP client could not otherwise see them at all — but the payload never followed, so every call pulled them out of the store and dropped them on the floor. The AG four are the ones that cost something, because those are the settings whose misconfiguration is least visible anywhere else: an agent asked why nothing alerted when a replica fell behind could not see the lag threshold, could not see whether AG notification was on at all, and had no way to tell "configured not to alert" apart from "failed to alert".
In the other direction the payload emitted
blocking.wait_threshold_secondsand theblockingarm of the update switch did not take it, so handing a whole read payload back to the writer — the round trip the tool's own description tells the caller to perform — was rejected withUnknown field, naming a field the caller never chose to send.What the fix decides
All six are writable, not merely readable. Every column on this row is a knob the Viewer's Settings window already writes, so a read-only knob just moves an operator to a hand-typed
UPDATE— and read-only would have needed an exemption entry in the new test, which is the same hand-maintained fact that let the six go missing. Every bound mirrorsDarlingAlertSettings' clamp exactly:Clamp(0, 1440)on both re-fires,Clamp(0, 86400)on the lag,Clamp(0L, 1073741824L)on the redo queue,Math.Max(0, ...)on the blocking wait. A bound that differs would let the tool accept a value the engine silently rewrites on read, which presents as the setting not sticking and nothing saying no. Zero stays in range on all four numerics — it is the shipped configuration meaning that gate is off, and the redo queue ships at zero precisely because a healthy queue size is workload-specific. The redo queue binds through a newAddLongrather thanAddIntbecause it is the onebiginton the row and its ceiling only happens to fit in an int today.The wire keys are the house's, not transliterations of the columns. AG becomes a group like every other alert family, with its master switch named
ag.enabledfor the same reasoncpu/blocking/pvs/database_statespell theirs that way — inside this payloadenabledalways means "this alert family is on", andnotify_ag_healthIS that switch rather than a second opt-in behind one. Its thresholds take the<what>_threshold_<unit>spelling the neighbours already use (blocking.wait_threshold_seconds,poison_wait.threshold_ms,low_disk.threshold_gb) instead of the columns' older_alert_<unit>suffix, which appears nowhere on the wire.The two connection settings stay top-level beside
notify_connection_changesrather than becoming aconnectiongroup. The family's master switch shipped as a top-level key and Lite emits it there too, so a group could only ever have held two of the three — splitting one family across two levels of the document is worse for a reader than two extra top-level keys. Their spellings are the store's column names, which are also the keys Lite already reads out ofsettings.json, so Lite's payload can adopt them verbatim when it follows.The part worth keeping
The existing tests pin individual keys, which is exactly why six missing keys and one unroundtrippable key both survived. The new one is derived: it builds a settings row, runs it through the real
BuildAlertSettingsPayload, serializes it through the same options the tool serializes with, and feeds the result to the realBuildAlertSettingsUpdate— then asserts the set of columns that comes out equals the set of columnsAlertSettingsSelectSqlreads.One equality closes all three directions. A column read but not emitted is missing from the write set. A key emitted but refused shows up as a parse error naming itself. And a key emitted for a column nobody reads fails the direction no hand-maintained list would ever have noticed. There are no exemptions, deliberately — if a genuinely read-only column ever arrives it gets exempted by name with its reason, rather than the equality being loosened.
A second test holds the clamp parity for everything this PR made writable, in the shape
FileGrowthWriteBounds_MatchTheEngineClampsalready established, including asserting that the two booleans reach the engine unclamped so the day a range is added to one of them is loud.Verification
Darling.Teststargetsnet10.0-windowsand cannot run on macOS, so the invariant was executed against the real compiled assemblies through a throwawaynet10.0harness (assembly namedDarling.Testsso theInternalsVisibleTogrants apply) running the new test's exact logic. Red/green:devas-isUnknown field 'blocking.wait_threshold_seconds'dev+ only the blocking arm addedag_disconnect_refire_minutes, ag_lag_alert_seconds, ag_redo_queue_alert_kb, connection_refire_minutes, notify_ag_health, notify_connection_down_at_startupThat third row is the one that matters — it shows the invariant catches each defect independently rather than one masking the other.
Every clamp literal the parity test asserts was grepped out of
DarlingAlertSettings.csandDarlingMcpAlertTools.csbefore being written down. Service, Viewer,Darling.TestsandLite.Testsall build clean.Nothing about the SELECT or the by-ordinal read changed. Every column was already being read, which is what made this a payload defect rather than a schema one, so
AlertSettingsSelect_ColumnCount_MatchesTheOrdinalsReadis untouched and still holds.PerformanceMonitor.Commongains anInternalsVisibleToforDarling.Testsso the new test serializes throughMcpHelpers.JsonOptionsitself rather than a look-alike — a naming policy added there can never leave the invariant asserting keys no client receives.Lite.TestsandDashboard.Testswere already on that list.Lite followed in the same PR, because CI now requires it
The first CI run went red on
Lite.Tests, and correctly. #2410 landed ondevbetween this branch's base and that run:GetAlertSettings_ReportsEveryGroupDarlingDoes_SpelledDarlingsWayparses Darling'sBuildAlertSettingsPayloadand requires Lite's payload to carry every group and key it finds. So the "Darling moves first, Lite follows separately" plan the issue describes is no longer available — the test named exactly the three additions (missing group 'notify_connection_down_at_startup'; missing group 'connection_refire_minutes'; missing group 'ag'). That test is doing what it was written to do, so Lite follows here rather than the test being weakened.Six of the seven needed no new plumbing.
App.NotifyConnectionDownAtStartup/App.ConnectionRefireMinuteshave driven the connection-alert path since #1659, andApp.NotifyAgHealth/App.AgLagAlertSeconds/App.AgRedoQueueAlertKbhave driven all four AG conditions since #1726. Only the MCP surface was narrow — the same gap #2394 closed for the other nine groups.The seventh is a real asymmetry, exempted by name rather than faked. Darling's
ag_disconnect_refire_minutesis #1696 / store V37, and Lite has no AG disconnect re-fire at all — no static, nosettings.jsonkey, no edge state that could re-announce. Emitting a constant0would tell an agent it can tune something Lite cannot, which is the reasoning this file already applies toself_alerts, here at member granularity instead of group.LiteOmittedMembersholds the one entry, and a new test asserts both halves still hold — Darling still emits it (so the exemption cannot become dead weight hiding a Darling regression) and Lite still does not (so it cannot mask a key that has since arrived). The set is pinned exactly, so a second exemption has to be justified in that test the way a second Lite-only group has to be justified in thesmtpassertion.Lite.Testscannot run on macOS either, so the parity test's parser was ported to Python and validated against the test's own self-checks before being trusted — the 15–40 group count,cpu/analysis/self_alertspresent,smtpabsent, andcpu's members exactlyenabled/threshold_percent/mode. Then:dev's Lite payloadag.disconnect_refire_minutesand nothing elseThat last row is what shows the exemption is both load-bearing and minimal. Darling's own round-trip invariant re-ran green after the merge.
Deferred
#2426 — Lite has no AG disconnect re-fire, so a replica disconnected for a week is announced once where Darling can re-announce it. That is #1696's Lite half: a static, a clamp on load and save, a Settings box, and real edge state. It is a feature, not a payload key, and does not belong in a payload-symmetry change. Filed with the mechanism, the four steps to close it, and the note that
ConnectionRefireMinutesand the AG re-fire are the same #1674 shape and may want one shared helper.The CHANGELOG entry is also deliberately not here — see the comment below.
[3.5.1]is being cut in #2395, so adding the section here would put this PR in conflict with a release PR over a file it otherwise does not touch. The bullet is written out ready to paste, and the safe order is #2395 first.🤖 Generated with Claude Code