Repository navigation
Darling MCP: mute-rule and route toggles return the previous value and an undo call (part of #6403) - #6514
Conversation
Release 3.11.0
…ious value and an undo call (#6403)
…gs, query tag rules, deadlock merge, and Lite query tags return previous and an undo call (#6403)
…errides and analysis mutes return previous and an undo call (#6403)
…the previous/undo sentence goes in the guide (#6403)
…to worktree-agent-a7f75a80a05e78dc7
… analysis mutes return previous and an undo call; update_alert_settings reads previous under the row lock; Level 2 mute rule answers proved (#6403)
| "one sweep and drops this install's own Extended Events sessions on the server, leaving the shared ones and " + | ||
| "any session another registration of this install keeps. Already-collected historical data is NOT deleted. Returns {status:\"removed\", server, " + | ||
| "display_name, matched_by:\"exact\"|\"partial\", matched_in:\"config_monitored_servers\", ever_connected} on " + | ||
| "display_name, matched_by:\"exact\"|\"partial\", matched_in:\"config_monitored_servers\", ever_connected, previous (the removed definition, no password, no AWS external ID), undo (the add_servers call that re-adds it, with a password placeholder), undo_note (what a re-add does not bring back)} on " + |
There was a problem hiding this comment.
previous does carry a password key, so "no password" is wrong here (and in the PR body).
previous and the undo's one entry are the same projection — RemovedPreviousOf(row) is called twice (lines 868 and 869–872) — and that projection sets entry["password"] = PasswordPlaceholder for a secret-auth row (line 775). The new test pins it: ServerEditLiveTests.Undo.cs asserts Assert.Equal(Edit.PasswordPlaceholder, (string)previous["password"]!).
No secret leaks (the stored blob is unreadable and Assert.DoesNotContain("blob-04", …) proves it), but an agent reading this description does not expect a password key under previous at all, and undo_note explains the placeholder only as something in the undo. Either say so here, or drop password from previous and keep it in the undo's servers_json alone.
| "display_name, matched_by:\"exact\"|\"partial\", matched_in:\"config_monitored_servers\", ever_connected, previous (the removed definition, no password, no AWS external ID), undo (the add_servers call that re-adds it, with a password placeholder), undo_note (what a re-add does not bring back)} on " + | |
| "display_name, matched_by:\"exact\"|\"partial\", matched_in:\"config_monitored_servers\", ever_connected, previous (the removed definition in the keys add_servers takes; the stored password cannot be read back, so a SQL or service-principal row carries the placeholder <password> in its place, and the AWS external ID is left out), undo (the add_servers call that re-adds it, with that same password placeholder), undo_note (what a re-add does not bring back)} on " + |
| @@ -0,0 +1,13 @@ | |||
| using System.Text.Json.Nodes; | |||
There was a problem hiding this comment.
New file with no copyright header. CONTRIBUTING.md:450 has it on the PR checklist ("New files have copyright headers"), and the four new test files in this PR all carry the block. 189 of the 193 files in this folder have it; this is one of the four that do not.
| using System.Text.Json.Nodes; | |
| /* | |
| * 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.Text.Json.Nodes; |
|
|
||
| if (expiresAtUtc is not null) | ||
| { | ||
| arguments["expires_at_utc"] = CustomAlertExpiry.Format(before.ExpiresAtUtc) ?? string.Empty; |
There was a problem hiding this comment.
Non-blocking, and the body already names it ("Known limit: undoing an update that cleared an expiry which had already passed is refused by the tool"). Two things the limit's phrasing understates, worth one line of code or one line of note:
-
The refusal is all-or-nothing.
ResolveUpdateExpiryrefuses any past value that is not equal to the rule's current expiry (line 905), and the tool returns{status:"invalid"}with nothing written — so the undo of a revive losesname,descriptionandenabledtoo, not just the expiry.CustomAlertRuleExpiryLiveTestsproves the refusal four lines above the assertion it adds:movedBackwithUtcNow.AddHours(-2)→"invalid", thenrevived["undo"]carries exactly such an instant. Nothing replays that undo, so it stays green. -
DeleteAnsweralready solved this shape (lines 2040–2049): a past expiry is left out of the create arguments andundo_notesays so. The same two lines here would makeundoa call that works:
| arguments["expires_at_utc"] = CustomAlertExpiry.Format(before.ExpiresAtUtc) ?? string.Empty; | |
| arguments["expires_at_utc"] = before.ExpiresAtUtc is { } was && was <= DateTime.UtcNow | |
| ? string.Empty | |
| : CustomAlertExpiry.Format(before.ExpiresAtUtc) ?? string.Empty; |
(with a sentence in undo_note saying the expiry had already passed, so the undo leaves the rule with none — otherwise the undo silently differs from previous.)
| changes[name] = value?.DeepClone(); | ||
| } | ||
|
|
||
| changes["expected_modified_at"] = newModifiedAt; |
There was a problem hiding this comment.
Non-blocking — the same "the undo is a call the tool refuses" shape as the custom-alert-rule expiry, but two cases here that the body does not list, both legacy-row only:
port.PlanEditrefusesc.Port is > 0on a SQL Server row (line 1100, A SQL Server entry's port is ignored without a warning, and a failed connect reports that port #6583). A row saved before that rule with a stored port, edited toport: 0to clear it, putsportinChangedFields; the undo then sends the old non-zero port and is refused with nothing written.host.ServerHostSetting.Refusalis applied to a changed host (line 1087, Server host field: refuse named-pipe and admin protocol prefixes and UNC paths #6352) but deliberately not to a host sent unchanged. A row saved before that rule on a named-pipe/admin-style host, edited onto a normal host, gets an undo carrying the old illegal host — also refused.
In both the undo is refused cleanly (invalid, nothing written), so no data is at risk; the cost is that undo reads as a call that works and undo_note is silent. EditServer_AnswersWithPreviousAndAnUndo_… replays the undo but only over display_name/monthly_cost_usd/host/trust_server_certificate on a legal host, so neither case is covered. A sentence in undo_note when plan.ChangedFields holds port or host and the previous value would not pass the add rule would close it.
| /// <c>set_notification_route_enabled</c> returns it, so the two cannot drift apart again. Field names mirror Lite's | ||
| /// payload (<c>McpAlertTools.RoutePayload</c>); <see cref="NotificationRouteKinds"/> spells <c>match_kind</c> and | ||
| /// <c>family</c> for both products, so a <c>*</c> route reads as <c>wildcard</c> with no family.</summary> | ||
| /// <summary> |
There was a problem hiding this comment.
Non-blocking: the new method landed inside RoutePayload's doc comment. RouteRecreateUndo now carries two <summary> elements — the one directly above this line ("The ONE Darling route projection (#5750): get_notification_routes lists it and set_notification_route_enabled returns it…") documents RoutePayload, which now sits at line 978 with no doc at all. Moving that first block down to line 978 restores both.
Also here, for the record: this undo is {tool, arguments, destinations}, not the {tool, arguments} shape DarlingMcpUndoShape.Call exists to make uniform (the update_notification_route "updated" answer does the same at RouteAdmin.cs:347–354, while its new unchanged answer uses the bare two-key form). Deliberate and documented in the summary — but a client that reads undo by shape now sees three variants across this PR: the {tool, arguments} object, null beside an undo_note, and a bare "None needed: …" string (assign_server_tag, unassign_server_tag, unassign_query_tag, mute_analysis_finding, the edge move). The null + undo_note form already covers "no call undoes this"; the string in the same slot is the odd one out, and answer.undo.tool throws on it.
| /// inside its own write transaction, after it has locked the rows it is about to update, so the <c>previous</c> it reports is | ||
| /// exactly what the write replaced. The single-table reads stay private; this and the entry point above are the only ways in. | ||
| /// </summary> | ||
| public static async Task<AlertConfigurationRead> GetAlertConfigurationAsync( |
There was a problem hiding this comment.
Non-blocking, but worth a sentence in this summary so the next reader does not have to re-derive it: unlike the entry point above, this overload runs on the caller's transaction, which update_alert_settings opens as plain READ COMMITTED — so the two single-table reads are not one snapshot, which is the property TheTwoConfigTables_AreReadUnderOneRepeatableReadSnapshot names. The PR body's "its rule … holds" is true of the rule the test now enforces (every path goes through the combined entry point), not of the snapshot half.
It is still correct, for a reason that is not obvious from here: the lock loop only takes FOR UPDATE on the tables the write targets, and AlertSettingsPreviousFields only ever projects fields drawn from updates. So a field that could be read stale (the other table's, when the write does not touch it) is never one that reaches previous. If a later change ever lets previous carry a field from a table this write did not lock, that stops being true silently.
There was a problem hiding this comment.
Verdict: CHANGES REQUESTED
Claims
- VERIFIED —
edit_server'spreviousholds the changed fields andundocarries the newexpected_modified_at, so a later change makes the replay aconflict—Edit.cs:541,:568-624, key parsed at:968; replayed inServerEditLiveTests.Undo.cs. A password-only edit answersundo: null(:607-610). - VERIFIED —
add_servers: only anaddedrow carries theremove_serverundo —DarlingMcpServerAdminTools.cs:360is the one site passingentry.StorageKey;:1940-1947. The edit core is shared with the web route, which now gets the three keys (DarlingWebEndpoints.cs:2139). - VERIFIED —
update_alert_settings'previousis the sent fields in the nested read shape, the cooldown alias maps todelivery.cooldown_minutes, a file/default route comes backnull—DarlingMcpAlertTools.cs:2141,:2147-2151; everyUpdateTarget.Fieldis the dotted wire path and matchesBuildAlertSettingsPayload's keys. - VERIFIED — the pre-write read now runs in the write transaction, after
FOR UPDATEin the writes' own table order, through a new(connection, transaction)overload whose single-table reads stay private —:1275-1300;DarlingAlertReader.cs:736-743. - VERIFIED — "nothing can fail that did not before" for that lock under the
mcprole —FOR UPDATEneeds SELECT on the projected column plus UPDATE on one column:DarlingManagedRoles.cs:1665/:1668grant column-level UPDATE onconfig_notification, andidis in its non-secret read carve (:230). - VERIFIED — Lite/Darling parity holds: Lite's write-tool roster is exactly the twelve tools changed here, and Darling's
unmute_analysis_findingalready carried both fields (DarlingMcpTools.cs:1508-1529), so Lite is catching up, not diverging. Lite'sUnmuteStoryAsyncreturns the removed rows and its one caller is the tool (FindingStore.cs:673,McpAnalysisTools.cs:1173). - VERIFIED —
merge_deadlock_signaturesanswersprevious,undo: null,undo_note, asserted inSignatureMergeLiveTests.MergedSignatures_ShareCounts(:107);delete_mute_rule's undo uses create's own parameter spellings whileprevioususesget_mute_rules' (:2194-2218vsAgentChangeImages.cs:61-76). - UNVERIFIED — every test-run total and the build/warning counts — needs a build and a live rig; each new fact is
Assert.SkipWhen(DARLING_TEST_PG is empty). - UNVERIFIED — "the five server-tag descriptions stay under the 600-character cap" — 600 is a target, not a gate:
McpToolsListBudgetTests.cs:44enforces 1,000 and only on tools carrying the<<GUIDE>>marker (:56-68); none of the five has one, so nothing measures them. - UNVERIFIED — "every tool in #6403's list answers with
previousandundo" (the list is not in the tree), and the two negative controls ("reverted theexpected_modified_at… the test failed"). - REFUTED — "
remove_server:previousis the removed definition … (no password, no external ID)" —previousand the undo entry are the same projection (:868and:869-872both callRemovedPreviousOf), which setsentry["password"] = PasswordPlaceholderfor a secret-auth row (:775); the PR's own test assertsprevious["password"] == "<password>", and the shipped description repeats the wording at:429. No secret leaks, but apasswordkey an agent is told is absent is one it can misread. Suggestion inline.
Riskiest lines
DarlingMcpCustomAlertTools.cs:991— the undo'sexpires_at_utcis the previous expiry verbatim, andResolveUpdateExpiryrefuses a past value that is not the rule's current one (:905), so the undo of a revive is refused with nothing written, losing thename/description/enabledrestoration too;DeleteAnsweralready handles this (:2040-2049). — UNCOVERED (the replay test only goes the clear-to-""direction)DarlingMcpAlertTools.cs:1289— theFOR UPDATEthat makespreviouswhat the write replaced; without it, or out ofWritableTablesorder, a concurrent write lands between read and UPDATE again — covered byToolUndoAnswersLiveTests.UpdateAlertSettings_PreviousIsReadUnderTheRowLock_SoAConcurrentWriteIsNotMissedDarlingMcpServerAdminTools.Edit.cs:618—expected_modified_aton the undo'schanges_json; without it the replay overwrites a later edit — covered byEditServer_AnswersWithPreviousAndAnUndo_ThatPutsTheChangedFieldsBack_AndASecondReplayIsAConflict
Traps near this diff
- "Lite and Darling ship in parity (CONTRIBUTING's two-store rule): a fix to one SKU's reader, tool, alert or description without the counterpart's is a finding, not a nit" — does not apply — every Lite write tool with a Darling twin changes here (
Lite/Mcp/McpQueryTagTools.cs,McpAlertTools.cs:663-751,McpAnalysisTools.cs:1122-1200). The residualtext_tagpatternasymmetry is pre-existing in each SKU'sRuleNode(McpQueryTagTools.cs:1394vsDarlingMcpQueryTagRuleTools.cs:656) and each newpreviousmirrors its own SKU. - "A test that encodes the lie … Ask what defect a test would fail ON." — applies —
CustomAlertRuleExpiryLiveTests.cs:104now asserts the undo's spelling (undo["tool"]); four lines above, the same test proves a pastexpires_at_utcanswers"invalid", which is what that undo carries. Replaying it is the assertion that fails on the defect. - "Ruling (#5833): tests are load-bearing … never answer a trap with a test that reads source text" — does not apply as a finding — no new source-reading pin; every new fact replays the undo through the real tool. The two pins amended (
FindingStoreTests.cs:730-731,DarlingMcpAlertToolsTests.cs:122-123) pre-date this PR and only follow a renamed signature and a moved count. - "Two copies of one definition pinned EQUAL to each other fail only after BOTH have drifted (#3684). Alias, do not copy." — applies weakly —
{tool, arguments}is written four times (DarlingMcpUndoShape.Call,DarlingMcpAlertTools.cs:2571,Lite/Mcp/McpAlertTools.cs:816,Lite/Mcp/McpQueryTagTools.cs:1159); two are a real project boundary, the payload is two keys, and the replay tests pin the shape. - "A JSON number above 2^53 is rounded by every JavaScript client (#3487)" — does not apply — the undo arguments carry low-range route/tag/server/rule ids, and a mute rule's id is already a string on the wire (
DarlingMcpAlertTools.cs:1123). - "
hours_back,limit,top,days_backare refused when out of range, never clamped (#3699)" — does not apply — no window or page argument is touched. - "A default interface member compiles a parity gap away (#3640)" — does not apply — the one new type is a non-virtual static helper (
DarlingMcpUndoShape.cs:11); no store interface gained a member.
Divergence (blind read vs PR body)
Three places, none in the write paths:
previouscarries apasswordkey — REFUTED above; the body and the shippedremove_serverdescription both say "no password". One-word fix, suggestion inline.- A new file with no copyright header —
Mcp/DarlingMcpUndoShape.csstarts at itsusing. CONTRIBUTING.md:450 puts "New files have copyright headers" on the PR checklist, and the four new test files here all carry the block. - The "one snapshot" half of the invariant — the body says the test's rule "holds". The rule it now enforces does; the new overload runs on the caller's plain READ COMMITTED transaction, so its two reads are not one snapshot. Still correct, for a reason the code does not state: the lock loop only locks the tables the write targets and
AlertSettingsPreviousFieldsonly projects fields drawn fromupdates. Noted onDarlingAlertReader.cs:736, because that stops being true silently ifpreviouswidens.
The rest lines up, including the two limits the body volunteers against itself. Two more "the undo is a call the tool refuses" cases the body does not list are inline onEdit.cs:618(portandhoston rows saved before #6583 / #6352): legacy-row only, refused cleanly with nothing written.
Reviewer checklist
- Run the new live facts against a real store — all are
Assert.SkipWhenonDARLING_TEST_PG, so a leg without a rig reports skipped, not green. - Confirm
SELECT id FROM config_notification WHERE id = 1 FOR UPDATEsucceeds as themcplogin. The grants read correctly, andDarlingManagedRoles.cs:1657records that this table's privileges were measured rather than assumed last time for exactly this reason (a missing SELECT and a missing UPDATE raise the same 42501). - Replay
update_alert_settings' undo for the shapes no test covers:excluded_databases/excluded_volumes(back throughVolumeExclusions.Refusal, which a list stored before #6098's bounds could fail) andanalysis.uncorroborated_routewhere the file is deciding. - Look at the web PATCH
/api/servers/{id}response in a browser: it now carriesprevious,undoandundo_notefrom the shared core.
An undo of a custom alert rule update that cleared an already-passed expiry now restores no expiry (a past instant was refused and dropped every other restored field), with an undo_note. edit_server's undo_note says when a legacy port or host would make the undo refused. remove_server's description says previous carries the password placeholder. Copyright header on DarlingMcpUndoShape, RoutePayload doc comment back on RoutePayload, and the READ COMMITTED caveat documented on the reader overload.
| // drop every other restored field with it), so an already-passed expiry is restored as "no expiry" and the note says so. | ||
| var passed = before.ExpiresAtUtc is { } was && was <= DateTime.UtcNow; | ||
| arguments["expires_at_utc"] = passed ? string.Empty : CustomAlertExpiry.Format(before.ExpiresAtUtc) ?? string.Empty; |
There was a problem hiding this comment.
passed only looks at before.ExpiresAtUtc, not at whether this call actually changed it. ResolveUpdateExpiry (line ~900) exempts a resend that equals the row's current expiry from the past-refusal (if (parsed == current) return (parsed, null);), specifically so "re-saving an already-expired rule still works."
So: if a caller resends the rule's own already-passed expires_at_utc unchanged (e.g. while also changing description), before.ExpiresAtUtc == after.ExpiresAtUtc, both past, and the real update_custom_alert_rule call would accept that exact resend (no refusal, no-op on that field). But this code still sets passed = true and builds an undo that clears the expiry to "" with undo_note claiming the undo "will be refused" — which is wrong here: replaying the real previous value would have succeeded.
The fix needs before.ExpiresAtUtc != after.ExpiresAtUtc (or equivalently, that this call actually moved/cleared the expiry) in the passed condition, not just "was it in the past." CustomAlertRuleExpiryLiveTests's new assertions only exercise the case where the forward call clears the expiry (before=past, after=null), so this unchanged-resend path isn't covered.
There was a problem hiding this comment.
Verdict: CHANGES REQUESTED
Claims
- VERIFIED — "Custom alert rule update: ... Fixed (aaedf50). A passed expiry is restored as "" with an undo_note." —
DarlingMcpCustomAlertTools.cs:989-998adds thepassedbranch;CustomAlertRuleExpiryLiveTests.cs:258-266replays the undo and checksundo_notecontains "already passed" andexpires_at_utcis"", which fails against the pre-fix unconditionalCustomAlertExpiry.Format(before.ExpiresAtUtc)line. - VERIFIED — "
remove_serverdescription and body saidprevioushas no password: Fixed." —DarlingMcpServerAdminTools.cs:429now says the placeholder is carried; confirmed againstRemovedPreviousOf(DarlingMcpServerAdminTools.cs:754-776), which does setentry["password"] = PasswordPlaceholderfor secret auth. - VERIFIED — "No copyright header on
DarlingMcpUndoShape.cs: Fixed." — header added matches the standard block used by sibling files in the same directory (e.g.DarlingMcpAlertTools.cs:1-7). - VERIFIED — "
RouteRecreateUndolanded insideRoutePayload's doc comment: Fixed... the summary is back onRoutePayload." — diff moves the<summary>The ONE Darling route projection...</summary>block from aboveDeleteNotificationRouteUndoto aboveRoutePayload(DarlingMcpAlertTools.cs). - VERIFIED — "
DarlingAlertReaderoverload is READ COMMITTED, not one snapshot: Fixed as a comment... the lock loop covers the tables the write targets." — the caller (DarlingMcpAlertTools.cs:1289FOR UPDATEloop, line 1295 calling the transaction overload) does lock both tables before the read the new comment describes. - VERIFIED — "
edit_serverundo refused for legacy rows (port..., host...): Fixed... The host case shares the same code path and is not separately tested." — confirmed inDarlingMcpServerAdminTools.Edit.cs:607-627: the host branch (line 617-620) is structurally identical to the tested port branch (line 612-615) and reuses the well-testedServerHostSetting.Refusal; only the port path has a new live test. - UNVERIFIED — the various test-run totals ("Total 55 run, 0 failed, 2 skipped", etc.) — these are live-rig results from the author's machine, not reproducible from a read-only tree.
Riskiest lines
Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomAlertTools.cs:992-994— thepassedcheck tests only "isbefore.ExpiresAtUtcin the past," not whether this call actually changed it, so a caller resending the rule's own already-passedexpires_at_utcunchanged (alongside another field change) gets an undo that wrongly clears the expiry and anundo_noteclaiming a refusal that would not actually happen (ResolveUpdateExpiryexempts a resend equal to the current value from the past check). See inline comment. UNCOVERED —CustomAlertRuleExpiryLiveTestsonly exercises the "forward call clears the expiry" case, not the "forward call resends the unchanged past expiry" case.Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpServerAdminTools.Edit.cs:617-620— the host-refusal branch of the new undo-note logic. UNCOVERED by a behaviour test (author discloses this in the PR body: "not separately tested"), but low risk since it mirrors the tested port branch and calls the already-well-testedServerHostSetting.Refusal.Darling/Darling.Tests/CustomAlertRuleExpiryLiveTests.cs:263-266— the replay assertion (Assert.NotEqual("invalid", ...)) is the one line that actually revert-proves the main fix; it is the test that fails on the old unconditional-restore code. Covered by itself (it's the test) — not a gap, listed to show the fix's one exercised path.
Traps near this diff
- "Ruling (#5833): tests are load-bearing... A trap is answered with a behaviour test that fails on the defect and passes on the fix, or with one sentence in the PR body saying why none is needed." — applies, and is satisfied: both new live tests (
CustomAlertRuleExpiryLiveTests,ServerEditLiveTests.Undo.cs) replay the real undo through the real tool and assert on outcome/state, not on source spelling. - "Lite and Darling ship in parity... a fix to one SKU's reader, tool, alert or description without the counterpart's is a finding." — does not apply: this commit's fixes are to Darling-only features (multi-server
edit_server/remove_server, custom alert rule expiry) that have no Lite equivalent (Lite has no server-admin tools and no custom alert rules;grepconfirms noCustomAlertRuleorUndoShapeunderLite/). - "A test that reads the clock twice measures the CI scheduler" — does not apply: the new tests anchor their past timestamps off a single DB-side
now()call (CustomAlertRuleExpiryLiveTests.cs:227) or a fixed literal (ServerEditLiveTests.Undo.csport1444), not two independent clock reads.
Divergence (blind read vs PR body)
The PR body's "Round 1 review" section describes the expiry fix only as handling "the undo of a revive carried a past expires_at_utc" (i.e., the forward call that clears an expired rule's expiry). The actual code change is broader: it treats ANY update call that resends an expires_at_utc where before.ExpiresAtUtc is in the past as needing the clear-and-note treatment, regardless of whether the field actually changed. That broader condition introduces the unchanged-resend false positive described above and in the inline comment — a case the body doesn't mention and the test doesn't cover.
Reviewer checklist
- Confirm with a live rig whether resending a rule's own already-passed
expires_at_utcunchanged (with another field also changed) produces an undo that incorrectly clears the expiry, per the inline comment onDarlingMcpCustomAlertTools.cs:994.
…e undo-seeded test servers the scrub rejects (#6403 round 2 review)
Claude posting for Erik Darling. Stale and capped at 2 review rounds: both round-2 findings (the expiry undo on an unchanged resend, and the scrub names) are fixed in cd775d1, with a live test for the expiry case.
Claude posting for Erik Darling.
Fixes #6403.
What changed
Every write tool on #6403's list now answers with the previous value and an undo call, in the shape the newer tools use (
previousplusundo={tool, arguments}, orundo: nullwith anundo_notewhere no single call restores the state). The work landed in groups, each described in its own section below (A, B, C), and the server-admin group (add_servers,edit_server,remove_server) is the last one, described first here.Server-admin group
edit_server(updatedanswer):previousholds the fields the call changed, as they were;undois anedit_servercall that sets them back, carrying the newexpected_modified_at, so a change made after the edit makes the replay aconflictinstead of an overwrite. The stored password and the AWS external ID cannot be read back, so they are never inpreviousorundo. When the edit changed only secrets (a password-only edit),undoisnull.undo_notesays so in one sentence, and says the undo needs the password (and the external ID, for an AWS role) added to itschanges_jsonwhen it puts a SQL or service-principal server's connection or role back.add_servers: each result row hasundo. Anaddedrow carries theremove_servercall for its storage name; skipped, refused and failed rows carrynull.remove_server:previousis the removed definition in the keysadd_serverstakes (no AWS external ID; a SQL or service-principal row carries the placeholder<password>inpreviousas well as in the undo, since the stored password cannot be read back);undois theadd_serverscall that re-adds it, with the password as the placeholder<password>(a SQL or service-principal server only).undo_notenames what a re-add does not bring back, read from the code: the removal also deletes the server's tag assignments, query-tag assignments and rules, and collector schedule overrides and run times (Darling server admin: list_servers current at once, remove_server clears schedule rows, cost can be cleared (#6454) #6515); the new row has the default monthly cost, excluded databases, plan-capture and alert-delivery settings and comes back enabled; an AWS external ID is not restored. Collected history and the connected-servers registry row are never deleted and reattach by server id.edit_server's core is shared with the web edit route, which now also receives the three extra keys in its answer.origin/devtwice (the second time after A SQL Server port is refused on new entries, warned about on existing ones, and no longer named in a failed connect (#6583) #6584 landed; no overlap). One conflict inDarlingMcpAlertTools.cs(Darling MCP write tools: unclear wording and missing validation #6455'supdated_fieldsname and this PR'sprevious/undo): kept both.update_alert_settingsreportsdelivery.cooldown_minutesinupdated_fieldsand inprevious.Proof (server-admin group)
Darling on a throwaway PostgreSQL rig (stopped and deleted after), new facts in
ServerEditLiveTests.Undo.cs:EditServer_AnswersWithPreviousAndAnUndo_...: edits name, cost, host and a TLS flag, runs the undo, and the stored definition equals the before state; a later edit makes the replayconflictand the row keeps the later state. Reverted theexpected_modified_atin the undo: the test failed (updatedinstead ofconflict).EditServer_APasswordChange_...: a password-only edit hasundo: nulland a note; a name-plus-password edit's undo carries only the name, and the typed password appears nowhere in the answer.AddServers_EachAddedEntry...: the added row's undo (remove_server) removes it; the duplicate row has no undo.RemoveServer_AnswersWithThePreviousDefinition_...: the removed definition is inpreviouswith no stored password; the undo with the placeholder replaced re-adds the same name, host, auth, username, encryption and intent; the tag assignment the removal deleted is not back, as the note says.ServerEditLiveTests,ServerAdminStaleReadsLiveTests,DarlingMcpServerAdminToolsTests,ServerEditCoreTests,ServerAddRouteTests,ServerAddViewerRoleLiveTests,AddServerVerbTests,AwsRoleEditTests,DarlingMcpAddServersPolicyTests,DarlingMcpAgentRouteTagGuardLiveTests,QueryTagStoreLiveTests,McpMachineIdentityTests,QueryStoreReplicaConnectionTestTests,McpWriteToolUndoLiveTests,ToolUndoAnswersLiveTests, every*Census*and*Ratchet*class: Total 962, Failed 0, Skipped 2. After the merge of A SQL Server port is refused on new entries, warned about on existing ones, and no longer named in a failed connect (#6583) #6584:SqlServerPortIgnoredTests,ServerEditCoreTests,DarlingMcpServerAdminToolsTests,ServerAddRouteTests: 140, Failed 0. The alert-tool classes the first merge touched (DarlingMcpAlertToolsTests,ExcludedVolumesStoreTests,ClientTimeoutsSettingsLiveTests,AlertResolutionSettingsRungTests,McpToolGuideHeads*,McpWriteToolWordingTests): 195, Failed 0. Darling.Tests builds with 0 errors; the 4 warnings in the full build are inDarlingMcpNamedTokenTests.cs, a file this PR does not touch (the service project alone builds with 0 warnings).cpucap.shandtestslot.shwere refused by the harness, so I built with plaindotnet build ... -m:1and ranDarling.Tests.exedirectly. Not run: Lite (no Lite file changed in this lane), the full suite and the pre-push gate (CI decides).Considered, not added
previousinside the delete transaction forremove_server: the row is read just before the transaction. A concurrent edit in between would makepreviousone edit old; no concrete trigger beyond two operators editing and removing the same server in the same moment.add_serversalready refuses a placeholder that fails the connection probe.CHANGELOG entry
SECTION: Fixed
ENTRY:
previousand anundocall in the{tool, arguments}shape, orundo: nullwith anundo_notewhere no single call restores it. This includesadd_servers(each added server carries theremove_servercall),edit_server(the changed fields as they were, as anedit_servercall guarded by the newmodified_at; passwords and AWS external IDs are never returned, and the note says what to add) andremove_server(the removed definition and anadd_serverscall with a password placeholder; the note says the server's tags, query-tag assignments, schedule overrides, cost and excluded databases are not brought back).REF:
[Darling MCP: mute-rule and route toggles return the previous value and an undo call (part of #6403) #6514]: Darling MCP: mute-rule and route toggles return the previous value and an undo call (part of #6403) #6514
Group B
Each tool now answers with
previousandundoas{tool, arguments}(same shape asenable_server/disable_server), built through one helper,DarlingMcpUndoShape.Call. Each proof below calls the tool, replays the returned undo through the real tool, and checks the store is back.Darling (
McpWriteToolUndoLiveTests, throwaway PostgreSQL, one test per tool pair):create_custom_alert_rule(undo =delete_custom_alert_rule),update_custom_alert_rule(undo =update_custom_alert_ruleat the new version, naming only the fields the call changed),delete_custom_alert_rule(undo =create_custom_alert_rulewith the deleted definition;undo_notesays the rule gets a new id and its per-server alert state is not restored). The older stringundoon the expiry path is now the same object (CustomAlertRuleExpiryLiveTestsupdated). There is no separate "enable" tool for custom alert rules: enabling goes throughupdate_custom_alert_rule, covered by the update test (it flipsenabled).create_custom_view,update_custom_view,delete_custom_view(a personal view is recreated shared, and the note says so).create_query_tag,update_query_tag(also onunchanged),delete_query_tag(a leaf tag undoes withcreate_query_tag; a tag with children, assignments or rules returnspreviousand anundo_note, because no single call restores the subtree),assign_query_tag(new: undo isunassign_query_tag; move: undo isassign_query_tagwith the earlier tag),unassign_query_tag.create_query_tag_rule,update_query_tag_rule(also onunchanged),delete_query_tag_rule.merge_deadlock_signatures:previous(counted as itself),undo: nulland anundo_notepointing at Agent changes (a merge has no tool-call undo). Asserted inSignatureMergeLiveTests.MergedSignatures_ShareCounts.Lite (
McpQueryTagWriteUndoTests, temp DuckDB): the same eight query-tag and query-tag-rule tools, same shape.Tool descriptions name the new fields. Left to group A: the Lite route tools (
set_notification_route_enabled,delete_notification_route) and Litemute_analysis_finding/unmute_analysis_finding; I did not touch them.An update that cleared an expiry which had already passed now gets an undo that restores no expiry (a past instant is refused by the tool), with an
undo_notesaying so (round 1).Run on a throwaway PostgreSQL rig (stopped):
McpWriteToolUndoLiveTests,SignatureMergeLiveTests,CustomAlertRuleExpiryLiveTests(22 tests, 0 failed); thenDarlingMcpCustomAlertToolsTests,DarlingMcpCustomViewToolsTests,DarlingMcpQueryTagToolsLiveTests,DarlingMcpQueryTagRuleToolsLiveTests,DarlingMcpWritePolicyTests,AgentChangeToolUndoLiveTests,AgentLevel2*, every*Census*and*Ratchet*: Total 663, Failed 0. Lite:McpQueryTagWriteUndoTests,McpQueryTagToolTests,LiteQueryTagMcpToolTests, every Census and Ratchet class: Total 68, Failed 0. Not run: the full suite and the pre-push gate (laptop override; CI decides).Whether #6403 is fully closed depends on group A and on #6402 (server-admin tools): "Part of #6403" stays until the last group confirms nothing is left.
Group A
Every remaining alerting write tool on Darling now answers with
previousandundoas{tool, arguments}(same shape as above; helperUndoCall). Each is proved byToolUndoAnswersLiveTests(own scratch store): the test makes the change, takes the undo out of the answer, runs it through the same tool, and compares the stored state. Where a tool already answered partly, only the gap was filled.update_alert_settingsdelivery.cooldown_minutes; a route the file or default decided comes back null, which clears the store column again)update_alert_settingswith that objectUpdateAlertSettings_AnswersWithThePreviousSettings_AndAnUndoThatRestoresThemset_alert_threshold_overrideprevious_overridewas already thereclear: truewhen there was noneSetAlertThresholdOverride_AnswersWithAnUndo_...create_mute_ruledelete_mute_ruleCreateMuteRule_AnswersWithAnUndoThatDeletesTheRuleupdate_mute_rule(updated and unchanged)update_mute_rulewith themUpdateMuteRule_AnswersWithThePreviousFields_...(2 cases)delete_mute_rulecreate_mute_rulecarrying its fields (new id and creation date, enabled)DeleteMuteRule_AnswersWithTheDeletedRule_...create_notification_routedelete_notification_routeDeleteNotificationRoute_...update_notification_routeunchangedanswer now has it tooupdate_notification_routedelete_notification_routecreate_notification_routewith match, scope, enabled, resolutions, fallback, recipients; sealed webhook URLs and keys cannot be read back, so the undo says to pass them againmute_analysis_findingunmute_analysis_findingfor the same scope; "None needed" when it already existed (unmute_analysis_findingalready had both)MuteAnalysisFinding_AnswersWithAnUndoThatUnmutesItset_mute_rule_enabled, Level 2 (agent_action)previous.enabledset_mute_rule_enabledAgentChangeToolUndoLiveTests.TheLevel2SetMuteRuleEnabledAnswer_...The Level 2 create/update/delete mute tools run the same cores, so their answers carry the same fields.
Also fixed on this branch:
set_notification_route_enabled's tool head had drifted from Lite's (the first commit put the new sentence in the head), failingMcpToolGuideTests.EverySharedToolName_...; the sentence now sits in the guide.DarlingMcpAlertToolsSurfaceAndSqlTests.TheTwoConfigTables_AreReadUnderOneRepeatableReadSnapshotcounts three combined-entry-point reads now (the new pre-write read uses the same snapshot entry point); its rule holds, the count moved.Run on a throwaway PostgreSQL rig (stopped after):
*Mute*,*McpRoute*,*Census*,*Ratchet*,*McpToolGuide*,*McpToolsListBudget*,*DarlingMcpAlertTools*,*AgentChange*,*ToolUndo*,*NotificationRoute*,*ThresholdOverride*,*McpAlertAdmin*: Total 1347, Failed 0. An earlier wider run (instructions budget, DarlingMcpTools, ClientTimeouts, AlertResolution, ExcludedVolumes, payload contract) found only the two failures fixed above. Not run: the full suite and the pre-push gate (laptop override; CI decides).Limits: an undo of a delete cannot restore the id, the creation date, or a disabled flag (create makes a new enabled rule or route); the tool text says so.
update_alert_settingsprevious reads the effective settings, so a stored pair that was crossed comes back clamped.For the coordinator: group B's text says the Lite route tools and Lite
mute_analysis_finding/unmute_analysis_findingare "left to group A"; my brief assigned the Lite tools to group B, so I did not touch them. They are unclaimed. Server-admin tools stay with #6402. Keep "Part of #6403" until that is settled.Group C
Same
previous/undoshape (DarlingMcpUndoShape.Callon Darling; the same{tool, arguments}object on Lite). Each proof calls the tool, runs the returned undo through the real tool, and checks the stored state is back.set_notification_route_fallback{fallback_route_id}NotificationRouteFallbackLiveTests); confirmed in the code, nothing leftcreate_server_tagdelete_server_tagMcpServerTagUndoLiveTests.CreateServerTag_...update_server_tag(updated and unchanged)update_server_tagwith themUpdateServerTag_AnswersWithThePreviousFields_...(2 cases)update_server_tagmoveAMoveOfAServerTag_...delete_server_tagcreate_server_tag(new id). With children, assignments or a rule scoped to it:undonull with anundo_note(the subtree note, as for query tags)DeleteServerTag_OfALeaf_...,DeleteServerTag_WithChildrenOrAssignments_...assign_server_tagunassign_server_tagfor the servers added; "None needed" when nothing was addedAssignServerTag_...unassign_server_tagassign_server_tagfor the servers removed; "None needed" when nothing was removedUnassignServerTag_...set_notification_route_enabled(updated and unchanged){enabled}NotificationRoutesMcpTests.SetEnabled_AnswersWithThePreviousValue_AndAnUndoThatRestoresItdelete_notification_routeundo_note: Lite has no tool that creates a route (destinations are secrets), so Settings > Manage Notification Routes is the only way backDelete_AnswersWithTheRouteAsItWas_AndNoUndoCallmute_analysis_findingunmute_analysis_findingfor the same scope; "None needed" when the mute already existedMcpMuteReportsWhatItMatchedTests.TheMuteAndTheUnmute_AnswerWithAnUndoThatPutsTheRegistryBackunmute_analysis_findingmute_analysis_findingwith the same scope and the reason a row carried; "None needed" when nothing was removedLite's route tools are only these two (Lite has no route create/update tool). Lite
FindingStore.UnmuteStoryAsyncnow returns the removed rows (RETURNING mute_id, story_path, muted_date, reason) instead of a count, as Darling's twin already did; its one caller is the tool.FindingStoreTestsanchors on that method's signature to pin which call holds the write lock; the anchor strings follow the new signature and the rule it pins (one write-lock site, with its written reason) is unchanged.Tool text: the previous/undo sentences are in the guide part after the marker for the Lite route and analysis-mute tools (so the shared heads stay byte-identical,
McpToolGuide*passes); the five server-tag descriptions were kept short by hand; 600 is a target, not a gate (the budget test enforces 1,000 on tools carrying the guide marker, and none of the five has one).Level 2 (agent action) mute rule answers
AgentChangeToolUndoLiveTestsgains three cases that run the Level 2create_mute_rule,update_mute_ruleanddelete_mute_rulewith an agent seat, take theundoout of the answer, run it through the Level 0 tool, and check the store: create is deleted again, update puts the reason and database pattern back, delete makes the rule again. One thing the tests showed: at Level 2 the stored reason carries the agent suffix (known batch [agent:...]), soprevious.reasonand the recreated rule carry it too. That is the stored value, which is what an undo should restore.Check of group A's
update_alert_settingsread (item 5): it was NOT in the write's snapshot; fixed in the codeThe pre-write read ran on its own connection and transaction before the write transaction began, so a concurrent
update_alert_settingscould commit between the two andpreviouswould name a value the write never replaced (the count of 3 was a count of that separate snapshot). Fix: the tool now opens the write transaction first, locks the singleton rows it is about to update (SELECT id FROM <table> WHERE id = 1 FOR UPDATE, in the same fixed table order the writes use), and reads both tables through a newDarlingAlertReader.GetAlertConfigurationAsync(connection, transaction)overload inside that transaction (the single-table reads stay private). A second writer waits on the lock instead of landing between the read and the UPDATE, and nothing can fail that did not before.TheTwoConfigTables_AreReadUnderOneRepeatableReadSnapshotgoes back to counting 2GetAlertConfigurationAsync(postgres)entry-point calls (get_alert_settingsand the post-write re-read); its rule (every tool path reads both tables through the combined entry point, under one snapshot) holds, and the pre-write read is now in the write's own transaction instead of a snapshot of its own. Proof:ToolUndoAnswersLiveTests.UpdateAlertSettings_PreviousIsReadUnderTheRowLock_SoAConcurrentWriteIsNotMissedholds the settings row in another transaction, starts the tool (it waits), commits the other write, and checkspreviousnames the other writer's value and the undo restores it. I did not revert the fix to watch that test fail.Run
Darling on a throwaway PostgreSQL rig (stopped after):
*McpServerTagUndoLive*,*ServerTag*,*AgentChangeToolUndoLive*,*ToolUndoAnswersLive*,*DarlingMcpAlertTools*,*McpToolGuide*,*McpToolsListBudget*,*Census*,*Ratchet*,*MuteRule*,*NotificationRoute*,*McpWriteToolUndo*,*DarlingMcpWritePolicy*: Total 1277, Failed 0. Lite:*McpMuteReportsWhatItMatched*,*NotificationRoutes*,*FindingStore*,*McpToolGuide*,*McpToolsListBudget*,*Census*,*Ratchet*,*LiteMuteAnalysisFinding*,*McpWritePolicy*,*CrossAppMcp*,*McpReadToolBudget*,*QueryTag*,*Route*,*AnalysisTools*: Total 463, Failed 0. Both test projects build with 0 warnings. Not run: the full suite and the pre-push gate (laptop override; CI decides).Status of #6403
With groups A, B, C and the server-admin group, every tool in the issue's list answers with
previousandundo(orundo: nullwith anundo_notewhere no single call restores the state). Lite's route and query-tag tools and Lite's analysis mutes were answered by group C.Round 1 review
remove_serverdescription and body saidprevioushas no password: Fixed (aaedf50). The description now says a SQL or service-principal row carries the placeholder<password>inpreviousand the undo, and the AWS external ID is left out. Kept the placeholder inprevious(same projection as the undo, one source) rather than splitting them.DarlingMcpUndoShape.cs: Fixed (aaedf50).expires_at_utc, which the tool refuses, dropping every other restored field too: Fixed (aaedf50). A passed expiry is restored as "" with anundo_note.CustomAlertRuleExpiryLiveTestsnow replays the undo (fails on the old code, revert-proved) instead of asserting its spelling.edit_serverundo refused for legacy rows (port saved before A SQL Server entry's port is ignored without a warning, and a failed connect reports that port #6583, host saved before Server host field: refuse named-pipe and admin protocol prefixes and UNC paths #6352): Fixed (aaedf50).undo_notenow says the undo will be refused with nothing written, naming the value. TestEditServer_ClearingAPortSavedBeforeTheRule_...seeds a row with port 1444, clears it, checks the note and that the replay answersinvalidand writes nothing (revert-proved). The host case shares the same code path and is not separately tested.RouteRecreateUndolanded insideRoutePayload's doc comment: Fixed (aaedf50), the summary is back onRoutePayload.DarlingAlertReaderoverload is READ COMMITTED, not one snapshot: Fixed as a comment (aaedf50). The remark says why it is correct (the lock loop covers the tables the write targets, andpreviousonly projects fields fromupdates) and when it stops being true.undoas a bare "None needed: ..." string beside the{tool, arguments}object: Not changed.AlertTools,CollectionStateToolsandMcpToolsalready answer this way on dev and existing tests assert it; moving it tonullplusundo_noteis a cross-tool contract change for its own PR.destinations: Not changed, documented in its summary; the webhook secrets cannot be placed inarguments.FOR UPDATEas themcplogin onconfig_notificationis exercised byToolUndoAnswersLiveTeststhrough the managed role (passed); the web PATCH browser check and theexcluded_databases/excluded_volumesreplay were not done.Test run (rig on a throwaway PostgreSQL):
CustomAlertRuleExpiryLiveTests,ServerEditLiveTests,ToolUndoAnswersLiveTests,McpServerTagUndoLiveTests: 55 run, 0 failed, 2 skipped (real SQL Server). Plus every*Census*and*Ratchet*class,McpToolsListBudget,DarlingMcpAlertToolsTestsandMcpServerAdminToolsclasses: 615 run, 0 failed, 2 skipped. Build0 Error(s). Full suite not run; CI decides.Round 2 review
Claude posting for Erik Darling.
passedbranch inDarlingMcpCustomAlertTools.UpdateAnswernow applies only when this call actually changed the expiry (after.ExpiresAtUtc != before.ExpiresAtUtc) and the old one had passed. An update that resends the rule's own passed expiry unchanged (while changing another field) now gets an undo that sends the same expiry back, which the tool accepts because it equals the current value, and carries no "already passed" note. New live caseCustomAlertRuleExpiryLiveTests.UpdateUndo_OnAnUnchangedResendOfAPassedExpiry_RestoresThatSameExpiry_WithNoNote: a rule aged to a past expiry, an update resending it and changing the description, then the undo replays without "invalid" and the expiry is unchanged. Revert-proved: with the old line the test fails onAssert.Null(undo_note); with the fix it passes.FleetIdentifierScrubTests.NoTrackedSourceFileNamesAServerByShortNameAndOrdinal: inServerEditLiveTests.Undo.csthe seeded namesundo-01/02/03/04/05/09/10are nowzeta-NN(zetais on the synthetic list) andblob-02/blob-04arerem-blob-b2/rem-blob-b4.Ran on a throwaway PostgreSQL (stopped after):
CustomAlertRuleExpiryLiveTests,FleetIdentifierScrubTests, every*ServerEdit*class,DarlingMcpCustomAlertToolsTests: Total 580, Failed 0, Skipped 2 (the twoServerEditLiveTestscases that need a real SQL Server). Not run: the full suite (CI decides).