Repository navigation
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
Conversation
…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
enabled auto-merge (squash)
September 20, 2026 02:27
erikdarlingdata
disabled auto-merge
September 20, 2026 02:34
This was referenced Sep 20, 2026
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
4 of 5 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 aServerResolvermiss or aMcpHelpersvalidator — 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 linksPerformanceMonitor.Commonand its 46 validator call sites change with it.The lie
#3719 collapsed every caught exception onto
McpHelpers.FormatErrorand 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 onstatus, which is what the instructions teach it to do, was handed prose for a badhours_backand 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_idandget_pg_wait_trend'squeryid— split the"error"literal across a line break and evaded the grep) took the worse path: they hand-builtMcpHelpers.Status("error", …), and since #3719 the web surface reads that word as a server fault. A badlimiton/api/read/get_pg_deadlocksanswered 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, andDarlingWebEndpoints.MuteRuleEnvelopeStatusalready 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/rejectedwere 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.
ClassifyToolResponsegains aRefusalkind, tested after the error envelope and BEFORE the{-sniff (which would answer 200 over it);ToHttpResultmaps it toResults.Text(envelope, "application/json", 400)— the envelope itself as the body, not the{"error": sentence}wrap — which is exactly the shapeMuteRuleToolResulthas always giveninvalid.MuteRuleEnvelopeStatusreaches 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_foundstill 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 }), besideInvalidEnvelopePrefix({"status":"invalid",) andIsRefusalEnvelope— the same prefix testIsErrorEnvelopeis, so the two consumers that split 500 from 400 tell them apart with two prefix tests.ErrorMessageOfunwraps either envelope.Why the hint is the PARAMETER and not the operation. The brief's twin signature named the operation, as
FormatErrordoes. 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". Sohints.parameterit is, on every refusal.The producers, not the sites. Every validator in
McpHelpers(ValidateHoursBack,ValidateTop,ValidateDaysBack,ValidateUncappedWindow,ResolveAsOf,ParseSummaryDate,ValidateChoice,ValidateMinMs;ValidateWindowpasses theirs through), both SKUs'ServerResolvermiss, the web dispatch'sMissingParam/UnparseableParam, the fleet-sweepValidateSpan/ValidateWatchState, and the handful of tools that refuse a parameter of their own inline (analyze_plan_xml's "No plan XML provided." andcompare_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'sgroup_by, and the nine PostgreSQL sites). Theif (error != null) return error;idiom at every call site passes the envelope through untouched. The SENTENCES are byte-for-byte unchanged insidemessage, so every log grep and every pinned fragment survives — read back throughErrorMessageOf.Darling's resolver, specifically.
ResolveOrErrorwrapsMissSentence(the local listing plus the #2339 peer disclosure, unchanged) in the envelope.ResolveOrErrorWithFleetSentinelAsyncappends 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, wearinginvalidwould tell them to fix a call that was fine, and this seam has no tool name forFormatError'shints.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
DarlingTriageEndpoint: the resolution note and the per-sectionerrorare TEXT the card renders — both read throughErrorMessageOfnow (the section arm already did; it just learned the second envelope).DarlingCliCommands(remove-server's miss on stderr) andremove_servers'not_foundoutcome — both consumed the resolver's miss as text; both unwrap.DarlingFleetSweepEndpoints— its own{"error": sentence}contract (The analysis family cannot be anchored at a past window, and it is the one an incident starts with #2506) is kept;SweepErrorunwraps, so a web client reading.erroris not handed JSON inside a string.wwwroot/js/util.jsclassifyResponse— the non-2xx arm readsmessageoff a{status, message}body when there is no.error(the refusal is now the 400 body on/api/read/*), soreadErrorStrip's "window too wide" degrade keeps its sentence; the 2xx arm treatsinvalidlikeerror(belt-and-braces for an unmapped body). The two hunks MCP errors take one wire shape: FormatError returns the {status:"error"} envelope on both SKUs (Partial #3653, Q11) #3719 touched, taught the word.The census
McpPayloadContractCensusTestsgains the arm, walker-driven rather than a hand-list of 400 sites:EveryRefusal_ReturnsThroughTheSharedShape_AndTheBareSentenceRosterIsExact— every guarded pass-through (if (x != null) return x;, 463 across both SKUs: 196 resolver, 218 validator, 49 gates and the health-parser family's carried result) is walked back to the LAST assignment ofxand its producer must be shared; everyreturn "…"in a tool body must be in the exact rosterBareSentenceReturns(three MISS sentences —list_serverson an empty registry on both SKUs, andanalyze_query_store_plan's vanished-connection sentence — One payload said NoData and No Data, eight spellings of four severity words were two bands and six other vocabularies, and the page cut had five names: the daily summary speaks one band token on both SKUs, six inferred cuts become observed truncated + *_returned, and the census classifies every cut key and severity literal by what it is (partial #3653) #3703's lane, not refusals); every hand-builtStatus("invalid"/new { status = "invalid" }fails. Floors on all three counts so the idiom regex cannot go dark.TheFailureWord_HasOneProducer_OnBothSkus—Status("error"appears in no tool source and exactly once inMcpHelpers.cs, insideFormatError. This is the pin that makes the nine-refusals shape unrepeatable.TheOneRefusalShape_IsTheEnvelope_ExecutedThroughRefusal_AndEveryValidatorBuildsIt— the shape executed (parses;status/message/hints.parameter; byte-equal toStatus(…)), the recognizer on the producer and the write tools' bytes and not on its neighbours, and every shared validator driven past its bound hands back exactlyRefusal(its parameter, its sentence).Red-first, all executed here: one PostgreSQL site reverted to
Status("error", limitError)→TheFailureWord_…namesDarlingMcpPgDeadlockTools.cs: GetPgDeadlocks;analyze_plan_xmlreverted to the bare sentence → the roster fails namingDarlingMcpPlanTools.cs analyze_plan_xml: "No plan XML provided.".DarlingWebEndpointsTests:ClassifyToolResponse_TheInvalidEnvelope_IsARefusalagainst the real producers (Refusal, four validators, the Darling resolver's miss, the write tools' bytes) and its neighbours (invalid_count,{"invalid":true});TheErrorEnvelope_IsServerErrorstays, its third case re-documented as the reason the census forbids the shape; the mute-rule table gainsRefusal'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) gainsEveryRefusal_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.SharedMissFragmentspins 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 throughErrorMessageOf(the JSON serializer escapes'and+, so a rawStartsWith/Containson the envelope would have gone red or vacuous).What is NOT changed
Outcome("invalid", …)— same word, same bytes, MCP write tools report what happened: every server you add lands in a counter, a partial name cannot delete the wrong server, an omitted field is never a clear, and a mute that matched nothing says so (#3541 A14) #3615's contract; they carry nohints.parameterand are not made to.deprecated/Dashboard/**source — frozen; builds clean; its ownServerResolveris separate and still a sentence.Descriptions
Both instructions files: the #3719 paragraph's SKU-specific trailing sentence on validation refusals is replaced by one shared paragraph teaching the sixth
statusword, with the example envelope byte-for-byte, and "sixstatuswords, then: four kinds of nothing, one failure, one refusal". Lite's## Error Handlingnames the envelope and re-labels "Could not resolve server" asinvalid's message.Tests — executed here vs CI-first
Executed on this Mac: the real
Darling.Testsin-process (WindowsDesktop stripped), 19 classes / 516 tests includingMcpPayloadContractCensusTests,DarlingWebEndpointsTests,DarlingPeerDisclosureTests,RepoFileAdoptionTests,StragglerCommandTimeoutTests,LiveCleanupConversionRatchetTests,MeasurementContractCensusTests,McpPageContractTests,DocCommentHygieneTests,FleetIdentifierScrubTests— 1 red, the harness-csproj artifact inTheDerivedProjectListCoversEveryProjectInTheTree; Lite harness (McpMissMessageParityPinTests,McpPageContractTests,AsOfWindowAnchorTests,DailySummaryRangeToolTests,MeasurementContractCensusTests: 109 tests, 6 reds all the MacPresentationFrameworkartifact 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