Repository navigation
A refused plan-tool dry run says the server's switch state; custom alert rule scope documented (part 1 of #6455) - #6617
Conversation
…ert rule scope is documented (part 1 of #6455)
| return $"For reference, {ServerActionNames.Label(serverAction)} on {serverName} is currently {(state.Enabled ? "on" : "off")}" | ||
| + (state.Enabled ? "." : ", so the real call would be refused as actions_off until it is turned on."); |
There was a problem hiding this comment.
The note names the wrong refusal code. A per-server switch that is off is refused as server_action_off, not actions_off:
AgentActionCodes.SwitchOff = "server_action_off"— "The server-action gate: that server's switch for that action is off, or has no row (no row means off)" (PerformanceMonitor.Common/Investigations/InvestigationContracts.cs:403).AgentActionCodes.ActionsOff = "actions_off"is a different condition: "config_service.agent_actions_enabledis off" (same file, line 382) — the service-wide knob, checked first.- The gate returns
AgentActionCodes.SwitchOfffor exactly this case (InvestigationRequestStore.ServerAction.cs:193), and the tree's own behaviour test pins it:PlanForceToolLiveTests.WithoutTheServersSwitch_TheCallIsRefused_Journaled_AndWritesNothing() => AssertRefusedAsync(Without.Switch, "server_action_off")(PlanForceToolLiveTests.cs:403), with the same word for evict (PlanForceToolLiveTests.Evict.cs:288) and kill (PlanForceToolLiveTests.Kill.cs:467,PostgresKillLiveTests.cs:362).
The whole point of #6455 item 3 is that the refusal should not mislead the agent about what the real call would do. As written it does: the agent is told the call would come back actions_off, and the two codes have different remedies — actions_off is cleared by the service's agent_actions_enabled, while this one is cleared by a person setting enabled for that (server, action) row in config.config_server_actions. An agent that acts on the sentence looks at the wrong knob, and an agent that later sees the real server_action_off sees a code the dry run never mentioned.
This is also the #3684 shape from .github/REVIEW-TRAPS.md — "Two copies of one definition pinned EQUAL to each other fail only after BOTH have drifted. Alias, do not copy." The code word is spelled as a literal here instead of coming from AgentActionCodes, and the copy has drifted on the first write.
| return $"For reference, {ServerActionNames.Label(serverAction)} on {serverName} is currently {(state.Enabled ? "on" : "off")}" | |
| + (state.Enabled ? "." : ", so the real call would be refused as actions_off until it is turned on."); | |
| return $"For reference, {ServerActionNames.Label(serverAction)} on {serverName} is currently {(state.Enabled ? "on" : "off")}" | |
| + (state.Enabled ? "." : $", so the real call would be refused as {AgentActionCodes.SwitchOff} until it is turned on."); |
Two smaller things in the same sentence, both non-blocking:
ServerActionNames.Labelreturns a verb phrase, so the note reads "For reference, Evict plans from the plan cache on srv-1 is currently off" / "Force or unforce plans on srv-1 is currently off". The fixture string in the newMcpWriteToolWordingTestscase ("Force plan on srv") reads like what was intended. Either the rawserverActionword (force_plan, which is also what the gate's own refusal message uses atInvestigationRequestStore.ServerAction.cs:195) or a re-worded sentence ("theforce_planswitch on srv-1 is currently off") would be both grammatical and consistent with the code the agent sees elsewhere.- Even with the switch on, the real call still has four conditions ahead of it (
actions_off,claim_not_live,not_level_3,no_remediation_credential). The "on." arm doesn't promise success, so this is fine as written — just worth not letting the "off" arm claim more precision than the gate order supports.
| /* #6455: no claim, no agent_action. The plan tools read the switch for a refused dry run through ServerActionSwitches.ListAsync. */ | ||
| var off = await DarlingMcpPlanForceTools.ReadSwitchStateAsync(owner, null, SqlServerId, "srv-1", "force_plan", ct); | ||
| Assert.Contains("is currently off", off, StringComparison.Ordinal); | ||
| Assert.Contains("actions_off", off, StringComparison.Ordinal); |
There was a problem hiding this comment.
This assertion pins the defect rather than failing on it: it requires the note to contain actions_off, which is the service-wide agent_actions_enabled code, not the per-server switch code the real call would answer (server_action_off — AgentActionCodes.SwitchOff, InvestigationRequestStore.ServerAction.cs:193). See the comment on DarlingMcpPlanForceTools.cs:191.
.github/REVIEW-TRAPS.md names this shape: "A test that encodes the lie: ACappedResult_WithholdsTheUnsatisfiedSummary_… handed the builder two facets at limit = 2 and asserted truncated — the >= limit inference as its own proof (#3699). Ask what defect a test would fail ON." This assertion is green on the wrong code and would go red on the right one.
If the message is fixed, this becomes the test that proves it:
| Assert.Contains("actions_off", off, StringComparison.Ordinal); | |
| Assert.Contains(AgentActionCodes.SwitchOff, off, StringComparison.Ordinal); |
(AgentActionCodes is already in scope here via using PerformanceMonitor.Common; at line 25 — it is used at line 154 of this same file.) Asserting the constant rather than the literal also keeps the test from drifting the next time the code word moves.
| { | ||
| try | ||
| { | ||
| var rows = await ServerActionSwitches.ListAsync(owner, ct); |
There was a problem hiding this comment.
Non-blocking, two observations about which pool and how much is read here.
The owner pool for a read the mcp role can do. ServerActionGate's own doc explains why owner exists on this path: "the remediation secret column is not readable by the mcp role" (ServerActionGate.cs:34-36). The switch state is not in that category — get_server_actions reads it through the mcp data source (DarlingMcpServerActionTools.cs:120), and so does the web GET (DarlingWebEndpoints.cs:4149). RunAsync has postgres in hand at every call site, so passing that instead would keep the owner pool for the reads that actually need owner rights.
A whole-fleet read for one sentence. ServerActionSwitches.ListAsync has no predicate and no LIMIT (ServerActionSwitches.cs:84-88): it returns every monitored server joined to every switch row, then materialises three ServerActionSwitchState records per server, and this code keeps one of them. On a large fleet that is the full registry crossed with the switch table on a path that any MCP token can hit repeatedly with no claim and no gate behind it (the refusal is returned before any of the gate's conditions are evaluated). A single-row read keyed on (server_id, action) — the shape ServerActionSwitchSql already uses at InvestigationRequestStore.ServerAction.cs:187 — would answer the same question. Worth it only if the fleet sizes you care about make it measurable; the correctness of the note doesn't depend on it.
There was a problem hiding this comment.
Verdict: CHANGES REQUESTED
Claims
- VERIFIED — item 3 touches all four plan tools (force/unforce, evict, kill, kill-on-PostgreSQL) and reads the switch with
ServerActionSwitches.ListAsync, which needs no claim —DarlingMcpPlanForceTools.cs:271,:446,:570,:726(the only fourServerActionGate.AuthorizeAsyncsites in the tree) and the read at:186. - VERIFIED — "Gate order is unchanged" —
ServerActionGate.csandInvestigationRequestStore.ServerAction.csare untouched; the switch is read only after the gate has returned its refusal (:177-179). - VERIFIED — "A real call, and any other refusal reason, keeps its old text" — the
dryRun && Reason == AgentActionCodes.Requiredguard at:161gates sentence and read alike; asserted atMcpWriteToolWordingTests.cs:107-108. - VERIFIED — a throwing switch read drops the note and still answers the refusal, logging a warning —
:197-201, with the cancellation rethrow at:193-196. - REFUTED — "
... currently off, so the real call would be refused as actions_off until it is turned on" — the tree answersserver_action_offfor a per-server switch that is off (AgentActionCodes.SwitchOff,InvestigationContracts.cs:403; returned atInvestigationRequestStore.ServerAction.cs:193).actions_offis a different condition, the service-wideconfig_service.agent_actions_enabled(InvestigationContracts.cs:382). Four behaviour tests already pinserver_action_offfor this exact state:PlanForceToolLiveTests.cs:403,PlanForceToolLiveTests.Evict.cs:288,PlanForceToolLiveTests.Kill.cs:467,PostgresKillLiveTests.cs:362. Inline atDarlingMcpPlanForceTools.cs:191. - VERIFIED — no
scopemeans every monitored server, and theservers/tagshapes are as documented —CustomAlertRuleDefinition.cs:462(defaults toCustomAlertScopeMode.All),:473-490,:491-510,:105. - VERIFIED — "a new definition replaces the old one whole" —
definition ?? row.DefinitionJsonatDarlingMcpCustomAlertTools.cs:293, so a scope omitted from new JSON is gone. - VERIFIED —
create_custom_alert_rulealready carried the sentence from #6509 —DarlingMcpCustomAlertTools.cs:159-160, outside this diff. - VERIFIED — the README paragraph sits under "Level 2: agents change mute rules and custom alert rules" (
Darling/README.md:2291, paragraph at:2307) and its agent-scope claim matchesDarlingMcpAgentActions.RuleScopeRefusalAsync:370-377. - VERIFIED — "Only description strings were touched in
DarlingMcpCustomAlertTools.cs" — the file's one hunk is inside the[Description(...)]literal ofUpdateCustomAlertRule. - VERIFIED — item 7 needed nothing:
RefuseMissingRequiredexists (McpUnknownArgumentGuard.cs:268), is called from the one filter (:107) registered in both SKUs (DarlingMcpHostService.cs:1849,Lite/Mcp/McpHostService.cs:487), and the diff does not touch it. - UNVERIFIED — "0 errors; the 6 warnings are in files this PR does not touch" — needs a build. Nullable flow on the four new
GateRefusalAnswerAsync(..., owner, ...)sites is what it would settle, and reads sound:owneris null-checked at:262,:436,:549,:715. - UNVERIFIED — "Total 654, Failed 0, Skipped 68 ... my own throwaway PostgreSQL" — a rig result; no runner or
DARLING_TEST_PGhere. - UNVERIFIED — the two new
PlanForceToolLiveTestscases skipped for want ofDARLING_TEST_SQL. Note neither asserts the refusal code in the note (only"is currently off"/"is currently on.",:415-418), so CI passing them would not catch the REFUTED claim.
Riskiest lines
Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanForceTools.cs:191— the sentence this change exists to produce namesactions_offwhere the gate answersserver_action_off, pointing the agent at the service-wide knob instead of the per-server switch row — UNCOVERED, and worse:ServerActionSwitchWriteLiveTests.cs:94asserts the wrong word, so the defect is pinned green and the fix turns that test red.Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanForceTools.cs:197-201— the catch that must let a failed switch read fall back to the bare refusal rather than answer an error — UNCOVERED; no behaviour test drives a failingListAsyncthrough this path.Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanForceTools.cs:186—ListAsync(owner, ...)reads the whole registry crossed with the switch table, on the owner pool, on a path reachable with no claim and no gate condition met — happy path covered byServerActionSwitchWriteLiveTests.ADryRunRefusedForWantOfTheClaim_ReadsTheSwitchWithoutOne_AndSaysOnOrOff; pool choice and read width UNCOVERED and non-blocking.
Traps near this diff
- "Two copies of one definition pinned EQUAL to each other fail only after BOTH have drifted (#3684). Alias, do not copy." — applies — the code word is a literal
actions_offin a message string at:191instead ofAgentActionCodes, and the copy is already wrong againstAgentActionCodes.SwitchOff. - "A test that encodes the lie ... Ask what defect a test would fail ON. (#3699)" — applies —
ServerActionSwitchWriteLiveTests.cs:94assertsAssert.Contains("actions_off", off): green on the wrong code, red on the right one. - "Ruling (#5833): tests are load-bearing ... never answer a trap with a test that reads source text or asserts how code is spelled" — does not apply — all three new tests drive behaviour (
McpWriteToolWordingTests.cs:100-108,ServerActionSwitchWriteLiveTests.cs:92-101,PlanForceToolLiveTests.cs:425). - "Lite and Darling ship in parity (CONTRIBUTING's two-store rule): a fix to one SKU's ... description without the counterpart's is a finding, not a nit" — does not apply — Lite ships no counterpart; the plan and custom-rule tools exist only under
Darling/plus the shared roster atPerformanceMonitor.Common/Investigations/InvestigationRules.cs:68. - "
hours_back,limit,top,days_backare refused when out of range, never clamped (#3699)" — does not apply — no window or page argument is added;ReadSwitchStateAsync:182takes a server id and an action name. - "Truncation inferred from a full page:
truncated = rows.Count >= limit... Fetchlimit + 1" — does not apply — no page and notruncated;ServerActionSwitches.ListSqlhas noLIMIT(ServerActionSwitches.cs:84-88) and the code keeps one state object (:187). - "A
total_*assigned from a.Countis a count of the page unless the set is provably whole (#3699)" — does not apply — nothing in the diff emits a count; the payload gains one sentence onmessage.
Divergence (blind read vs PR body)
One, and it is the finding. The body quotes the off-arm sentence verbatim and presents it as the mechanism item 3 needed; my blind read of the gate found that mechanism answers server_action_off, not actions_off — two codes separated by condition and by checking order (InvestigationContracts.cs:382 vs :403), with four live tests already pinning the right word. Body and diff agree with each other and both disagree with the gate: the body reproduces the literal instead of checking it against AgentActionCodes, and the new test at ServerActionSwitchWriteLiveTests.cs:94 asserts the same literal, closing the loop on itself.
Two smaller gaps. The body writes For reference, <action> on <server> is currently on., but <action> renders through ServerActionNames.Label (InvestigationContracts.cs:334-340), so the real sentences read "Force or unforce plans on srv-1 is currently off" and "Evict plans from the plan cache on srv-1 is currently off" — and the author's own fixture string ("Force plan on srv", McpWriteToolWordingTests.cs:98) does not match what the code emits. The body also does not mention that the note reads the whole fleet's switch table on the store owner's pool on a pre-gate path; non-blocking, inline at :186.
Reviewer checklist
- Run the two new
PlanForceToolLiveTestsdry-run cases withDARLING_TEST_SQLandDARLING_TEST_PGset and read the whole refusalmessage, not just the"is currently off"substring. - Against a live store, send
force_query_store_planwithdry_run=trueand noagent_actionon a server whoseforce_planswitch is off, then the same call with a valid Level 3agent_action, and check the second answer's code is the one the first predicted. - Confirm the build is clean, including nullable flow on the four new
GateRefusalAnswerAsync(..., owner, ...)sites (:271,:446,:570,:726). - On a fleet-sized registry, time one refused dry run to see whether the whole-fleet
ListAsyncon the owner pool is measurable.
There was a problem hiding this comment.
Verdict: CHANGES REQUESTED
Claims
- VERIFIED — "Merged origin/dev. One conflict, the UpdateCustomAlertRule tool description in DarlingMcpCustomAlertTools.cs: dev added the previous/undo sentence to the same line this PR extends with the server-scope wording. Kept both, in that order." —
Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomAlertTools.cs:234-235: the previous/undo sentence (dev) is immediately followed by the "Server scope is part of the definition…" sentence (this PR), in that order. - VERIFIED — "Merging dev conflicted in docs/mcp-server.md only… Kept both, dev's row unchanged." —
docs/mcp-server.md:142-143: the "Custom alert rules" row (this PR) and the rewritten "Alert tuning" row withupdate_notification_channels(dev) both sit intact, adjacent. - REFUTED (carried forward, unfixed by this commit) — the PR's own summary line, "the dry-run refusal now also states the server's switch state," including the off-arm wording quoted in the body —
Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanForceTools.cs:191still emits"...so the real call would be refused as actions_off until it is turned on.", but the gate for an off switch answersAgentActionCodes.SwitchOff(server_action_off), notactions_off(InvestigationContracts.cs:403vs the service-wideactions_offat:382). This is the exact defect the prior review on this PR (head=1bc880f8) flagged as CHANGES REQUESTED. The only commit in this follow-up range is thedevmerge, and it does not touchDarlingMcpPlanForceTools.csorServerActionSwitchWriteLiveTests.csat all (git diff 1bc880f8..3051d1b22is empty for both files), so the defect — and the self-confirming wrong assertion atServerActionSwitchWriteLiveTests.cs:94(Assert.Contains("actions_off", off, ...)) — stands exactly as before.
Riskiest lines
Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanForceTools.cs:191— still unfixed from the prior review: an agent refused a dry run for a switch that is off is told the real call "would be refused asactions_off," pointing it at the wrong knob (config_service.agent_actions_enabled) instead of the per-server switch it just read — UNCOVERED, and worse,ServerActionSwitchWriteLiveTests.cs:94pins the wrong word green, so a correct fix turns that test red.docs/mcp-server.md:142-143— this follow-up's only real content: a two-way merge conflict between this PR's new row and dev's rewritten row — correctly resolved (both verified present) — UNCOVERED (docs, no behaviour test expected).Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomAlertTools.cs:234-235— the other merge conflict, splicing dev's previous/undo sentence with this PR's scope sentence into one[Description]literal — correctly resolved, both sentences intact — UNCOVERED (description text, no behaviour test expected).
Traps near this diff
- "Two copies of one definition pinned EQUAL to each other fail only after BOTH have drifted (#3684). Alias, do not copy." — applies (carried forward, unfixed) — the literal
actions_offatDarlingMcpPlanForceTools.cs:191is a hand copy of a code, not a reference toAgentActionCodes, and it is already wrong againstAgentActionCodes.SwitchOff; the merge in this range left it untouched. - "A test that encodes the lie … Ask what defect a test would fail ON. (#3699)" — applies (carried forward, unfixed) —
ServerActionSwitchWriteLiveTests.cs:94still assertsAssert.Contains("actions_off", off, ...), green on the wrong code. - "Ruling (#5833): tests are load-bearing … never answer a trap with a test that reads source text or asserts how code is spelled." — does not apply to this follow-up's own content — neither merge conflict (
DarlingMcpCustomAlertTools.cs:234-235,docs/mcp-server.md:142-143) added or touched a test.
Divergence (blind read vs PR body)
The body's "Dev merge" note accurately describes both conflicts and how they were resolved — verified on both counts, no divergence there. The divergence is with the PR's main account, carried into this commit unchanged: the body still presents the off-arm sentence as delivering "the server's switch state," and the merge commit in this follow-up range had the opportunity to touch DarlingMcpPlanForceTools.cs (dev's refactor passed directly through several nearby files, e.g. DarlingMcpCustomAlertTools.cs's CustomAlertRuleStore construction) but did not reconcile or even revisit the actions_off wording the prior review rejected. Nothing in this commit range addresses the earlier CHANGES REQUESTED finding.
Reviewer checklist
- Fix
DarlingMcpPlanForceTools.cs:191to emit the real refusal code (AgentActionCodes.SwitchOff/server_action_off, not the literalactions_off), then fixServerActionSwitchWriteLiveTests.cs:94to assert the corrected word so it fails on the old defect and passes on the fix. - Run the two new
PlanForceToolLiveTestsdry-run cases withDARLING_TEST_SQL/DARLING_TEST_PGset and read the whole refusalmessage, not just the"is currently off"substring, to confirm the corrected code reads the way a live agent would parse it. - Confirm the build is clean after the merge (
dotnet build Darling/Darling.Tests).
…he constant (#6455) The note said the real call would be refused as actions_off, the service-wide switch. The per-server switch answers server_action_off. Build it from AgentActionCodes.SwitchOff, assert the predicted code equals the gate's, and cover the failed-read fallback.
Claude posting for Erik Darling. Addressed in 20bf2a5: the dry-run note now names the per-server switch code from AgentActionCodes.SwitchOff, and the live test asserts that code. CI green on that head.
Claude posting for Erik Darling.
Refs #6455 (all seven items were fixed on dev by #6509).
Follow-up to #6509 on items 3 and 4: the dry-run refusal now also states the server's action switch, and
update_custom_alert_ruleplus the docs state the rule scope. Item 7 needed nothing more (#6509 addedRefuseMissingRequired). No second part.Why
Dev already carries most of this (#6509 merged earlier): the plan tools' dry-run refusal explains why a dry run needs the claim,
create_custom_alert_rulestates its scope, and the sharedMcpUnknownArgumentGuardnames a missing required argument. What was still open against the issue's "Expected" lines is listed below.What changes, per item
Item 7 (missing required argument): nothing to change.
PerformanceMonitor.Common/Mcp/McpUnknownArgumentGuard.csalready hasRefuseMissingRequired, called from the same call filter the unknown-argument guard is registered as, for every tool, in Darling and in Lite (both registerMcpUnknownArgumentGuard.Instance). It reads the tool's advertisedrequiredlist and answersMissing required argument 'version' for tool '...'. The brief asked for a sibling filter; the existing arm in the one shared filter does the same job, so adding a second filter would only run the same check twice. Its tests are inMcpUnknownArgumentGuardTests(around theRefuseMissingRequiredcalls: refused with the name, passes when present). I made no item 7 change, so there is no revert-proof to run.Item 3 (dry run refused as
agent_action_required). Gate order is unchanged. The refusal text already said why a dry run needs the claim; what was missing was the per-server switch state. For a dry run refused asagent_action_required, the four plan tools (force/unforce, evict, kill, kill on PostgreSQL) now read the switch with the existingServerActionSwitches.ListAsync(no claim needed) and append one sentence:For reference, <action> on <server> is currently on.or... currently off, so the real call would be refused as server_action_off until it is turned on.(the code is built fromAgentActionCodes.SwitchOff;actions_offis the service-wide switch). A real call, and any other refusal reason, keeps its old text. If the switch read throws, the note is left out and the refusal still goes out (logged as a warning).Item 4 (custom alert rule scope). The code does offer a way: the rule definition's
scope(modeall,serverswith a non-emptyserverslist, ortagwith a fleet-tagtagId); with noscopethe rule applies to every monitored server.create_custom_alert_rule's description already said so (#6509). Added: the same statement inupdate_custom_alert_rule's description (and that a new definition replaces the old one whole, so the scope must be included to keep it), a row indocs/mcp-server.md, and a paragraph inDarling/README.mdunder "Level 2: agents change mute rules and custom alert rules". Only description strings were touched inDarlingMcpCustomAlertTools.cs.Bot review rounds 1-2 (changes requested)
actions_off; the per-server gate answersserver_action_off(AgentActionCodes.SwitchOff). The sentence now takes the code from that constant, so it cannot drift. The other dry-run sentences this PR added name no code.ServerActionSwitchWriteLiveTestsnow assertsserver_action_off(and thatactions_offis absent).TheGate_ReadsTheNewState...also asserts the code the dry-run note predicts equals theReasonthe real gate call returned for the same server. The wording test's sample note uses the constant too.ADryRunWhoseSwitchReadFails_FallsBackToTheBareRefusal_NotAnErrorpoints the read at a data source nothing listens on (port 1): the note is null and the refusal is stillrefused/agent_action_requiredwith no "For reference". It exercises the read helper and the answer builder, not the full tool path.ServerActionSwitches.ListAsyncis the only public read (the gate's single-switch SQL is internal and needs an open transaction), and the brief allowed no new store method. The registry is small and the read runs only for a dry run refused for want of a claim.Test plan
dotnet build Darling/Darling.Tests: 0 errors; the 6 warnings are in files this PR does not touch.McpWriteToolWordingTests,ServerActionSwitchWriteLiveTests,McpUnknownArgumentGuardTests,PlanForceTool*,McpInstructions*,PayloadContract*, every*Census*and*Ratchet*: Total 654, Failed 0, Skipped 68 (thePlanForceToolLiveTestsset skips withoutDARLING_TEST_SQL). Run against my own throwaway PostgreSQL (stopped after).McpWriteToolWordingTests.ADryRunRefusedForWantOfTheClaim_AddsTheSwitchState_AndAWriteOrAnotherReasonDoesNot(the answer for a dry run vs a write vs another reason);ServerActionSwitchWriteLiveTests.ADryRunRefusedForWantOfTheClaim_ReadsTheSwitchWithoutOne_AndSaysOnOrOff(live: off, then on afterSetAsync, and no note for an unknown server). Both ran and passed.PlanForceToolLiveTests.ADryRunWithNoAgentClaim_IsRefusedAsRequired_AndSaysWhyAndThatTheSwitchIsOff/...IsOn: need a test SQL Server (DARLING_TEST_SQL), which I did not set, so they skipped here. CI decides.pre-push.shnot run (brief: CI decides).Considered, not added
CHANGELOG entry
SECTION: Fixed
ENTRY:
update_custom_alert_rule,docs/mcp-server.mdand the Darling README now say a custom alert rule applies to every monitored server unless its definition carries ascope.REF:
[Darling MCP write tools: unclear wording and missing validation #6455]: A refused plan-tool dry run says the server's switch state; custom alert rule scope documented (part 1 of #6455) #6617
Dev merge
Merged origin/dev. One conflict, the UpdateCustomAlertRule tool description in DarlingMcpCustomAlertTools.cs: dev added the previous/undo sentence to the same line this PR extends with the server-scope wording. Kept both, in that order. Dev's CustomAlertRuleStore construction is unchanged (no deadline argument on dev's form) and nothing in the file needed to change for it.
Merging dev conflicted in docs/mcp-server.md only: this PR's new Custom alert rules row and dev's rewritten Alert tuning row (adds update_notification_channels) sat on adjacent lines. Kept both, dev's row unchanged. This PR touched no tool count, so none were recounted.