Skip to content

Level 2: agents change mute rules and custom alert rules, journaled (#5915) - #5934

Merged
erikdarlingdata merged 5 commits into
devfrom
fix/5915-level2-mute-rule-tools
Oct 16, 2026
Merged

erikdarlingdata merged 5 commits into
devfrom
fix/5915-level2-mute-rule-tools

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

Claude posting for Erik Darling.

Fixes #5915. Part of #5632 (Phase B, wave 3).

Why

At Level 2 an agent working a live claim can now mute one metric on the request's server and create, change, disable or delete a custom alert rule scoped to that server. Every change goes through the one gate from #5925, carries a reason and evidence, is attributed on the row and is journaled.

What changes

  1. Mute tools (agent arm). create_mute_rule, update_mute_rule, delete_mute_rule, set_mute_rule_enabled are in DarlingMcpWritePolicy.AgentActionTools. Under the investigations arm they take agent_action and call DarlingAgentActionGate.RunAsync (two phases: action_called committed first, then ONE transaction that re-checks, writes on the gate's own connection and inserts action_done; a failure rolls the change back and is journaled action_failed). Under the writes arm they are the same tools and ignore agent_action. The write is the same code the writes arm runs (CreateMuteRuleOver, UpdateMuteRuleCore, DeleteMuteRuleCore, SetMuteRuleEnabledCore).
    • Kind rules: mute_needs_metric (no server_id or no metric_name, on the after image of create, update and enable); self_monitor_metric (family self-monitor, or any *Metric constant of DarlingSelfAlertEvaluator except the five AG metrics, Agent Not Running and the three report digests; read by reflection so a new constant is protected without an edit); other_server when the mute's server_id, or the stored rule's server_id for update, delete and enable, is not the request's (a stored rule with no server_id counts as another server's). Disable and delete need only the before image on the server.
    • Reason suffix [agent:<id> request <uuid>]. A mute's scope is exactly the request's server_id. Mute rules have no tag-scope column, so "tag scope null" holds structurally; the agent arm has no parameter that could set one.
  2. Custom alert rule tools (agent arm). create_custom_alert_rule, update_custom_alert_rule, delete_custom_alert_rule. rule_scope refuses unless the definition's scope is mode: servers with exactly the request's one server (by storage name), checked on the before image and the after image. Disable and delete are allowed. updated_by is agent:<id>. No view tool is in the agent arm.
  3. Level 2 offered. The brief composer reads the request's stamped autonomy_level (new InvestigationBriefRequest.AutonomyLevel, default 1, read by the Postgres reader): a Level 2 brief names the seven tools in allowed_actions and says "change first, report last: writes refuse after submit"; a Level 1 brief is byte-for-byte as before. The Viewer route editor now has an enabled "Act (Level 2...)" item and tooltip. action_proposals stays refused (pinned). Web: the route table on the web is read-only (no agent_level editor exists there; it already prints "Act (n/h, n/day)"), so there was no web editor control to add.
  4. Docs. New section in Darling/README.md ("Level 2: agents change mute rules and custom alert rules"). Each tool description starts with a short agent-arm note ("Agent arm: needs agent_action, request's server only.").

Also changed: the instructions census (DarlingMcpInstructions.ToolCensus) no longer reads the seven agent tools as "writes on"; under the investigations arm it says the write tools are off and counts the seven as served to an agent.

New DI seat DarlingAgentActionSeat (holds the served policy; registered in ConfigureMcpServices, so a client can never supply it). Pin in McpServiceParameterDiSeatCensusTests updated for the new type.

tools/list numbers

No new tool names: the catalog is unchanged (182). Under the writes arm the served set is unchanged. Under the investigations arm tools/list gains exactly the seven names, each carrying one new schema parameter, agent_action (the seat and the logger are DI services, never served to a client). The writes-arm tools/list tests pass unchanged.

Test plan

  • New Darling/Darling.Tests/AgentLevel2MuteRuleToolTests.cs: AgentLevel2ToolPolicyTests (served set is exactly the seven writes; update_alert_settings, set_notification_route_enabled, delete_notification_route, mute_analysis_finding, Custom Views, edit_server, tag tools not offered; no served action tool has an alerts_enabled, self_alerts or exclusion parameter; every self alert *Metric constant protected except the nine; brief text at Level 1 and 2; action_proposals refused; census text) and AgentLevel2ToolLiveTests (live Postgres, through the real tool methods: attributed create; mute_needs_metric x3; other_server for a create, update, delete and enable; self_monitor_metric on five protected names and on update/enable; allowed uncovered names; rule_scope x5 on create and before/after on update and delete; gate codes agent_action_required, claim_not_live, actions_off, not_level_2 through the tool; writes arm unchanged).
  • Ran (rig PG on a throwaway cluster, UTC): the new classes plus McpServiceParameterDiSeatCensusTests, DarlingMcpWritePolicyTests, DarlingMcpInstructionsBudgetTests, McpToolsListBudgetTests, ViewerNotificationRouteActTests (updated: the Act item is now enabled), AgentActionGateTests, AgentActionGateLiveTests, AgentLevel2StoreTests, InvestigationBriefComposerTests, DarlingMcpAlertToolsTests, DarlingMcpCustomAlertToolsTests, McpUnknownArgumentGuardTests, McpPayloadContractCensusTests, DocCommentHygiene, ReadAdmissionCoverageCensusTests: Total 410, Failed 0. Build 0 warnings.
  • Full suite not run (laptop rule): CI decides. Lite.Tests not run: Lite is untouched, and the composer's Level 1 output is unchanged.
  • Darling web UI smoke is red on dev for an unrelated reason (Darling web UI smoke is red on every PR since 06:00Z on 2026-10-16: the four page baselines no longer match dev #5929); no web page changed here, so none of the four baselines is affected.

Decisions and things to check

  • Journal atomicity (rewritten in round 1). The change, the gate's re-check (claim_not_live, not_level_2, other_server, with the request row held FOR SHARE) and the action_done row are one transaction. The write hooks now receive the gate's connection and transaction and the stores run on them (PgMuteRuleStore and CustomAlertRuleStore take an ambient connection and transaction, through the new PgStoreScope; nothing commits but the gate). So a change is in force if and only if its action_done row exists. The one step that is not inside it is the custom rule delete's best-effort resolution of open incidents, which writes on its own connections before the delete, exactly as the web delete does. A write that fails (a duplicate name, a stale version, a rule re-scoped since it was read) rolls back and the gate journals action_failed on a fresh connection; the row now carries the answer the agent was handed, not a constant.
  • For custom-rule tools the gate's target is the request's own server (read from the request row), and rule_scope is what judges the rule. For mute tools the target is the mute's server_id, so a mute on another server answers other_server from the gate itself.
  • mute_needs_metric on a create with no server_id or no metric is answered before the live checks and journaled as a refusal (once per request, tool, code and hour).
  • Custom rule enable/disable: the brief says "disable and delete are allowed", so update_custom_alert_rule may set enabled either way on a rule in scope; no other enable flag is reachable.
  • No Lite write path, no migration, nothing under deprecated/. Sibling PRs (Per-server threshold overrides in Darling #5916, Agent changes list with one-click undo and the switch #5917, People can give feedback on investigation reports: store, web tab, false-positive counts (#5913) #5926) will touch the same README, the DI pin list and the census texts; my additions are self-contained so the merges are additive.

CHANGELOG entry

SECTION: Added
ENTRY: - Agents at Level 2 can change mute rules and custom alert rules, journaled ([#5934]) - An alert route set to Act (Level 2) now lets an agent with a live claim mute one metric on the request's server and create, change, disable or delete a custom alert rule scoped to that server. Every change needs a reason and evidence, is stamped with the agent, and is journaled with the before and after rule; the service's own alerts cannot be muted and writes stop once the report is submitted. The Viewer route editor offers the Act level.
REF: [#5934]: #5934

Review round 1

The CI red and the review's findings, each with what changed.

CI red: DarlingMcpAlertToolsSurfaceAndSqlTests.ParamContract_MatchesContract for the four mute write tools. The optional agent_action parameter is last on each tool; the contract pin in DarlingMcpAlertToolsTests.cs now lists it for create_mute_rule, delete_mute_rule, set_mute_rule_enabled and update_mute_rule. A behaviour pin, no new census.

Three more tests pinned the old shapes and failed locally, and are fixed with the same change: MuteRulesPageTests.EveryRuleFieldThePageReads_IsOneTheToolReturns and MuteRuleClientAppTests.TheMcpToolsNameTheClientAppParameter_InCreateAndTheUpdateFieldList (both searched the tool source for a signature text that changed: BuildMuteRulePayload is now internal, and the create tool's list no longer ends at external_source), and DarlingMcpInvestigationToolsTests.ResolveForStart_WritesOff_InvestigationsOn_WithAToken_ServesThem (asserted create_mute_rule is not offered under the investigations arm).

  1. update_mute_rule head over 620. Fixed in DarlingMcpAlertTools.cs. The arm note is now "Agent arm: needs agent_action, request's server only." (54 characters, was 84) on all seven tools, and the head drops ", whatever changes" and "(nothing to write)". The head keeps every phrase McpToolGuideHeadsAlertingTests pins. McpToolGuideTests.EveryConvertedHead_StaysAtOrUnder620Characters passes.
  2. update_custom_alert_rule head over 620. Fixed in DarlingMcpCustomAlertTools.cs: the same shorter note, "a rule" is "an existing rule" again, and the validation sentence is cut to "An invalid definition changes nothing." (the guide tail already says the rest). The head still names update_custom_view (pinned by McpToolGuideHeadsCustomAlertTests).
  3. Investigations arm asserts no write is served. DarlingMcpInvestigationToolsTests.Annotations_ClaimAndSubmit_... now pins the exact seven served writes (literal list), that update_alert_settings, mute_analysis_finding, the route tools, the Custom View tools, the server tag tools and the server admin tools are not served, and that the seven are not read-only and are flagged destructive. ResolveForStart_WritesOff_InvestigationsOn_WithAToken_ServesThem got the same correction (the seven offered, the others not).
  4. Attribution suffix accumulates. DarlingMcpAgentActions.WithSuffix strips any trailing [agent:<id> request <uuid>] (this agent's, another's, or several stacked) before it appends the current one. Tests: WithSuffix_PutsExactlyOneAttributionSuffix_OnTheReason (null, blank, already suffixed, another agent's, doubled, a bracket mid-text) and the live ASecondUpdate_NeverStacksTheAttributionSuffix_WhetherTheReasonIsOmittedOrSentBackWhole (an update with no reason, then the read-modify-write that sends the whole suffixed reason back).
  5. A succeeded change journaled action_failed / the write was not in the gate's transaction. The seven write hooks now take the gate's connection and transaction. PgMuteRuleStore and CustomAlertRuleStore got an internal constructor over an ambient connection and transaction (new PgStoreScope: owns nothing, commits nothing, rolls nothing back); CustomAlertEvaluator.ResolveAndDeleteRuleAsync has an overload over a given store. The re-checks moved into the transaction and tightened: the mute recheck locks the stored row FOR UPDATE and compares server_id AND metric_name with the before image (StillTheSameMuteAsync, so a mute re-pointed at a protected metric is refused as conflict); the custom update and delete re-read the stored definition FOR UPDATE and re-run the rule_scope rule on it (StillInScopeAsync). AgentToolOutcomeException now carries the answer as its message, so the action_failed row says why. The "journal atomicity" paragraph under Decisions is rewritten. Tests (live): AChangeThatSucceeds_IsJournaledActionDone_WithItsAfterImage_AndNeverActionFailed, AWriteThatFails_RollsBackWithTheGatesTransaction_AndJournalsActionFailed_WithTheRealRefusal, TheMuteRecheck_InTheGatesTransaction_RefusesARuleRePointedSinceItWasRead, TheCustomRuleRecheck_InTheGatesTransaction_RefusesARuleRescopedSinceItWasRead. The review's narrower point (StillOnServerAsync re-read only ServerId; the custom paths had no re-read) is covered by the last two.
  6. Trap The MCP trend reader's tier ladder and hourly SQL were pinned EQUAL to the Storage definitions #3666 moved them to, which fails only after two copies drift — they are now aliases; and the Query Store duration chart, the one Performance Trends chart left untitled, says what served it and where the rollup's floor cut the window's head (#3653) #3684, a second copy of the tool names. One definition: InvestigationRules.AgentActionToolNames in PerformanceMonitor.Common. DarlingMcpWritePolicy.AgentActionTools is built from it, InvestigationBriefComposer joins it into allowed_actions, and Level2Tools is deleted. A tool added to the list (Per-server threshold overrides in Darling #5916) is served and named with no second edit. The tests keep their literal rosters on purpose: they are the behaviour pin of what is served (TheInvestigationsArm_ServesExactlyTheSevenActionTools_OfTheWrites and the new literal in item 3), not a copy of production state.
  7. A definition that does not parse was refused as rule_scope. The after image of a create or update whose definition does not parse is now refused as agent_action_invalid, naming the definition field and the parse error, with no word about scope (RuleScopeRefusalAsync). A stored rule (the before image) that does not parse stays rule_scope, since the agent cannot fix it. Test: ACustomRuleDefinitionThatDoesNotParse_IsRefused_AgentActionInvalid_NamingTheField_NotRuleScope (create and update). Also from the same comment: RequestServerTargetAsync now sets McpCommandDeadlines.ReadSeconds and passes the tool's ct.
  8. Two <summary> elements on MuteRuleAfterUpdate. The BuildMuteRuleUpdate doc block moved back above BuildMuteRuleUpdate; each method has one summary.
  9. Instructions census parenthetical (the review's last, non-blocking finding). DarlingMcpInstructions.ToolCensus now says mcp.allowWrites with a token adds writeCount - agentActionServed, matching the head of the sentence. It does not say the writes arm also drops the two investigation tools (the review's second half): that is a wording change to a budgeted text and it is left as is.

README: the Level 2 section states the one-transaction rule, the suffix rule and the agent_action_invalid refusal.

Test run (rig PostgreSQL on a throwaway cluster in UTC, stopped afterwards): AgentLevel2*, AgentActionGate*, AgentLevel2StoreTests, DarlingMcpAlertTools*, McpToolGuide*, DarlingMcpInvestigationTools*, *ToolsList*, DarlingMcpCustomAlertTools*, DarlingMcpCustomViewTools*, InvestigationBriefComposer*, DarlingMcpInstructions*, DarlingMcpWritePolicy*, *MuteRule*, *CustomAlert*, *Investigation*, *Viewer*, *Notification*, McpUnknownArgumentGuard*, DocCommentHygiene, every *Census* and *Ratchet* class: Total 5446, Failed 0, Skipped 1. Build 0 warnings. Full suite not run (laptop rule); Lite.Tests not run (Lite untouched).

CI fix (after round 1)

Claude posting for Erik Darling.

Darling PG shard 4 was red on five tests. Two files of pins, one source file of commands.

Darling/Darling.Tests/AgentActionGateTests.cs (AgentActionPolicyTests, three failures). They pinned yesterday's truth: no write tool is offered under the agent arm. This PR offers the Level 2 tools there, so:

  • TodaysActionSet_LeavesEveryWriteOffUnderTheAgentArm (create_mute_rule, update_custom_alert_rule) is now EveryWrite_IsOffUnderTheAgentArm_UnlessItIsALevel2ActionTool (line 69). Still a Theory over write tools; the expected value is ActionToolNames.Contains(tool), where ActionToolNames (line 35) is built from InvestigationRules.AgentActionToolNames, never a copied list. Every write outside that set (update_alert_settings, edit_server, and now also delete_mute_rule as an in-set row) stays pinned to its set membership, so a write that is not offered under the agent arm stays pinned off.
  • BothArms_AreTodaysBehaviour is now BothArms_OfferALevel2ActionToolUnderTheAgentArm_AndEveryOtherWriteOnlyUnderTheWritesArm (line 78); the create_mute_rule row's agent-arm expectation is true. update_alert_settings stays false on the agent arm.
  • No census, roster or source-text test added.

McpReadCommandTimeoutTests (two failures). Both scans found real misses in DarlingMcpAgentActions.cs:

  • EveryMcpAndWebReadCommand_SetsAnExplicitDeadline: the servers.server_name lookup in RuleScopeRefusalAsync set no CommandTimeout, so it inherited Npgsql's 30 s. A real defect, not a scan quirk.
  • EveryCommandInScope_IsBuiltAgainstTheStore_NotAMonitoredTarget: the three commands on the gate's connection and transaction (StillTheSameMuteAsync, StillInScopeAsync, the server_name lookup) used new NpgsqlCommand(sql, connection, transaction). The scan only accepts postgres.CreateCommand or a connection opened from postgres.OpenConnectionAsync in the same file, and the gate (a different file) opens that connection, so the receiver was unrecognised.
  • Fix, no test touched and no allowlist entry added: the three commands moved into the stores that already take the gate's connection and transaction and are outside the scanned surface. PgMuteRuleStore.LockServerAndMetricAsync (PgMuteRuleStore.cs:163), CustomAlertRuleStore.LockDefinitionAsync (CustomAlertRuleStore.cs:281) and CustomAlertRuleStore.StorageServerNameAsync (CustomAlertRuleStore.cs:290). Each sets an explicit CommandTimeout (the stores' own existing deadline constants). DarlingMcpAgentActions.cs (lines 231, 357, 396) now calls them on the same connection and transaction, so the FOR UPDATE rechecks stay in the gate's transaction (round 1's blocker fix is intact) and the answers (not_found, scope refusal) are unchanged.

Verified locally (0 warnings): AgentActionPolicyTests, McpReadCommandTimeoutTests, and every class matching *AgentAction*, *AgentLevel2*, *MuteRule*, *CustomAlertRule*, *InvestigationTool*, *McpServiceParameter*, *Census* (including SlowClassCensusTests), *Ratchet*, *Pin*, with the throwaway PostgreSQL rig set (the Level 2 live classes ran): Total 1604, Failed 0, Skipped 2. The full suite was not run; CI decides the rest. The rig is stopped.

Merge of dev (#5937)

Claude posting for Erik Darling.

Merged origin/dev (practice requests, #5937) into this branch as one merge commit (2ca6a4c). Two files conflicted.

  • McpServiceParameterDiSeatCensusTests.cs: both sides added a served DI seat. The pinned list now holds both, DarlingAgentActionSeat (this PR) and InvestigationReadPolicy (Practice requests with a score per agent #5918), so the pin went from 12 to 14 distinct types. Both comment sentences are kept.
  • PgInvestigationBriefSourceReader.cs: both sides added a column to RequestSql and both read it at ordinal 12 (this PR autonomy_level, dev practice_of), so a plain keep-both would have read a smallint as a Guid. The select list is now ..., r.first_dedup_key, r.autonomy_level, r.practice_of; autonomy_level stays at ordinal 12 and practice_of moves to ordinal 13. Dev's EarlierReportsSql and PriorLinksSql practice filters (LivePracticeRequestsSql) are untouched.
  • Auto-merged files checked: DarlingMcpHostService.cs registers both seats, README.md has no duplicated heading, and the composer and sources keep both the AutonomyLevel (level 2) and Practice branches.

Verified: dotnet build of Darling.Tests (builds the service and storage): 0 warnings, 0 errors. Darling.Tests classes McpServiceParameterDiSeatCensusTests, InvestigationBrief*, PgInvestigationBriefSourceReader* (live), InvestigationPractice*, AgentAction*, AgentLevel2* (live), and every *Census*, *Ratchet* and *Pin* class, on a throwaway PostgreSQL rig (stopped after): Total 1291, Failed 0, Skipped 2 (the two DarlingPgRuntimeVersionPinTests cases gated on DARLING_TEST_PGRUNTIME). SlowClassCensusTests did not time out. No full suite; CI decides the rest. No new tests added.

…5915)

The mute and custom alert rule tools join the agent arm through the one gate, with the mute and rule_scope kind rules, attribution on the rows, and Level 2 offered in the brief and the Viewer route editor.
@erikdarlingdata
erikdarlingdata marked this pull request as ready for review October 16, 2026 10:39

[McpServerTool(Name = "update_mute_rule"), Description(
"Edits an existing alert mute rule IN PLACE by its id — changes shared alert configuration. PARTIAL " +
"Level 2 (investigations arm): needs agent_action; changes only the request's server. Edits an existing alert mute rule IN PLACE by its id — changes shared alert configuration. PARTIAL " +

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update_mute_rule's served head now breaks the 620-character cap.

McpToolGuideTests.EveryConvertedHead_StaysAtOrUnder620Characters (Darling/Darling.Tests/McpToolGuideTests.cs:51) fails Assert.True for every tool whose description carries <<GUIDE>> and whose served head (pointer included) exceeds 620. This description is converted (marker at DarlingMcpAlertTools.cs:1565), so it is in scope.

Measured from the tree (lines 1559–1564 are the head; each source line is 8 spaces + " + content + " + + + CRLF, so content = line length − 14):

line length content
1559 198 184
1560 115 101
1561 113 99
1562 111 97
1563 110 96
1564 74 60

Head = 637, plus McpToolGuide.GuidePointer (31) = 668 > 620. The 84-character "Level 2 (investigations arm): needs agent_action; changes only the request's server. " prefix is what pushes it over: without it the head was 553 and served 584.

update_custom_alert_rule is over too (682 — separate comment). create_mute_rule lands at 613 and set_mute_rule_enabled at 568, so those pass, but create_mute_rule now has 7 characters of headroom.

The test plan lists McpToolsListBudgetTests (whose cap is the looser 1,000) but not McpToolGuideTests, which is where the 620 target lives — that is why the rig run came back green.


[McpServerTool(Name = "update_custom_alert_rule"), Description(
"Partial update of an existing rule: send only the fields to change (name, description, definition, enabled); an " +
"Level 2 (investigations arm): needs agent_action; changes only the request's server. Partial update of an existing rule: send only the fields to change (name, description, definition, enabled); an " +

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update_custom_alert_rule's served head now breaks the 620-character cap too.

Same failure as update_mute_rule: this description is converted (<<GUIDE>> marker at line 201), so McpToolGuideTests.EveryConvertedHead_StaysAtOrUnder620Characters applies.

Lines 195–200 are the head; content = line length − 14 (8 spaces + two quotes + + + CRLF):

line length content
195 211 197
196 123 109
197 125 111
198 128 114
199 126 112
200 23 9

Split does description[..at].TrimEnd(), so the trailing space on "its cap. " comes off: head = 651, plus the 31-character guide pointer = 682 > 620. Without the 84-character Level 2 prefix the head was 567 and served 598.

Two tools over the cap, so this is one red test, not two — but the fix has to shorten both heads (or move the Level 2 sentence into the <<GUIDE>> tail, where there is 24,000 characters of room and no cap pressure).

Comment on lines +117 to +126
internal static readonly IReadOnlySet<string> AgentActionTools = new HashSet<string>(StringComparer.Ordinal)
{
"create_mute_rule",
"update_mute_rule",
"delete_mute_rule",
"set_mute_rule_enabled",
"create_custom_alert_rule",
"update_custom_alert_rule",
"delete_custom_alert_rule",
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filling this set breaks an existing assertion that no write tool is served under the investigations arm.

Darling/Darling.Tests/DarlingMcpInvestigationToolsTests.cs:120 applies the investigations arm to the real served collection and then asserts, at line 141:

DarlingMcpWritePolicy.Apply(all, new DarlingMcpWritePolicy.Effective(Writes: false, ServerAdmin: false, Investigations: true));
var byName = all.ToDictionary(t => t.ProtocolTool.Name, t => t.ProtocolTool.Annotations!, StringComparer.Ordinal);
...
Assert.DoesNotContain(byName.Keys, DarlingMcpWritePolicy.IsWrite);

That held while AgentActionTools was empty: Offers reached if (IsWrite(toolName)) return Writes; and dropped every write. With the seven names here, Offers now short-circuits at if (Investigations && IsAgentAction(toolName, agentActionTools)) return true; (line 177), so byName.Keys contains create_mute_rule and its six siblings — all of them in WriteTools — and Assert.DoesNotContain fails.

The assertion is a secondary line in a test named for annotations, which is probably why it was missed; it is not in the test plan's run list either. It needs to become "no write outside AgentActionTools", in the same shape ToolCensus already uses at DarlingMcpInstructions.cs:92. Worth checking the annotation half of that test at the same time: the seven are destructiveHint: true and readOnlyHint: false under the investigations arm, which is correct but newly reachable on that arm.

Comment on lines +238 to +246
if (before is null)
return notFound!;
if (body is null)
return DarlingMcpAlertTools.Outcome("invalid", "changes_json must be a JSON object of the mute-rule fields to change.");
/* The reason carries the attribution suffix: what the agent sent, else the stored reason, then the suffix. */
string? current = before.Reason;
if (body.TryGetPropertyValue("reason", out var sent) && sent is JsonValue v && v.TryGetValue(out string? text))
current = text;
body["reason"] = WithSuffix(current, action!);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The attribution suffix accumulates: a second agent update stores … [agent:a request g] [agent:a request g].

current falls back to before.Reason when the body sends no reason, and WithSuffix appends unconditionally (reason.Trim() + " " + ReasonSuffix(action), line 112) with no check that the reason already ends with that suffix.

Two ways this fires:

  1. A second agent change on the same rule that touches another field — update_mute_rule(rule_id, "{\"expires_at_utc\":\"2026-11-01T00:00:00Z\"}"). before.Reason is already "known batch [agent:agent-a request 7f3…]", so the stored reason becomes "known batch [agent:agent-a request 7f3…] [agent:agent-a request 7f3…]".
  2. The read-modify-write an agent is most likely to do: get_mute_rules returns reason with the suffix, the agent edits one word and passes the whole string back, and the suffix doubles even though it sent reason explicitly.

The Viewer's mute list and get_mute_rules both show that field, and InvestigationRules.MaxAgentJournalRows only bounds changes per request — a long-lived mute touched across several requests keeps growing. AnUpdate_OnTheRequestsServer_KeepsTheSuffix_AndARefusedUpdateChangesNothing does exactly one successful update, so it does not see this.

The README's own wording is singular: "The reason ends with [agent:<id> request <uuid>]". Strip a trailing [agent:… request …] (or return reason unchanged when it already ends with this action's suffix) before appending.

Comment on lines +185 to +193
async (_, _, _) =>
{
var answer = await DarlingMcpAlertTools.CreateMuteRuleOver(new PgMuteRuleStore(postgres), server_name, metric_name,
database_pattern, query_text_pattern, wait_type_pattern, job_name_pattern, WithSuffix(reason, action!), expires_at,
server_id, id => DarlingMcpAlertTools.MonitoredServerDisplayNameAsync(postgres, id), client_app_pattern,
external_source);
RequireSuccess(answer);
return answer;
}, ct);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A change that succeeded is journaled action_failed, not left as "result unknown".

All seven write hooks discard the connection and transaction the gate hands them (async (_, _, _) => here, and the same at lines 258, 278, 299, 357, 397, 418) and write on postgres instead, so the change commits on its own connection and is durable the moment the hook returns. Then RunAgentActionAsync inserts action_done and commits — and if either step fails, the catch-all at InvestigationRequestStore.AgentAction.cs:211-222 opens a fresh connection and writes:

await JournalReturningAsync(..., "action_failed", actor, "error",
    new JsonObject { ["called_id"] = calledId, ["error"] = ex.Message }...);
await transaction.CommitAsync(CancellationToken.None);
throw;

So the journal says the mute rule was not created while the rule is in force — the opposite of a missing result. The PR body's "Decisions" section says the failure mode is "the action_called row stands with no result, which the gate already reads as 'result unknown'"; the tree says action_failed. That is the audit trail the README sells as the Level 2 guarantee ("A change journals action_called … and then action_done … or action_failed"), so a reader cannot tell a refused change from a committed one.

Two smaller consequences of the same discard:

  • The body also says "the write runs inside the gate's re-check transaction (row held FOR SHARE)". It does not — only checks 3, 5 and 6 run there; the write is on a separate connection, so the FOR SHARE lock does not cover it and a concurrent submit/release can land between the re-check and the write.
  • AgentToolOutcomeException (not_found, conflict, invalid from RequireSuccess / CustomRuleAnswer) also travels through that catch-all, which journals reason: "error" with ex.Message — the constant string "agent tool outcome". The actual refusal the agent was handed never reaches the journal.

A narrower version of the same gap: StillOnServerAsync (line 209) re-reads only ServerId, not MetricName, so a stored mute re-pointed at a protected metric between the kind rule and the write is still enabled; the custom-rule paths have no re-read at all before store.UpdateAsync / ResolveAndDeleteRuleAsync.

Comment on lines +50 to +53
/// <summary>The Level 2 tools a brief names (#5915): mutes and custom alert rules, on the request's server only.</summary>
internal const string Level2Tools =
"create_mute_rule, update_mute_rule, delete_mute_rule, set_mute_rule_enabled, create_custom_alert_rule, update_custom_alert_rule, delete_custom_alert_rule";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trap #3684: this is a second copy of AgentActionTools, and nothing pins the two equal.

Two copies of one definition pinned EQUAL to each other fail only after BOTH have drifted (#3684). Alias, do not copy.

Level2Tools hand-writes the same seven names DarlingMcpWritePolicy.AgentActionTools holds (DarlingMcpWritePolicy.cs:117-126), and AgentLevel2ToolPolicyTests.ActionTools is a third copy. The test roster is at least checked against the real served set (TheInvestigationsArm_ServesExactlyTheSevenActionTools_OfTheWrites), so it fails loudly on drift; this constant is checked by nothing. ALevelTwoBrief_NamesTheTools_… only asserts the string contains create_mute_rule, so six of the seven could go missing silently.

The copy is structural — Common cannot reference the Darling service assembly — but the drift is already scheduled: the class doc on AgentActionTools says "the threshold override tools add to it (#5916)". When #5916 lands, a Level 2 brief will under-name the tools the agent is actually served, and the agent reads allowed_actions as its contract.

The seam that exists for this is InvestigationBriefTools (already a composer parameter, already supplied per SKU as DarlingInvestigationBriefTools.Tools): carry the action-tool names through it from the one Darling set, instead of a literal here. Failing that, a behaviour test in Darling.Tests that the brief's allowed_actions names every member of AgentActionTools would at least fail on the defect.

Comment on lines +324 to +339
internal static async Task<AgentActionRefusal?> RuleScopeRefusalAsync(NpgsqlConnection connection, NpgsqlTransaction transaction,
string definitionJson, int requestServerId, string which, CancellationToken ct)
{
var (definition, _) = CustomAlertRuleDefinition.TryParse(definitionJson);
string? storageName;
await using (var command = new NpgsqlCommand("SELECT server_name FROM servers WHERE server_id = $1", connection, transaction))
{
command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Integer, Value = requestServerId });
storageName = await command.ExecuteScalarAsync(ct) as string;
}

var inScope = definition is not null && storageName is not null
&& definition.ScopeMode == CustomAlertScopeMode.Servers
&& definition.ScopeServers.Count == 1
&& string.Equals(definition.ScopeServers[0], storageName, StringComparison.OrdinalIgnoreCase);
return inScope

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: a definition that does not parse is refused as rule_scope, not invalid.

TryParse failing leaves definition null, and inScope is then false, so the agent is told "The rule's scope (after) must be exactly the request's server: scope.mode 'servers' with that one server name…" for a body that was malformed JSON or failed validation. The write hook does have the right answer (throw new AgentToolOutcomeException(Outcome("invalid", error …)), line 356) but the kind rule runs first, so it is unreachable on a create. An agent that follows the refusal will keep rewriting a scope that was never the problem.

Separating the two reads cleanly: refuse invalid when definition is null, and rule_scope only when it parsed and the scope is wrong.

Also here: RequestServerTargetAsync (line 309) builds its command with postgres.CreateCommand and then calls ExecuteReaderAsync() / ReadAsync() with no cancellation token and no CommandTimeout, unlike MonitoredServerDisplayNameAsync next door, which sets McpCommandDeadlines.ReadSeconds. The ct the tool was handed is in scope.

Comment on lines +1750 to +1755
/// <summary>
/// The mute rule an <c>update_mute_rule</c> body would leave stored: <paramref name="before"/> cloned with the body's
/// changes applied, through the same parser the tool runs (#5915). Null with an <paramref name="error"/> when the body
/// is invalid. The Level 2 kind rule reads the after image from this, so it cannot disagree with the write.
/// </summary>
internal static MuteRule? MuteRuleAfterUpdate(MuteRule before, JsonObject body, out string? error)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: the new method was inserted between BuildMuteRuleUpdate and its doc comment, so it now carries two <summary> elements.

The long block above (lines 1730–1749, "turns a partial update body … into appliers over a MuteRule clone", the explicit-null rule, the four non-editable keys) documented BuildMuteRuleUpdate. MuteRuleAfterUpdate landed between them, so that method now has two <summary> tags in its doc — valid XML, no diagnostic, but the generated docs describe MuteRuleAfterUpdate with BuildMuteRuleUpdate's contract — and BuildMuteRuleUpdate at line 1767 is left undocumented.

Moving the new method (with its own summary) below BuildMuteRuleUpdate restores both.

Comment on lines 104 to 110
if (!writesServed)
off.Add($"the write tools are off, so the {writeCount + adminCount} that change monitoring configuration are not served " +
off.Add($"the write tools are off, so the {writeCount + adminCount - agentActionServed} that change monitoring configuration are not served " +
$"(`{DarlingMcpWritePolicy.WritesSetting}` with a token adds {writeCount}, `{DarlingMcpWritePolicy.ServerAdminSetting}` " +
$"{adminCount} more)");
$"{adminCount} more)" + (agentActionServed > 0
? $"; the {agentActionServed} mute and custom-rule tools are served only to an agent with an `agent_action` at Level 2"
: string.Empty));
else if (!adminServed)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: the parenthetical in the same sentence is now off by seven on the investigations arm.

The head count is corrected (writeCount + adminCount - agentActionServed), but the clause that follows still reads "mcp.allowWrites with a token adds {writeCount}" — and writeCount counts all of them, including the seven already served. Turning mcp.allowWrites on from this state adds 15, not 22, and (ruling R2, Resolve at DarlingMcpWritePolicy.cs:197) also removes the two investigation tools, so the sentence overstates the gain and omits the loss. An agent reading it to decide what to ask the operator for gets the wrong number.

writeCount - agentActionServed in the parenthetical would match the head of the sentence.

claude[bot]
claude Bot previously requested changes Oct 16, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: CHANGES REQUESTED

Two existing tests go red, neither in the test plan's run list; one correctness bug in the attribution suffix; and the "Decisions" account of the journal-failure mode is the opposite of what the tree does. The security shape of the agent arm reads correctly. Details in the inline comments.

Claims

  • VERIFIED — The seven tools are in AgentActionTools and served under the investigations arm — DarlingMcpWritePolicy.cs:117-126, short-circuited in Offers at :177
  • VERIFIED — other_server covers a stored rule with no server_id — DarlingMcpAgentActions.cs:799-802 plus gate check 6 at InvestigationRequestStore.AgentAction.cs:290
  • VERIFIED — self_monitor_metric is the family plus every *Metric const except the nine, by reflection — DarlingMcpAgentActions.cs:658-683. Narrower than claimed: the predicate is f.IsLiteral, so a future alert declared static readonly, or named without the Metric suffix, is not protected without an edit here
  • VERIFIED — rule_scope judged on before and after by storage name — :387-388, the same name the evaluator uses (CustomAlertRuleDefinition.cs:104-106)
  • VERIFIED — Brief reads the stamped autonomy_level, Level 1 byte-for-byte as before — InvestigationBriefComposer.cs:127 with the default at InvestigationBriefSources.cs:19; the column is smallint (InvestigationTables.cs:91), so GetInt16(12) at PgInvestigationBriefSourceReader.cs:219 matches its ordinal
  • VERIFIED — Seat and logger are DI services a client cannot supply — DarlingMcpHostService.cs:1288, pinned at AgentLevel2MuteRuleToolTests.cs:92-93
  • VERIFIED — Route editor offers an enabled Act item and tooltip — NotificationRouteEditDialog.xaml.cs:73, .xaml:121; the save path already admitted 2 (ViewerDataService.NotificationRoutes.cs:270)
  • VERIFIED — No new tool names; Lite untouched; mute rules have no tag-scope column — no [McpServerTool(Name = …)] added, no file under Lite/, no tag field in PerformanceMonitor.Notifications/MuteRule.cs
  • VERIFIED — The census no longer reads the seven as "writes on", and the writes-arm tests are unaffected — DarlingMcpInstructions.cs:92-94, :105-107; the writes-arm tests build from Effective.Off / writes-only, where agentActionServed is 0
  • UNVERIFIED — "the catalog is unchanged (182)" — needs runtime reflection over the registered tool classes
  • UNVERIFIED — "Ran … Total 410, Failed 0. Build 0 warnings." — needs the rig and a build; the run list omits the two classes holding both failures below
  • UNVERIFIED — "Darling web UI smoke is red on dev for an unrelated reason (#5929)" — needs the CI history
  • REFUTED — "a failed action_done leaves the action_called row with no result … 'result unknown'" — the catch-all at InvestigationRequestStore.AgentAction.cs:211-222 journals action_failed, denying a change that is live
  • REFUTED — "the write runs inside the gate's re-check transaction (row held FOR SHARE)" — all seven hooks discard the supplied connection and transaction (DarlingMcpAgentActions.cs:185, 258, 278, 299, 357, 397, 418) and write on the data source

Riskiest lines

  1. Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpWritePolicy.cs:117 — the one line making seven WriteTools reachable by a client holding only the investigations token — pinned by TheInvestigationsArm_ServesExactlyTheSevenActionTools_OfTheWrites, and what turns DarlingMcpInvestigationToolsTests.cs:141 red
  2. Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs:1185 (and :1425, :1486, :1600, DarlingMcpCustomAlertTools.cs:165, :228, :297) — IsGated is false for a null seat, so a served path reached without the seat writes ungated; only the source-text DI pin guards it — UNPINNED
  3. Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAgentActions.cs:246 — WithSuffix(current, action!) appends to whatever is stored, so a second change doubles the suffix — UNPINNED

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 — InvestigationBriefComposer.Level2Tools (:51-53) copies AgentActionTools (DarlingMcpWritePolicy.cs:117-126), nothing pins them equal, and that set's doc says #5916 will extend it
  • "Lite and Darling ship in parity … a fix to one SKU's reader, tool, alert or description without the counterpart's is a finding, not a nit" — does not apply — Level 2 is Darling-only by the existing ruling: Lite's store refuses to write it (Lite/Services/DuckDbNotificationRouteStore.cs:610) and Lite registers none of the seven (Lite/Mcp/McpAlertTools.cs:709 serves only the get_mute_rules read)
  • "Ruling (#5833): tests are load-bearing … never answer a trap with a test that reads source text" — does not apply — AgentLevel2ToolLiveTests drives the real tool methods against a scratch store and asserts rows and journal events (AgentLevel2MuteRuleToolTests.cs:275-533)
  • "A default interface member compiles a parity gap away (#3640)" — does not apply — no interface member added; the new optional parameters sit on static tool methods and fail closed without Investigations (DarlingAgentActionGate.cs:62-64)
  • "hours_back, limit, top, days_back are refused when out of range, never clamped" — does not apply — the seven gained one string parameter; no window or page argument changed
  • "A JSON number above 2^53 is rounded by every JavaScript client (#3487)" — does not apply — rule_id is echoed as a number at DarlingMcpAgentActions.cs:424, matching the existing shape, over a bigserial from 1

Divergence (blind read vs PR body)

  1. "Total 410, Failed 0" is scoped to classes that do not contain the failures. McpToolGuideTests.EveryConvertedHead_StaysAtOrUnder620Characters fails: update_mute_rule's served head is 637 + 31 = 668 and update_custom_alert_rule's is 651 + 31 = 682, both over 620, because the 84-character Level 2 prefix landed on heads already at 553 and 567. The body lists McpToolsListBudgetTests, whose cap is the looser 1,000, not McpToolGuideTests, where the 620 target lives. Separately DarlingMcpInvestigationToolsTests.cs:141 asserts exactly the invariant this PR changes, and that class is not in the run list either — "the writes-arm tools/list tests pass unchanged" is true as written, which is why the gap stayed invisible: the test that breaks is an investigations-arm test.
  2. The journal-failure mode is stated backwards, and the write does not run in the gate's transaction at all (see the two REFUTED claims).
  3. The suffix is described as singular and implemented as cumulative — a second update, or an agent that round-trips the reason it read from get_mute_rules, stores it twice.

Also in the diff but not in the body: Level2Tools in Common is a third copy of the agent-action list, which the body's own #5916 note will break.

Reviewer checklist

  • Run Darling.Tests filtered to McpToolGuideTests and DarlingMcpInvestigationToolsTests — both should be red on this head
  • Build and confirm "0 warnings" still holds, in particular that the doubled <summary> at DarlingMcpAlertTools.cs:1749-1754 raises no diagnostic
  • On the rig: two successive successful update_mute_rule calls under one claim (the second sending only expires_at_utc), then read config_mute_rules.reason — the suffix should appear twice
  • On the rig: fail the action_done insert and read what the journal says about a mute rule that is present in config_mute_rules
  • Drive a real MCP client at the investigations arm: tools/list should advertise agent_action but neither agentSeat nor logger, against the live DI container
  • Click through the route editor: pick Act, save, reopen, confirm it reads back as Act with the limits boxes enabled

The agent writes now run on the gate's connection and transaction, so the re-check, the change and the action_done row are one commit.
Heads under the 620 cap, one definition of the action tool names, one attribution suffix per reason, an unparsed definition refused as agent_action_invalid, and the tests that pinned the old shapes updated.
@erikdarlingdata
erikdarlingdata dismissed claude[bot]’s stale review October 16, 2026 11:37

Claude posting for Erik Darling. All eight round-1 findings and the Guard red are answered in the body's Review round 1 section (head 4ea910e); the next round reviews that head.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant