Skip to content

Darling MCP write tools: honest statuses, hash validation, named missing arguments (#6455) - #6509

Merged
erikdarlingdata merged 6 commits into
devfrom
fix/6455-write-tool-wording
Oct 23, 2026
Merged

erikdarlingdata merged 6 commits into
devfrom
fix/6455-write-tool-wording

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Claude posting for Erik Darling.

Fixes #6455.

What changed

Seven findings, in the issue's order:

  1. edit_server host change wording: the refusal for a move that keeps the stored password now reads "Changing the host, port or any other connection setting of this server needs its password sent with the change, because a saved password is only ever sent to the server it was saved for. Send the password together with the new settings." The rule (ServerConnectionRule, the store trigger) refuses a change of host, port, engine, database, read-only intent, auth, username, encryption mode, trust-certificate, multi-subnet failover or the Query Store replica, so the text names the host and port and says "any other connection setting". Same text in ServerConnectionRule.PasswordNeededOnMoveText (MCP, Viewer), the web admin.js, the store trigger in DarlingManagedRoles.cs and Darling/tools/provision-roles.sql. No numbered migration: the trigger is redefined by the role provisioning, which CREATE OR REPLACEs it on each run.
  2. dismiss_alert_history / restore_alert_history: status is now dismissed / restored only when a row changed. Keys that name no row answer not_found; rows already in the asked state answer unchanged. Counts and the undo are unchanged. Tool descriptions say so.
  3. Plan tools (force_query_store_plan, unforce_query_store_plan, evict_cached_plan, kill_session, SQL Server and PostgreSQL paths): a dry run with no agent_action still refuses (it signs in with the remediation login and reads the per-server switch, so it is gated like a write), but the refusal text now says why a dry run needs the claim. I took the issue's "or the refusal text says why" option; a dry run that reports the switch state without a claim would change the gate's order and is a design call.
  4. create_custom_alert_rule: the finding is partly wrong. A server scope already exists inside the definition (scope.mode all, servers, tag). The tool text now says so, with the JSON to use, and that no scope means every monitored server.
  5. update_alert_settings: updated_fields names the cooldown delivery.cooldown_minutes (the name used in the call and the read), whether the caller sent that or the email_cooldown_minutes alias. Other fields keep their names. The existing live test that asserted the old name is updated.
  6. assign_query_tag: a query_hash that is not 0x plus 1 to 16 hex digits, or a decimal that fits an unsigned 64-bit number (the long-query trace form), is refused as invalid/bad_key. unassign_query_tag is not changed, so a key stored before this check can still be removed.
  7. Missing required MCP argument: new McpUnknownArgumentGuard.RefuseMissingRequired, chained after Refuse in the shared call-tool filter. It reads the tool's advertised required list and answers invalid naming the argument, with the accepted parameters. It also covers a call with no arguments at all (the old guard skipped those). It is in PerformanceMonitor.Common, so Lite gets it too. I left Refuse unchanged so its single-argument probe tests keep their meaning.

Proof

  • New McpWriteToolWordingTests (21 cases): the dismiss/restore status rule, the cooldown field name, hash refusals through the real AssignQueryTag (refused before any store call), hash acceptance, and the dry-run refusal text for the four code paths' shared helper.
  • New in McpUnknownArgumentGuardTests: ACallWithoutARequiredArgument_IsRefusedNamingIt against the real registered update_custom_alert_rule (no version) and dismiss_alert_history (no arguments); ACallWithNoArguments_IsNotRefusedWhenNothingIsRequired.
  • McpAlertAdminLiveTests (rig): dismiss and restore of an unknown key answer not_found; restore of a not-dismissed row answers unchanged. DarlingMcpAlertToolsLivePostgresTests asserts delivery.cooldown_minutes in updated_fields.
  • Run: McpWriteToolWordingTests, McpUnknownArgumentGuardTests, McpWholeNumberArgumentTests, McpArgumentTypeTests, every *Census*, *Ratchet*, *WritePolicy* class: Total 659, Failed 0 (1 skipped). On a throwaway PostgreSQL rig: McpAlertAdminLiveTests (9), DarlingMcpQueryTagToolsLiveTests, DarlingMcpAlertToolsLivePostgresTests, DarlingMcpAlertToolsSurfaceAndSqlTests, PlanForceToolLiveTests, ServerActionGateTests: 0 failed. Lite: guard, whole-number, Census and Ratchet classes, Total 49, Failed 0.
  • Not run: the full suite and the pre-push gate (laptop override; CI decides).
  • A first version put the missing-argument check inside Refuse; four existing tests that probe one argument at a time failed, which is why it is a separate method.

Finding 1 proof (added)

  • ServerEditCoreTests.AMoveOfHostOrPort_OnASecretRow_AsksForThePasswordBeforeTheTest_WithNoProbeAndNoWrite (calls edit_server with a new host or port and no password) now also checks the refusal names the host and port and says why. ServerEditPasswordRuleViewerTests (MCP, Viewer and the core agree) and AdminServerEditBehaviourTests (web) pass with the new text.
  • Live, on a throwaway PostgreSQL from pg-runtime.zip: StoreServerPasswordRulesLiveTests gets the new text from the provisioned store trigger (SQLSTATE PW002), as the admin, the MCP role and the Viewer.
  • Run: those classes plus DarlingManagedRolesTests, ServerPasswordFromRequestTests, every *Census* and *Ratchet* class: Total 1079, Failed 0, Skipped 0. Not run: the full suite and the pre-push gate (laptop override; CI decides).

Left to others

  • Nothing: all seven findings are covered. (Finding 6 was Darling-only in the first push; Lite's assign_query_tag is now covered too, see the round 1 line below.)
  • Not run: no live MCP call through a real host for finding 7; the filter wiring is Instance only, covered through RefuseMissingRequired on the real tool schemas.

CHANGELOG entry

SECTION: Fixed

  • Darling MCP write tools say what they did and name what is missing ([Darling MCP write tools: unclear wording and missing validation #6455]) - dismiss_alert_history and restore_alert_history answer not_found or unchanged instead of dismissed/restored when no row changed; update_alert_settings names the cooldown delivery.cooldown_minutes; assign_query_tag refuses a non-hexadecimal query_hash; a call that leaves out a required argument is refused naming it (also in Lite) instead of "An error occurred invoking"; edit_server names the host, port and connection settings when it asks for the password again; the plan tools' refusal says why a dry run needs agent_action; create_custom_alert_rule documents the scope field in its definition.

CI fix

CI fix: test. QueryStoreComposeStampWriterGuardLiveTests.AnUnrelatedLongWriter_... asserted that exactly one backend in the scratch database holds an xid (SELECT count(*) FROM pg_stat_activity WHERE datname = current_database() AND backend_xid IS NOT NULL AND pid <> pg_backend_pid()), so any other backend that holds an xid at that moment (a TimescaleDB job worker or autovacuum in the throwaway database) made it read 2, as CI did. The PR's diff (MCP write-tool wording, unknown-argument guard, role provisioning comments) touches nothing this test reads: no compose-stamp, scratch-database or pg_stat_activity code. The assertion now scopes the count to the test's own writer (pid = writer.ProcessID), so it proves the writer holds an xid whatever else runs in the database. Every other assertion is unchanged. Merged origin/dev into the branch.

Local runs of the test on a throwaway rig (UTC): 3 of 3 pass at the PR head (with dev merged), 3 of 3 pass at origin/dev, so it does not fail locally either way; the cause comes from reading the test, not from reproducing it. Ran the CI-selected Darling classes, *Census*, *Ratchet*, *QueryStoreComposeStamp*, McpWriteToolWordingTests, McpUnknownArgumentGuardTests: Totals 946 / 553 / 591, Failed 0 (88 skipped: tests that need a SQL Server).

Bot review round 1 fixes: the bot was right that finding 6 reached Darling only. Lite's assign_query_tag still stored any text as a hash key (Lite/Mcp/McpQueryTagTools.cs, "hello" went in as typed). Fixed in about 30 lines of product code: the shape check moved from DarlingMcpQueryTagTools.IsHashText to one shared QueryHashIdentity.IsAssignableHash in PerformanceMonitor.Common (0x plus 1 to 16 hex digits, or up to 20 decimal digits that fit a ulong, and not the all-zero did-not-say hash), both products' assign paths ask it (requireHashShape, passed only by assign; unassign skips it on both), and Lite's refusal text and code (invalid / bad_key) are the Darling ones. The bot's non-blocking note that an all-zero hash (0, 0x0000000000000000) passed the check is taken: it is now refused on both. Lite.Tests McpQueryTagToolTests gained a nine-case behaviour test that assign refuses each bad hash and stores nothing, and one that unassign does not apply the refusal; the Darling refusal theory gained the two all-zero cases. The two stale comments the bot found are updated (McpUnknownArgumentGuard's contract paragraph now says the unknown-key check reads the keys that arrived and a missing required one is its own arm; the updated_fields comment in DarlingMcpAlertTools names the cooldown as the one qualified entry) and the stale "kept as typed" comment on the assign path is reworded. Ran: Darling.Tests AdminServerEditBehaviourTests, DarlingMcpAlertToolsTests, McpAlertAdminLiveTests (own PostgreSQL), McpUnknownArgumentGuardTests, McpWriteToolWordingTests, ServerEditCoreTests, QueryHashIdentity*, QueryTag*, McpToolsListBudgetTests, McpToolGuideTests and all Census and Ratchet classes (1189 passed, 0 failed, 0 skipped); Lite.Tests McpQueryTagToolTests, LiteQueryTag*, QueryHashIdentityTests, McpUnknownArgumentGuardTests, McpToolsListBudgetTests and all Census and Ratchet classes (205 passed, 0 failed).

@erikdarlingdata
erikdarlingdata marked this pull request as ready for review October 22, 2026 17:40
@erikdarlingdata
erikdarlingdata enabled auto-merge (squash) October 22, 2026 17:40

/* #6455: a NEW assignment must name something a query hash can be; unassign skips this so a key stored before the check
can still be removed. */
if (requireHashShape && !IsHashText(hash))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Parity: Lite's assign_query_tag is the same tool and does not get this check.

Lite/Mcp/McpQueryTagTools.cs:788-790 is the same BuildKey hash branch with no shape test — it still does QueryHashIdentity.CanonicalKey(hash) ?? hash and stores whatever arrived. So after this PR:

assign_query_tag {tag_id: 1, kind: "hash", query_hash: "hello"}
  Darling → {"status":"invalid","hints":{...},"code":"bad_key"}   (this hunk)
  Lite    → {"status":"assigned", ...}                            and a row the resolver can never match

That is exactly the silent-inert-assignment the check exists to stop, and Lite's own class doc promises the opposite: "Lite's twin of Darling's query-tag and query-tag-rule tools (#5981, #5982), under the same names, parameters, refusal codes and result shapes" (Lite/Mcp/McpQueryTagTools.cs:27). .github/REVIEW-TRAPS.md calls this shape out: "a fix to one SKU's reader, tool, alert or description without the counterpart's is a finding, not a nit".

Nothing in Lite's existing tests blocks the fix — every hash in Lite.Tests/McpQueryTagToolTests is already valid hex (0xABCD, 0x01, 0xAB, 0xabcdef).

Please also avoid a second copy of the predicate when you do it (#3684's "Alias, do not copy"): IsHashText reads only text, both SKUs already reference PerformanceMonitor.Common, and it sits naturally next to QueryHashIdentity.CanonicalKey in PerformanceMonitor.Common/QueryHashIdentity.cs — one definition called from both BuildKeys, with the refusal sentence shared too so the two tools cannot drift into refusing differently.

can still be removed. */
if (requireHashShape && !IsHashText(hash))
{
refusal = Refusal("invalid", "bad_key", "query_hash must be hexadecimal: 0x followed by 1 to 16 hex digits (for example 0x1A2B3C4D5E6F7081), or the decimal number the long-query trace stores.");

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, but the check stops one case short of its own doc ("a NEW assignment must name something a query hash can be"): an all-zero hash passes. IsHashText("0") → ulong.TryParse succeeds, IsHashText("0x0000000000000000") → all hex digits, so both are accepted, and CanonicalKey returns NULL for them (QueryHashIdentity.IsUnknown) so the key is stored as typed.

QueryHashIdentity is explicit that this is not a hash: "0 here means 'the engine did not say', never 'query number zero'" — and the comment immediately below this hunk still names it as such: "A hash that is not a hash at all (all zeros, or neither hex nor decimal) is kept as typed." An assignment to the did-not-say bucket labels nothing, the same outcome as "hello".

refusal = ... when requireHashShape && (!IsHashText(hash) || QueryHashIdentity.IsUnknown(hash)) would close it. While you are here, that comment on 524-525 is now half-stale for the assign path: "neither hex nor decimal" is no longer kept as typed, it is refused two lines above.

public static McpRequestFilter<CallToolRequestParams, CallToolResult> Instance =>
next => async (request, cancellationToken) =>
Refuse(request.Params, FindTool(request), RecordOf(request))
?? RefuseMissingRequired(request.Params, FindTool(request))

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: this arm contradicts the type's own contract paragraph, which the diff leaves standing.

Line 46-49, unchanged: "What it refuses, and what it deliberately does not. Only a key that is NOT a property of the tool's advertised schema. An OMITTED optional parameter is not an unknown key and never was — the guard reads the keys that arrived, never the ones that did not, so optionality and null handling are untouched."

As of this line the guard does read the keys that did not arrive. The optional half is still true, so it is a sentence to amend rather than delete — something like "an omitted OPTIONAL parameter is not refused; an omitted REQUIRED one is, by name, because the binder would throw on it anyway." Worth doing for a file whose doc is the only place this surface's rules are written down. The same sentence is repeated in both SKUs' test-class docs (Darling/Darling.Tests/McpUnknownArgumentGuardTests.cs:44, and Lite's twin: "An omitted optional parameter (the guard reads arrived keys only)").

writable column name appears on both tables, so a bare name is still unambiguous --
asserted, not assumed, by WritableColumnNames_DoNotCollideAcrossTheTwoTables. */
updated_fields = updates.Select(u => u.Column).ToArray(),
updated_fields = updates.Select(u => UpdatedFieldName(u.Column)).ToArray(),

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 comment directly above this line is the argument against the line.

Unchanged at 1297-1300: "Bare column names, unqualified, even though two tables are now in play: this array is a consumer API and qualifying the existing entries would redefine every one of them." This change qualifies one of them, deliberately — and it is the right call, because DeliveryCooldownColumn's own doc already claimed it ("delivery.cooldown_minutes is what the tool reports", 2273-2275) and Darling/README.md documents the knob as "reported and accepted as delivery.cooldown_minutes". So the code was the thing that was wrong, which the comment should now say: bare column names except the delivery cooldown, which is reported under its wire name because it is the only writable column whose table is not config_alert_settings.

Left as is, the next reader of #3314's reasoning finds a rule and a counter-example on adjacent lines with nothing saying which won.

@claude

claude Bot commented Oct 22, 2026

Copy link
Copy Markdown

Three measurements and one observation from the read, none of them blocking — the blocking one is the Lite parity gap, inline on DarlingMcpQueryTagTools.cs.

The two lengthened heads still fit their caps. Both dismiss_alert_history and restore_alert_history carry <<GUIDE>>, so they are "converted" tools and McpToolsListBudgetTests.ConvertedTools_HonorD2sAbsoluteCaps_AndNoMarkerReachesTheWire (cap 1,000) plus McpToolGuideTests.EveryConvertedHead_StaysAtOrUnder620Characters (target 620) both apply to them. Measured off the source lines, served head = head + the 30-character GuidePointer: dismiss 585, restore 413. 35 characters of headroom on the dismiss head, so the next sentence added there is the one that reds the 620 test. update_alert_settings' changed line is after its marker (tail, capped at 24,000) and create_custom_alert_rule carries no marker at all, so its now ~1,100-character description is served whole and no cap test governs it.

The password sentence is consistent in all six places it lives — ServerConnectionRule.cs:21, admin.js:481, DarlingManagedRoles.cs:3548, provision-roles.sql:1846 and the two test copies — byte-identical, and the new text contains no apostrophe, which matters because DarlingManagedRolesTests.TheRulesRefusalSentences_AreTheSharedConstants_InTheManagedBatchAndTheScript extracts the raised sentences with RAISE EXCEPTION '%', '([^']*)'.

The three added Assert.Contains in ServerEditCoreTests sit beside an Assert.Equal on the same string (784 vs 785-789), so they can only ever fail on a wording edit to ServerConnectionRule.PasswordNeededOnMoveText, not on any defect in the edit path. That is a wording pin, which .github/REVIEW-TRAPS.md's #5833 ruling asks for none more of; the live StoreServerPasswordRulesLiveTests pin on the same sentence is the one that earns its place, because it proves the store trigger and the C# constant agree. Not worth a revision on its own.

Unrelated to this PR, but adjacent to finding 7 and now newly visible: InvestigationBriefComposer.cs:237 tells an investigating agent its report contract is required: [request_id, claim_id, report_format, verdict, root_cause, root_cause_found, evidence], while submit_investigation_report's actual parameters are request_id, claim_id, report_format, verdict, root_cause, evidence, summary — root_cause_found is a store column, not an argument, and summary is required but unlisted. An agent that follows the brief sends an unknown key (refused by Refuse since #3870) and, after this PR, omitting summary gets the new Missing required argument 'summary' refusal instead of the SDK's opaque error. The improvement is real; the brief is still wrong. Probably its own issue.

claude[bot]
claude Bot previously requested changes Oct 22, 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

Claims

  • VERIFIED — the new move-refusal sentence is in every surface that answers it, and the rule does cover "any other connection setting" — ServerConnectionRule.cs:21, admin.js:481, DarlingManagedRoles.cs:3548, provision-roles.sql:1846; v_moved at provision-roles.sql:1817-1829 is exactly the field list the body names.
  • VERIFIED — no numbered migration is needed — the function is created by the provisioning batch (provision-roles.sql:1754) and DarlingManagedRolesTests.cs:125 asserts the name is in no PgMigrations script.
  • VERIFIED — dismiss/restore answer a success word only when a row changed, not_found for keys naming no row, unchanged otherwise, counts and undo untouched — DarlingMcpAlertTools.RouteAdmin.cs:532-538 over Known = "rows that exist, dismissed or not" (AlertDismissStore.cs:20-23, CountKnownKeysSql); descriptions at 451 and 491.
  • VERIFIED — a dry run is gated like a write and the refusal now says why, at all four paths — DarlingMcpPlanForceTools.cs:229 (force/unforce share RunAsync), :404, :528, :684; a dry run really does sign in with the remediation login (:234-257).
  • VERIFIED — finding 4 was partly wrong and the new text matches the parser — CustomAlertRuleDefinition.cs:461-512: default All, mode of all/servers/tag, non-empty servers, positive tagId.
  • VERIFIED — updated_fields names the cooldown delivery.cooldown_minutes whichever spelling arrived, other fields unchanged — DarlingMcpAlertTools.cs:1301 + :2283, keyed on the column; two spellings in one body are already refused at :3005-3015. Updated assertion at DarlingMcpAlertToolsTests.cs:2996.
  • VERIFIED — assign refuses a non-hex hash, unassign is unchanged — requireHashShape defaults false, passed only at DarlingMcpQueryTagTools.cs:287; the unassign path at :373 omits it.
  • VERIFIED — RefuseMissingRequired is in .Common, chained after Refuse, covers a no-argument call, and reaches both SKUs — McpUnknownArgumentGuard.cs:106, :267-283, :157; registered at Lite/Mcp/McpHostService.cs:349 and DarlingMcpHostService.cs:1724. Refuse is untouched, so Lite's probes (which call it directly, Lite.Tests/McpUnknownArgumentGuardTests.cs:185) keep their meaning.
  • VERIFIED — McpWriteToolWordingTests is 21 cases (4+3+1+7+5+1), the hash refusals precede any store call (BuildKey is the first statement of AssignQueryTagCore), and the rig assertions are at McpAlertAdminLiveTests.cs:218-223 with the Blocking row visible there (dismissed :203, restored :212), so unchanged is right.
  • UNVERIFIED — every reported run (659/0, 1079/0, Lite 49/0, the PostgreSQL-rig classes) — no dotnet here, and Darling.Tests needs Windows plus DARLING_TEST_PG.
  • UNVERIFIED — "a first version put the check inside Refuse; four tests failed" — a discarded revision, nothing in the tree to check.
  • UNVERIFIED — that an upgraded store gets the new PW002 text — the trigger body changes only when provisioning re-runs there.
  • REFUTED — "Left to others — Nothing: all seven findings are covered." Finding 6 is Darling-only. Lite ships the same assign_query_tag, documented as the twin "under the same names, parameters, refusal codes and result shapes" (Lite/Mcp/McpQueryTagTools.cs:27), and its hash branch (:788-790) still stores any text as a key. The body reasons about Lite for finding 7 and not for 6.

Riskiest lines

  1. PerformanceMonitor.Common/Mcp/McpUnknownArgumentGuard.cs:106 — one arm refuses calls across every tool of both SKUs off the generated required list; a list that names a parameter a caller cannot send (a DI service, a host-bound value) refuses every call to that tool — covered by ACallWithoutARequiredArgument_IsRefusedNamingIt for its two tools only (the dismiss_alert_history row fails on a leaked name, since the message takes the plural branch); UNCOVERED for every other registered tool.
  2. Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryTagTools.cs:518 — the new refusal, and the half missing on the other SKU — covered on Darling by McpWriteToolWordingTests.AssignQueryTag_RefusesAHashThatIsNotHexadecimal; UNCOVERED on Lite, where neither check nor test exists.
  3. Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.RouteAdmin.cs:532 — a status word callers branch on changed meaning — covered by DismissAlertHistory_OnlyAnswersDismissedWhenItDismissedARow, its restore twin, and ADismissedAlert_LeavesTheHistoryRead_AndTheUndoBringsItBack.

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" — applies — DarlingMcpQueryTagTools.cs:518 refuses a non-hash hash; Lite/Mcp/McpQueryTagTools.cs:788-790 does not. Every other changed surface is Darling-only (grep custom_alert Lite/Mcp is empty; the alert-admin and plan tools have no Lite twin), and finding 7 sits in .Common with both hosts registering it.
  • "Two copies of one definition pinned EQUAL to each other fail only after BOTH have drifted (#3684). Alias, do not copy." — applies, prospectively — all six copies of the sentence moved together and DarlingManagedRolesTests.cs:157 pins the SQL against ServerConnectionRule; IsHashText is the definition that must be shared, not copied, when Lite is fixed.
  • "Ruling (#5833): tests are load-bearing … never answer a trap with a test that reads source text or asserts how code is spelled" — applies, minor — the three Assert.Contains at ServerEditCoreTests.cs:785-789 sit beside an Assert.Equal on the same string and can only fail on a wording edit; everything else added is behaviour.
  • "hours_back, limit, top, days_back are refused when out of range, never clamped" — does not apply — the guard refuses without filling the argument in (McpUnknownArgumentGuard.cs:280-282) and the hash check refuses rather than canonicalising (DarlingMcpQueryTagTools.cs:518-521).

Divergence (blind read vs PR body)

The one that matters: the seven-for-seven completeness claim does not hold for finding 6 — Lite's identically-named assign_query_tag still accepts "hello" as a query hash, so two tools documented as twins now refuse differently. Two smaller ones, both documentation the diff left standing and the body does not mention: McpUnknownArgumentGuard's contract paragraph still says the guard "reads the keys that arrived, never the ones that did not" (lines 46-49) directly above the arm that reads the ones that did not, and DarlingMcpAlertTools.cs:1297-1300 still argues that qualifying an updated_fields entry "would redefine every one of them" directly above the line that qualifies one. In the other direction the body undersells finding 5: the change makes the code agree with DeliveryCooldownColumn's own doc and with Darling/README.md, which both already claimed the reported name.

Reviewer checklist

  • Build and run both suites on Windows (no dotnet in this environment): the two new classes, McpToolsListBudgetTests and McpToolGuideTests (the dismiss head now measures 585 of the 620 target), and Lite's guard tests.
  • Call a tool through a real MCP host with a required argument omitted, and confirm from a live tools/list that no tool's required array names a DI-injected parameter — the body states no live MCP call was made for finding 7, and that array now decides whether a call runs at all.
  • Upgrade an existing store and confirm the provisioning run replaces the PW002 trigger body: with no migration rung, until it re-runs that store raises the old sentence while the app ships the new one.

…n the database (#6455)

The test counted every backend with an xid in the scratch database, so a TimescaleDB
job worker or autovacuum holding an xid made it read 2. It now asks about its own writer.

@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: LGTM

Claims

  • VERIFIED — "Lite's assign_query_tag still stored any text as a hash key... the shape check moved from DarlingMcpQueryTagTools.IsHashText to one shared QueryHashIdentity.IsAssignableHash... both products' assign paths ask it (requireHashShape, passed only by assign; unassign skips it on both)" — Lite/Mcp/McpQueryTagTools.cs:303 and Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryTagTools.cs:287 both pass requireHashShape: true on assign; the unassign call sites (Lite:376, Darling:373) omit it; the old private IsHashText is deleted and no reference to it remains in the tree.
  • VERIFIED — "an all-zero hash (0, 0x0000000000000000) passed the check... now refused on both" — IsAssignableHash routes through KnownHash/IsUnknown (PerformanceMonitor.Common/QueryHashIdentity.cs:91-95), which treats all-zero as unknown and returns false; new [InlineData("0")]/[InlineData("0x0000000000000000")] cases were added to both Darling.Tests/McpWriteToolWordingTests.cs and Lite.Tests/McpQueryTagToolTests.cs.
  • VERIFIED — "Lite.Tests McpQueryTagToolTests gained a nine-case behaviour test that assign refuses each bad hash and stores nothing, and one that unassign does not apply the refusal" — AssignQueryTag_RefusesAHashThatIsNotHexadecimal_AndStoresNothing has 9 [InlineData] entries and asserts status == "invalid" plus an empty-assignment follow-up read; UnassignQueryTagCore_DoesNotApplyTheHashShapeRefusal_SoAnOlderKeyCanStillBeRemoved is the separate fact.
  • VERIFIED — "the two stale comments the bot found are updated" — McpUnknownArgumentGuard.cs's contract paragraph now separates the unknown-key check from RefuseMissingRequired, and DarlingMcpAlertTools.cs's updated_fields comment now names the cooldown exception.
  • VERIFIED — CI-fix commit's mechanism: QueryStoreComposeStampWriterGuardLiveTests.cs's xid count now filters pid = {writer.ProcessID} instead of counting every backend in the database, matching the stated TimescaleDB-worker/autovacuum false-positive explanation.
  • UNVERIFIED — reported test run totals (e.g. "Darling.Tests... 1189 passed, 0 failed", "Lite.Tests... 205 passed, 0 failed", "3 of 3 pass at the PR head... 3 of 3 pass at origin/dev") — these are live/CI execution results that cannot be reproduced from a read-only tree review.

Riskiest lines

  1. PerformanceMonitor.Common/QueryHashIdentity.cs:88-122 (IsAssignableHash) — now the single shared validator both SKUs' assign paths call; a regression here breaks hash-key validation identically on both products — covered by Darling's McpWriteToolWordingTests.AssignQueryTag_RefusesAHashThatIsNotHexadecimal (9 cases through the real AssignQueryTag) and Lite's McpQueryTagToolTests.AssignQueryTag_RefusesAHashThatIsNotHexadecimal_AndStoresNothing (9 cases through the real AssignQueryTagCore), both of which fail if the shape check regresses.
  2. Lite/Mcp/McpQueryTagTools.cs:303 (requireHashShape: true on assign's BuildKey call) — this one argument is the entire Lite/Darling parity fix; dropping it silently reopens the original bot-reported gap — covered by McpQueryTagToolTests.AssignQueryTag_RefusesAHashThatIsNotHexadecimal_AndStoresNothing.
  3. Darling/Darling.Tests/QueryStoreComposeStampWriterGuardLiveTests.cs:67 (pid = {writer.ProcessID}) — this is itself the fix for a flaky live-Postgres assertion; a regression (e.g. back to counting every backend) would reintroduce the CI flake under concurrent TimescaleDB workers — UNCOVERED by any other automated test; validated only by the author's reported live-rig runs (noted UNVERIFIED above), since it is a live-rig assertion rather than product code with its own test.

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" — applies as the subject of this diff, not as a new violation: the diff is exactly closing that gap for assign_query_tag (Lite/Mcp/McpQueryTagTools.cs:303 now mirrors Darling/.../DarlingMcpQueryTagTools.cs:287).
  • "Two copies of one definition pinned EQUAL to each other fail only after BOTH have drifted (#3684). Alias, do not copy." — does not apply as a defect here: the diff removes the second copy (Darling's private IsHashText) in favor of one shared QueryHashIdentity.IsAssignableHash (PerformanceMonitor.Common/QueryHashIdentity.cs:88) that both products now call, which is the trap's own prescribed fix rather than a new instance of the trap.

Divergence (blind read vs PR body)

None — the body's account (the shared-method extraction, the requireHashShape wiring on both assign paths only, the all-zero-hash fix, the stale-comment fixes, and the xid-count scoping) matches the diff on every claim graded above.

Reviewer checklist

  • Confirm the reported test totals (Darling.Tests 1189 passed / Lite.Tests 205 passed, and the 3/3 local reruns of the xid test at both the PR head and origin/dev) by running the suites, since a read-only tree review cannot execute them.

@erikdarlingdata
erikdarlingdata dismissed claude[bot]’s stale review October 22, 2026 22:32

Stale: this review is on 09fa03e. The bot's review of the current head 1438c9d is LGTM.

@erikdarlingdata
erikdarlingdata merged commit 592da54 into dev Oct 23, 2026
35 of 36 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/6455-write-tool-wording branch October 23, 2026 00:00
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