Skip to content

MCP refusals get their status word: every validation bail answers the {status:"invalid"} envelope on both SKUs, and the PostgreSQL refusals that wore error go HTTP 500 → 400 (Closes #3739) - #3776

Merged
erikdarlingdata merged 1 commit into
devfrom
fix/3739-refusal-status-word
Sep 20, 2026
Merged

erikdarlingdata merged 1 commit into
devfrom
fix/3739-refusal-status-word

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Closes #3739 — the ruling #3719 asked for (Q11 residue of #3653), made and landed.

This is a WIRE CHANGE. Roughly four hundred validation bails on both SKUs — every if (error != null) return error; after a ServerResolver miss or a McpHelpers validator — answered with the validator's bare sentence, the one outcome on the wire that was not JSON. They now answer with the same envelope every other outcome uses: {"status":"invalid","message":"<the same sentence>","hints":{"parameter":"<what to fix>"}}. The frozen Dashboard twin links PerformanceMonitor.Common and its 46 validator call sites change with it.

The lie

#3719 collapsed every caught exception onto McpHelpers.FormatError and the payload contract said "errors one shape" — but a REFUSAL is not an error, and the refusals never got a shape at all. A client that keys on status, which is what the instructions teach it to do, was handed prose for a bad hours_back and JSON for everything else, with nothing in the prose to say whether the tool failed, refused, or found nothing.

Nine PostgreSQL refusals (the issue counted seven; two more — get_pg_plans' query_id and get_pg_wait_trend's queryid — split the "error" literal across a line break and evaded the grep) took the worse path: they hand-built McpHelpers.Status("error", …), and since #3719 the web surface reads that word as a server fault. A bad limit on /api/read/get_pg_deadlocks answered HTTP 500. Before #3719 it answered 200 (the {-sniff passed it through). Neither was the 400 a client-correctable refusal deserves. #3719 stated the consequence, pinned the present behaviour (ClassifyToolResponse_TheErrorEnvelope_IsServerError), and said the word was a ruling it would not make.

The ruling

The word is invalid, widened. It was already the write tools' word for a body that would not parse — Outcome("invalid", …) across the mute-rule, alert-settings, custom-alert and custom-view verbs, and DarlingWebEndpoints.MuteRuleEnvelopeStatus already mapped it to HTTP 400 on the write surface — and "a body that will not parse" is one instance of "the request as given cannot be served: a parameter the tool cannot honor, a missing required parameter, an unresolvable server name". So the read surface says the same word for the same kind of thing. refused / rejected were considered and NOT added: a sixth word beside a fifth that already means this would be a divergence, not a distinction.

HTTP: a refusal is 400, on both surfaces, by one rule. ClassifyToolResponse gains a Refusal kind, tested after the error envelope and BEFORE the {-sniff (which would answer 200 over it); ToHttpResult maps it to Results.Text(envelope, "application/json", 400) — the envelope itself as the body, not the {"error": sentence} wrap — which is exactly the shape MuteRuleToolResult has always given invalid. MuteRuleEnvelopeStatus reaches its 400 through the same classifier arm now (its parsed "invalid" case survives as belt-and-braces for an envelope serialized some other way; not_found still reaches its 404). The nine PostgreSQL refusals go 500 → 400. The ~400 bare sentences already answered 400 (the final arm, by the accident of being unshaped); shaping them does not move their code, only their body. #3719 pinned the present mapping so this would change it knowingly. This is that change.

The fix — a twin of FormatError, built in the producers

McpHelpers.Refusal(parameter, sentence) → Status("invalid", sentence, new { parameter }), beside InvalidEnvelopePrefix ({"status":"invalid",) and IsRefusalEnvelope — the same prefix test IsErrorEnvelope is, so the two consumers that split 500 from 400 tell them apart with two prefix tests. ErrorMessageOf unwraps either envelope.

Why the hint is the PARAMETER and not the operation. The brief's twin signature named the operation, as FormatError does. The producers of a refusal are the shared validators and the server resolvers, which are called from four hundred tools and know nothing about which one — what every one of them knows is the parameter it refused, and that is the thing the caller has to change. A failure's useful question is "which read broke"; a refusal's is "which knob". So hints.parameter it is, on every refusal.

The producers, not the sites. Every validator in McpHelpers (ValidateHoursBack, ValidateTop, ValidateDaysBack, ValidateUncappedWindow, ResolveAsOf, ParseSummaryDate, ValidateChoice, ValidateMinMs; ValidateWindow passes theirs through), both SKUs' ServerResolver miss, the web dispatch's MissingParam / UnparseableParam, the fleet-sweep ValidateSpan / ValidateWatchState, and the handful of tools that refuse a parameter of their own inline (analyze_plan_xml's "No plan XML provided." and compare_analysis' baseline rule on both SKUs, mute_analysis_finding's hand-serialized {status:"invalid"} on both SKUs, get_sweep_reports' sweep_id, get_top_queries_by_cpu's group_by, and the nine PostgreSQL sites). The if (error != null) return error; idiom at every call site passes the envelope through untouched. The SENTENCES are byte-for-byte unchanged inside message, so every log grep and every pinned fragment survives — read back through ErrorMessageOf.

Darling's resolver, specifically. ResolveOrError wraps MissSentence (the local listing plus the #2339 peer disclosure, unchanged) in the envelope. ResolveOrErrorWithFleetSentinelAsync appends its disclosure to the SENTENCE and rebuilds the envelope, rather than appending text to JSON. The registry-read FAULT ("Could not read the servers registry from the Postgres store: …") deliberately stays a bare sentence: it is a store fault, not the caller's request, wearing invalid would tell them to fix a call that was fine, and this seam has no tool name for FormatError's hints.operation. It maps to the web's bare-string 400 arm exactly as before — stated here rather than hidden; the right shape for it is a separate question.

The consumers this would otherwise have broken

The census

McpPayloadContractCensusTests gains the arm, walker-driven rather than a hand-list of 400 sites:

Red-first, all executed here: one PostgreSQL site reverted to Status("error", limitError) → TheFailureWord_… names DarlingMcpPgDeadlockTools.cs: GetPgDeadlocks; analyze_plan_xml reverted to the bare sentence → the roster fails naming DarlingMcpPlanTools.cs analyze_plan_xml: "No plan XML provided.".

DarlingWebEndpointsTests: ClassifyToolResponse_TheInvalidEnvelope_IsARefusal against the real producers (Refusal, four validators, the Darling resolver's miss, the write tools' bytes) and its neighbours (invalid_count, {"invalid":true}); TheErrorEnvelope_IsServerError stays, its third case re-documented as the reason the census forbids the shape; the mute-rule table gains Refusal's bytes and a spaced envelope; the bare-string theory keeps its rows as the floor under what the producers no longer emit, plus the two sentences that still can. McpPageContractTests (Lite) gains EveryRefusal_IsTheInvalidEnvelope_OnLite — the resolver's miss, four validators through real tools, analyze_plan_xml, and a miss neighbour — the half that RUNS on Lite. McpMissMessageParityPinTests.SharedMissFragments pins the new instructions paragraph byte-for-byte on both SKUs (the two SKU-specific tails #3719 left unpinned now say the same true thing and are shared). Forty-odd sentence pins in twenty-two test files across both projects read through ErrorMessageOf (the JSON serializer escapes ' and +, so a raw StartsWith/Contains on the envelope would have gone red or vacuous).

What is NOT changed

Descriptions

Both instructions files: the #3719 paragraph's SKU-specific trailing sentence on validation refusals is replaced by one shared paragraph teaching the sixth status word, with the example envelope byte-for-byte, and "six status words, then: four kinds of nothing, one failure, one refusal". Lite's ## Error Handling names the envelope and re-labels "Could not resolve server" as invalid's message.

Tests — executed here vs CI-first

Executed on this Mac: the real Darling.Tests in-process (WindowsDesktop stripped), 19 classes / 516 tests including McpPayloadContractCensusTests, DarlingWebEndpointsTests, DarlingPeerDisclosureTests, RepoFileAdoptionTests, StragglerCommandTimeoutTests, LiveCleanupConversionRatchetTests, MeasurementContractCensusTests, McpPageContractTests, DocCommentHygieneTests, FleetIdentifierScrubTests — 1 red, the harness-csproj artifact in TheDerivedProjectListCoversEveryProjectInTheTree; Lite harness (McpMissMessageParityPinTests, McpPageContractTests, AsOfWindowAnchorTests, DailySummaryRangeToolTests, MeasurementContractCensusTests: 109 tests, 6 reds all the Mac PresentationFramework artifact on Lite's wait-stats read). CI-first: every live-PostgreSQL pin touched (McpFilterSemanticsLivePostgresTests, DarlingMcp*ToolsTests' unknown-server rows, DarlingDailySummaryRangeTests, AnalysisAsOfAnchorTests) and the Dashboard.Tests run. Builds: Common, Darling.Service, PerformanceMonitorLite, Darling.Tests, Lite.Tests, deprecated/Dashboard — 0 warnings on the touched projects.

Closes #3739

…s answers McpHelpers.Refusal's {status:"invalid", message, hints.parameter} envelope, and the nine PostgreSQL refusals that wore `error` go HTTP 500 -> 400 on the web (Closes #3739)

The ruling #3719 left for a ruling: a refusal is the fourth kind of outcome beside data, miss and fault, and its word is `invalid`, widened from the write surface's "a body that will not parse" to "the request as given cannot be served". Built in the producers (the McpHelpers validators, both resolvers, the web dispatch's missing-parameter arm, the handful of inline refusals), so the ~400 `if (error != null) return error;` sites pass it through unchanged. ClassifyToolResponse gains the Refusal kind (400, the envelope as the body, tested before the {-sniff), MuteRuleEnvelopeStatus reaches its 400 through the same arm, and the text consumers (triage note, CLI stderr, remove_servers' not_found, the fleet-sweep {"error"} body, util.js) read the sentence back through ErrorMessageOf. The census walks every guarded pass-through to its producer, rosters the three bare miss sentences left, and holds that Status("error") has one producer.
@erikdarlingdata
erikdarlingdata enabled auto-merge (squash) September 20, 2026 02:27
@erikdarlingdata
erikdarlingdata merged commit b458b18 into dev Sep 20, 2026
5 of 7 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/3739-refusal-status-word branch September 20, 2026 02:34
erikdarlingdata added a commit that referenced this pull request Sep 25, 2026
…ad fault

The round-1 H2 fix made ClassifyToolResponse answer ServerError for any
sentence starting with DarlingServerResolver.RegistryReadFaultPrefix. An
older #3776 theory row still expected 400 for that exact sentence, which
CI caught as a live regression against the new behavior.

Remove the stale InlineData row, update the theory's summary to describe
what still reaches the 400 arm, and add a fact pinning the fault's 500.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
erikdarlingdata added a commit that referenced this pull request Sep 25, 2026
…ng paths (#4283) (#4293)

* Web viewer: stop sending exception text to the browser on the remaining paths (#4283)

Round-1 review of #4281 found three older paths that still answered a failed web
request with the caught exception's own text; this lane's own grep of the web host
found two more. All eight now answer a fixed message and log the real text once
through DarlingWebFailureLog, reusing #4281's classifier and messages rather than a
second copy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* Route mute-rule ServerError through ServerErrorResult (#4283 H1)

MuteRuleToolResult wrapped a tool's caught-exception text straight onto
the wire under ErrorResult, never through ServerErrorResult's logged,
fixed-message answer the read surface already uses. Classify the
result first and route ServerError through ServerErrorResult, with a
Stopwatch per mute-rule route matching the /api/read/* loop's timing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* Classify the resolver's own registry-read fault as a ServerError (#4283 H2)

The resolver's store-fault sentence ("Could not read the servers
registry...") fell through ClassifyToolResponse as a bare ClientError,
so ToHttpResult, MuteRuleToolResult and the triage page's note/card all
answered it as a 400 with ex.Message on the wire, instead of the
generic/timeout 500/503 every other caught exception gets.

Factor the sentence's prefix into a constant on DarlingServerResolver,
byte-identical to the old inline literal, and recognize it in
ClassifyToolResponse alongside the existing "Error during " bare
sentence. MCP callers see no change: they read the resolver's sentence
back unchanged, never through ClassifyToolResponse.

The triage note block gets its own matching branch, since it inspects
the resolver's error string directly rather than going through
ClassifyToolResponse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* Anchor the sentence timeout token to a known prefix (#4283 L1)

s_sentenceTimeoutToken matched \b57014\b anywhere in a tool-caught
sentence, so a real SQLSTATE elsewhere in the tail (a quoted value, a
port number) could false-positive a 500 into a 503. Anchor the match
to the start of the sentence, requiring 57014 immediately after one of
the three known prefixes: ErrorSentence's "Error during {op}: ", the
compose route's "Error running query: ", and H2's resolver registry-
read-fault prefix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4283 review round 1 (M1): route a non-actionable compose/run PostgresException through the web backstop

RunComposedPanelAsync's PostgresException catch put every SQLSTATE's real MessageText on the wire at
400, including auth failures (28P01), admin shutdowns (57P01) and other STORE faults a Custom Views
panel author cannot act on. Ruling: keep the FIRST design from the review handoff (ComposeRunOutcome
gets a Fault field; run_custom_view_panel's MCP answer stays byte-for-byte the same either way).

IsComposeRunAuthorActionable classifies 57014 / class 22 / class 42 (except 42501) as author-actionable
- those keep "Query failed: {MessageText}" verbatim at 400. Everything else rides back on
ComposeRunOutcome.Fault, and the web endpoint mapping (factored into a standalone
ComposeRunFailureResult, mirroring ToHttpResult/MuteRuleToolResult so it is directly testable without a
live Postgres round trip) answers it through the same fixed-body backstop #4276 gives an uncaught
exception: 500, one log line, no role/host text on the wire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4283 review round 1 (L2): widen WebExceptionTextCensusTests' census past a fixed ex/exception name

The census only matched (ex|exception).Message(Text)?, so a differently-named caught exception
(pgEx.MessageText) or a bare interpolated {e}/{ex} with no property access at all could reach a web
response unseen. Widened the pattern to any leading identifier before .Message/.MessageText/
.InnerException/.Detail/.Hint/.Where (LINQ .Where( excluded) or an ex-shaped .ToString()/{ex}
interpolation, matched line-by-line instead of a +-60-char window that could spill a neighboring
statement's allow-listed snippet onto an unrelated line.

Added DarlingServerResolver.cs and DarlingWebFailureLog.cs to the roster (both now carry exception-text
code after H1/H2/M1), with the five new allow-list entries the widened pattern surfaces for real:
CustomViewResult/CustomAlertRuleResult's own Conflict.Message/Invalid.Message business text,
CollectorRuntimeState's own snapshot.Detail, DarlingWebFailureLog's own
exception.InnerException type-pattern check, and DarlingServerResolver's registry-read fault sentence
(reclassified through ToHttpResult before the web surface, MCP unchanged, the same shape as
ComposeRunOutcome.ServerError's existing allow-list entry).

Fixed one bug in the review handoff's own proposed regex along the way: its .ToString() alternative
was folded into the unrestricted property-name group, so it matched every unrelated .ToString() in the
roster (value.ToString(), m.Archetype.ToString(), builder.ToString()) instead of only an exception-
shaped receiver - caught by the handoff's own required negative test case for "count.ToString()", which
its literal regex did not actually satisfy. Gave .ToString() its own alternation gated on an ex-shaped
identifier to match the handoff's stated design intent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4293 round 2 (R2-L1, R2-L2): a FATAL/PANIC store fault is no longer treated as author-actionable, and a panel's own query error is logged once

A FATAL 22023/42704 from a startup parameter the server rejects shared IsComposeRunAuthorActionable's SQLSTATE
buckets with a plain ERROR, so it answered 400 with the configured setting's name and value instead of routing
through the Fault backstop. Pull the compose runner's PostgresException decision into a testable
FromPostgresException helper: author-actionable now also requires ERROR severity, so FATAL/PANIC always carries
Fault regardless of SQLSTATE class.

ComposeRunOutcome gains a trailing AuthorSqlState member and an AuthorQueryError factory so
ComposeRunFailureResult can log a single Warning when it answers a panel's own query error at 400 - store drift
(42P01/42703 after an unfinished migration) now reaches the service log, not only one author's browser. The MCP
run_custom_view_panel path is unaffected: it reads only Error/IsServerError, unchanged on every arm.

Adds FromPostgresException coverage for both arms, a source pin on the catch body, and
ComposeRunFailureResult logging coverage for AuthorQueryError vs a plain BadRequest. Updates the stale
revert-proof doc comment on the neighboring M1 test to describe what fails now. Darling.Tests: 0 warnings, 0
errors; WebExceptionTextCensusTests 33 (1 skip, no DARLING_TEST_PG), DarlingComposeTests 280, DocCommentHygieneTests
77, all green. Confirmed each revert-proof by hand: dropping the InvariantSeverity clause fails the FATAL cases,
reverting the catch to the old ternary fails the source pin, and removing the log call fails the logger test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4293 round 2 (R2-L3): an allow-list snippet now vouches only for the match it contains, not the whole line

NoWebEndpoint_BuildsAnAnswerFromExMessage_ExceptTheNamedAllowList used line.Contains(snippet) to decide a match
was accounted for - true the moment the snippet appeared ANYWHERE on the offending line, even for a second,
unrelated exception-text access sharing that line. Add SnippetCovers(line, snippet, column, length), which scans
every occurrence of the snippet on the line and accepts only when the match's own span sits inside one of them.

Pull the per-file loop out of the [Fact] into ComputeUnaccounted(relativePath, code) so a test can run the same
check against one fabricated line instead of the whole roster. Add SnippetCovers unit coverage (covered,
uncovered, a second occurrence), a test running ComputeUnaccounted against the real allow-list on the synthetic
line "Query failed: {ex.MessageText} {ex.Detail}" (one unaccounted match, ex.Detail), and a revert-proof pinning
that the retired line.Contains check finds the snippet anywhere on that line.

Darling.Tests: 0 warnings, 0 errors; WebExceptionTextCensusTests 36 (1 skip, no DARLING_TEST_PG),
DocCommentHygieneTests 77, both green. Confirmed by hand: reverting ComputeUnaccounted's SnippetCovers call back
to line.Contains(snippet) fails SnippetCovers_RealAllowList_OnASyntheticLine_LeavesExDetailUnaccounted (empty
collection instead of the one expected ex.Detail entry).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4293 round 2 (R2-L4): widen s_exMessagePattern to the shapes it still misses, and correct the snapshot.Detail allow-list reason

Four widenings to the exception-text census pattern:
- A ?, ! or closing ) may now sit before the dot on the generic-receiver alternation: ex?.Message, (ex as
  PostgresException)?.MessageText, ex.GetBaseException().Message.
- A second property list (StackTrace, InternalQuery, TableName, SchemaName, ColumnName, ConstraintName,
  Routine) gated on an exception-shaped receiver only (ex/exception/e/pgEx/fault/Fault, bare or at the end of a
  dotted path like outcome.Fault) - TableName/ColumnName are common, harmless names on other receivers, so this
  list cannot reuse the generic-receiver alternation.
- The .ToString()/{...} arms' shared name fragment (now pulled into a private const, ExOrFaultName) also
  accepts Fault/fault, bare or dotted.
- A new alternation catches a string literal concatenated onto an exception-shaped name ("Query failed: " +
  ex), gated by a negative lookahead so it defers to the other alternations when the name is further accessed.

Also corrects the snapshot.Detail allow-list's reason: it is the raw ex.Message of a startup failure today
(round-1 H4, tracked in #4316, fixed by PR #4326), not never PostgresException.Detail as it previously read;
the entry is to be removed once #4326 lands.

Adds every new shape to the must-match list, two non-exception-receiver negatives (widget.TableName,
row.ColumnName) proving the new property list's gating, and a revert-proof pinning that the round-1 pattern
misses every round-2 shape. Checked every roster file by hand for "fault"/"Fault"/the new property names before
widening: DarlingServerResolver.cs has bare fault/Fault tuple variables, none followed by a dot, so none newly
match; other hits are inside comments, which StripComments removes.

Darling.Tests: 0 warnings, 0 errors; WebExceptionTextCensusTests 37 (1 skip, no DARLING_TEST_PG) -
NoWebEndpoint_BuildsAnAnswerFromExMessage_ExceptTheNamedAllowList still reports 0 unaccounted on the real
roster files under the widened pattern; DocCommentHygieneTests 77. Both green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4293ci: drop the stale 400 theory row for the resolver's registry-read fault

The round-1 H2 fix made ClassifyToolResponse answer ServerError for any
sentence starting with DarlingServerResolver.RegistryReadFaultPrefix. An
older #3776 theory row still expected 400 for that exact sentence, which
CI caught as a live regression against the new behavior.

Remove the stale InlineData row, update the theory's summary to describe
what still reaches the 400 arm, and add a fact pinning the fault's 500.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
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