Record which credential DefaultAzureCredential selected, on an event-id allowlist (#3218 follow-up) - #3224
Conversation
…id allowlist Lite's EntraDefaultCredential mode signs in as whichever Azure identity the machine already has, in an order the driver owns and the app cannot narrow. #3218 disclosed that in dialog text; nothing recorded which identity won. A narrowly-scoped EventListener enables the Azure-Identity source for exactly the duration of one connection open, forwards Azure.Identity's event 13 and nothing else, and writes the selected credential's type name through AppLogger. The filter is an allowlist on the event id because the level cannot narrow this - event 13 is itself Informational, so that is the lowest level that delivers it - and no event in that source declares Keywords, so EnableEvents has no dimension to exclude the sensitive siblings on.
Mutation found two pins green that should have been red. Every rejection case for the type-name shape check was also missing a dot, so the dot requirement alone rejected it and the character allowlist was never under test - widening it by a hyphen left the suite green. And Decide re-reads the rule rather than trusting the listener's verdict, which nothing exercised: keying the refusal off the bool alone was also green.
The mode's own section already warns that the credential order is the driver's and that a machine with several Azure identities connects as whichever comes first. That caveat is now answerable after the fact, so the section says so - including that there is exactly one such line per run, and that the type name is the only thing recorded.
The listener reads Payload[0] positionally. Azure.Identity arrives transitively and unpinned, so a parameter added or reordered ahead of credentialType needs no code change here to happen - the type-name shape check declines such a value at runtime rather than logging it, and this makes the move loud rather than merely survivable. Measured: the runtime does report PayloadNames for this event, so the name is readable where it matters.
It is not a control. An empty PayloadNames makes names[0] throw rather than pass, so the pin cannot go vacuous that way - measured by discarding the names and deleting the assertion, which still failed. The assertion earns its place by naming the cause instead of leaving an index-out-of-range to diagnose.
|
Reviewed this PR (Lite-only, C#, no T-SQL changes — the CONTRIBUTING.md T-SQL style rules don't apply here). Summary: Correctness — found and flagged inline (both call sites): Lite/Darling parity: No drift. Darling never builds an Azure-token connection at all ( Security: The event-id allowlist + payload shape-check design (reject anything without the shape of a dotted CLR type name) is sound and well-tested — it structurally excludes the sensitive sibling Minor/non-blocking: Test coverage for the listener/allowlist/shape-check/gate logic is thorough. |
The cost note said seven IsEnabled guards; at 1.18.0 there are 21, plus 10 when-IsEnabled switch arms. And a count in a comment is a partial list with a numeral welded on, so each one now names the version it was measured at, and the event histogram says it was counted twice off things that fail differently - the [Event] attributes at the tag, and reflection over the shipped assembly.
…cally Azure.Identity raises event 13 when it ACQUIRES a token, before SQL Server has accepted or rejected the identity that token names. So the case this feature exists for - DefaultAzureCredential picked the wrong ambient identity - arrives as a login failure with the selection already captured, and reporting only after a successful open dropped it. It is also the only chance to see it: the driver caches the credential the moment a token exists, so a retry raises nothing. Both call sites now report from a finally, and the call-site pin requires that finally between the attach and the report - Begin-before-open with Report-after-open was satisfied by the success tail. And the dedupe is claimed with Interlocked.Exchange rather than read then written. One raised event reaches every attached listener, and connection checks run concurrently, so two concurrent first connections capture the same name and a read-then-write pair lets both decide they are first.
Lite has around twenty SqlConnection sites reaching a monitored server and only two are instrumented, which is sound only because the connectivity check gets there first: CollectionBackgroundService.ExecuteAsync runs CheckAllConnectionsAsync before RunDueCollectorsAsync on every cycle including the first, and that loop is the only thing driving collection. A reorder there breaks observability with nothing to notice - the app connects normally, the listener attaches normally, and the event has already been raised and discarded by a collector, permanently, because it fires at most once per process. The remaining sites and what they cost are stated in the helper's remarks rather than implied.
…it today No comment there names either identifier now, so the strip is invisible - until someone reordering the loop writes a one-line ordering note, which is the case the pin exists for. Measured: inverted loop plus such a comment passes on raw text and fails on stripped text.
ReviewReviewed the diff ( Correctness
Lite/Darling parity
Security
No blocking issues found. Nothing to flag inline. |
|
Reviewed. This is a C#/Lite-only change (no T-SQL, so the collector-query style rules don't apply here). Correctness — the two instrumented call sites ( One thing worth being aware of (not a bug, and it looks deliberate): Security — the allowlist-on-event-id + shape-check-on-payload design is sound: only Lite/Darling parity — checked whether this needed a Darling counterpart. It doesn't: Darling's Style — Nothing blocking. Nice work on the mutation-testing coverage in the test files. |
Two later insertions anchored on the class declaration rather than above its doc comment, so each one pushed that comment further up and left the class undocumented behind two stacked summaries. XML docs take the LAST one, so tooling read correctly and only the file was misleading; Darling's whole-tree DocCommentHygieneTests caught it, on Lite source, from the Darling suite. The block is moved, not deleted - it documents a different member, which is what that guard's own message warns about.
ReviewThis is a well-scoped, observability-only change: an
One finding posted inline: I wasn't able to run 🤖 Generated with Claude Code |
…d to Two pins here drain AppLogger's process-wide buffer and assert on what came out, and one moves the process-wide minimum. LiteLogLevelGateTests created app-logger-statics for exactly that and named the condition that would make the hazard bite - a second class reading the log. This is that class, so it joins, and the name now serialises two classes rather than nothing. Its own remarks said this class is the only reader of the sink anywhere in the suite, which this PR made false. Corrected there rather than left as a claim someone would reason from: a class that only WRITES through the adapter still does not need to join, and adding it would serialise the suite for nothing. Two failure modes, both intermittent: DrainBufferedLines is destructive, so a concurrent sweep dequeues the line this class is about to look for and tag filtering cannot recover it; and those sweeps set the minimum as far as None, so the Information line this class expects admitted is never enqueued.
|
Reviewed. This is a tightly-scoped, purely additive observability change (one Checked and found no issues:
No blocking findings. |
Begin constructs a real EventListener over Azure.Identity's process-wide event source, and the sibling class raises real events on that same source - a raised event reaches every listener attached to it anywhere in the process. It changes no assertion there today, because those pins only ask whether Begin returned a listener and never read what one captured; the condition that would make it bite is reading SelectedCredentialType or calling Report. Joined to app-logger-statics rather than a new name: a class can be in only one collection, so a separate event-source name would have left the one pairing that matters unserialised. That widening is recorded in the collection's home file too, so the name is not the only thing describing what it covers. OrderingTests stays out and is the case that shows the rule is about REACH, not subject matter - it is entirely about this feature and reads source files only.
The acceptance criterion for the case where the chain found nothing, or where the driver's cache means this connection raised no event: it must not record a selection that did not happen - not an empty type name, not unknown, not a line at all at the default minimum. Only the repeat case was pinned at that level; the never-observed case was pinned only at a lowered one, so the property that matters on a shipped install was untested. The Debug explanation still exists and is still asserted at a lowered minimum, because an operator who goes looking should find the reason rather than silence. Those are two questions and only one of them is about the artifact.
ReviewScope: Lite-only observability addition — logs which Checked in detail:
No correctness, security, or parity issues found. The test suite ( One minor, non-blocking observation: |
The dedupe was justified by calling the selection a process fact. The chain it resolves from is process-wide, but the driver keys its credential cache on authority, scope, audience and client id - so two servers in different tenants get different DefaultAzureCredential instances and each raises its own event. Collapsing them when they pick the same type is still right, for a different reason: the type name is all that is recorded, so the second line would repeat it. Telling them apart would need the tenant or the server on the line.
|
Reviewed. This is a tightly-scoped, well-tested observability addition — no correctness issues found. Parity check: Confirmed this is intentionally Lite-only. Darling's Correctness:
Security: No secrets, tenant ids, or account details reach the log — verified by the test suite's negative controls, and the shape check independently rejects every sensitive sibling payload shape on a character a CLR type name can't contain. Nothing blocking. Only note is what the PR body already discloses: unverified against a live Entra tenant (needs a Windows host + real tenant), which is a pre-existing limitation of this whole auth mode, not something introduced here. |
The Azure credential-recording entry led with [#3218], the pull request it is a follow-up to, which resolves to a different change. No issue exists behind #3224, so under the file's rule -- issue numbers where an issue exists, pull-request numbers where one does not -- its own number belongs in the citation position, with the follow-up relationship kept as prose. #3224 was the only one of the ten merged pull requests with no issue behind it whose number went uncited. The [#3218] definition is swapped for [#3224] rather than added, since nothing else cited it.
…d pull requests, and strike an unsound claim from #3199's (#3232) * Record the CHANGELOG entries for seventeen changes across ten merged pull requests Applies the [Unreleased] entries for the pull requests merged to dev after #3213's batch pass, and strikes an unsound arithmetic claim from the #3199 entry that pass shipped. CHANGELOG.md only: 17 entries prepended inside [Unreleased] -- 5 under Added, 12 under Fixed -- plus the 15 link-reference definitions they need, and one edited line. Entry text comes from each pull request's own body where it carried one. * Cite #3224's own number on its entry, not the pull request it follows The Azure credential-recording entry led with [#3218], the pull request it is a follow-up to, which resolves to a different change. No issue exists behind #3224, so under the file's rule -- issue numbers where an issue exists, pull-request numbers where one does not -- its own number belongs in the citation position, with the follow-up relationship kept as prose. #3224 was the only one of the ten merged pull requests with no issue behind it whose number went uncited. The [#3218] definition is swapped for [#3224] rather than added, since nothing else cited it. * Absorb #3230, which merged mid-batch #3230 was open when this batch was cut and merged at 19:31:55Z while it was being verified, making it the newest merge in the range. origin/dev is merged in rather than rebased, so e459539 stays intact in the history. Its body carries no entry text and neither queue directory holds any, so the entry is written here from the description. No issue exists behind it, so it cites its own number, and its definition takes its ascending place between [#3226] and [#3231]. * Take #3230's entry from its own CHANGELOG block, not its measurement prose The entry was written from #3230's description before that description carried an entry-ready block, and the description's measurement section was taken at c9f04f3 -- before #3227's rebase moved Deadlock.cs LastTranStartedLocal onto FormatServerClock, which took it out of both the inventory and the string-literal subset. KnownTruncatedRanges has 30 entries; the retired entry said 31, and carried 13, 544, 510, 2,238 and 42,927 besides. The 544 and 510 predate the content-trim and cannot be recomputed from shipped code at all, and the file and declaration totals move with every commit. The lane's own block names only what shipped, so it replaces the entry wholesale rather than the numbers being patched. Its one quantity is cross-checked against the shipped array by the verification battery, so the entry cannot restate a count the code does not have.
EntraDefaultCredentialsigns in as whichever Azure identity the machine already has, in an order the driver owns and this app cannot narrow. #3218 disclosed that in dialog text and in the README, and recommended instrumenting it as a follow-up. This is the follow-up: the credential typeDefaultAzureCredentialactually selected, in the log, and nothing else.Observability only. No change to the connection string or the credential chain, and the only change to control flow is a
try/finallyaround each open whosefinallywrites a log line.The upstream event, re-verified at the tag rather than at
mainRead at tag
Azure.Identity_1.18.0— the version the repository resolves, transitively, throughMicrosoft.Data.SqlClient.Extensions.Azure7.0.2 (#3219). All four line citations I was handed check out exactly, which is worth saying because amain-vs-tag mismatch cost this queue three corrections in one day:Azure.Identity_1.18.0Credentials/DefaultAzureCredential.cs:178raisesDefaultAzureCredentialCredentialSelected(credential.GetType().FullName)on the success pathAzureIdentityEventSource.cs:18— source nameAzure-IdentityAzureIdentityEventSource.cs:34— event id13AzureIdentityEventSource.cs:307—Level = EventLevel.Informational, onestring credentialTypeAn allowlist, because the level cannot narrow this and keywords do not exist here
The brief that reached me said event 13 shares
Informationalwith 18 other events. The count is 19 others, and the framing understates the problem in a way that matters:EventListener.EnableEvents(source, level)admits every event at or above the requested severity, soInformationaldelivers 28 of that source's 29 events — 20Informational, 4Warning, 2Error, 1Critical, 1LogAlways. Only the singleVerboseevent is excluded. So the level admitsMsalLogError,MsalLogCritical,MsalLogAlwaysandProcessRunnerErroras well as theInformationalsiblings that carry tenant ids, account details, scopes, parent request ids and a formattedException.And no event in that file declares
Keywords— 0 of 29 — soEnableEventshas no dimension to exclude them on.Every count above was taken twice, off two things that fail differently: parsing the
[Event]attributes in the source at tagAzure.Identity_1.18.0, and reflecting over the shippedAzure.Identity.dllin the harness's own closure. They agree exactly — 29 events, 20Informational, 28 delivered atInformational, 0 withKeywords— and the second instrument also confirms the assembly is1.18.0+05d48fdf, so the line numbers and the histogram are about the same build. The callback filter is the only barrier, which is why it is an allowlist onEventId == 13and not a denylist: a futureAzure.Identityadding an event is precisely where a denylist fails and an allowlist survives.Two barriers, not one. The allowlist guarantees the event; a shape check guarantees the value taken from it.
Payload[0]is forwarded only if it looks like a namespace-qualified CLR type name — which rejects every sensitive sibling payload on a character a type name cannot contain: a tenant id has-, an account UPN has@, a scope has:and/, a message has spaces. That is the barrier that survives a payload reorder, which the id allowlist alone does not. NevereventData.ToString(), never the payload collection, neverMessageformatted with its arguments.The sharpest evidence that the id is doing the work:
ManagedIdentityCredentialSelected(event 26,Informational) has a first payload slot that is also calledcredentialTypeand also holds a real credential type name. It passes the level, the source name and the shape check. Only the id keeps it out, and a test raises it to prove that.Both lifetime decisions
The listener's window is one connection open, not the process. While the source is enabled,
AzureIdentityEventSource.IsEnabled(Informational, …)answers true insideAzure.Identityand it performs the formatting work its sevenif (IsEnabled(…))guards otherwise skip. A monitoring tool that left it on would pay that on every token operation for the life of the process, forever, to learn a fact that can only be reported once.Beginis called immediately before the open; the listener is disposed immediately after it, because an undisposedEventListenerkeeps receiving events. The cost of the narrow window is that an event raised outside it is missed — which is why both of Lite's connection-open sites carry one rather than only the dialog a user is looking at.Only the last reported name outlives the attempt. The listener is gone and the captured value is read once. One static string exists so an unchanged repeat is
Debugrather thanInformation: the selection is a process fact, and a monitoring tool restating it once per server per sweep would be noise. It is claimed withInterlocked.Exchangerather than read and then written — one raised event is delivered to every attached listener andCheckAllConnectionsAsyncchecks servers concurrently, so two concurrent first connections capture the same name and a read-then-write pair lets both decide they are first. A changed name still reports atInformation— the driver clears its credential cache (ActiveDirectoryAuthenticationProvider.cs:136-138), so a different source genuinely can start winning, and that is a different identity connecting.Why it reports at most once, stated rather than discovered. Two process-wide caches sit above the event.
DefaultAzureCredentialraises event 13 only on the branch with no cached credential (DefaultAzureCredential.cs:168-179); once_credentialLockholds a value, later requests take theHasValuebranch and raise nothing. And SqlClient caches theDefaultAzureCredentialinstance in astaticmap keyed by authority, scope, audience and client id (ActiveDirectoryAuthenticationProvider.cs:29,:258-262). So the event fires at most once per process per key, and a second connection to the same tenant reports nothing at all. That absence is aDebugline saying why, not silence.Two sites out of about twenty, and that is sound for a reason rather than by hope. Lite has roughly twenty
new SqlConnectionsites that reach a monitored server. Only two are instrumented, which only works because the connectivity check gets there first — and it does, by construction:CollectionBackgroundService.ExecuteAsyncrunsServerManager.CheckAllConnectionsAsyncbeforeRemoteCollectorService.RunDueCollectorsAsyncon every cycle including the first, and that loop is the only thing that drives collection. The interactive first-touch paths are the dialog's Test button andMainWindow's explicit retry, and both go through an instrumented open. A reorder of that loop would break observability with nothing to notice — the app connects normally, the listener attaches normally, and the event has already been raised and discarded, permanently — soEntraCredentialSelectionOrderingTestspins it.What that still does not cover, said rather than implied: the bulk-add dialog, the excluded-databases dialog, a server tab's ad-hoc reads and the plan fetcher all open connections too. Each needs an already-configured server, so in practice the sweep has run first; a user who drives one inside the background service's five-second startup delay could acquire the first token there. The consequence is the
Debug"not observed" line rather than a wrong one, which is the direction to fail in, and it is why that line names the caching as the expected cause instead of asserting it.Reported on the failure path as well, and that is where it matters most.
Azure.Identityraises event 13 when it acquires a token — before SQL Server has accepted or rejected the identity that token names. So "DefaultAzureCredentialpicked the wrong ambient identity", which is the exact complaint this feature exists to answer, arrives as a login failure with the selection already captured. Reporting only after a successful open dropped precisely that case; both sites now report from afinally. It is also the only chance to see it: the driver caches the credential the moment a token exists, so a retry raises nothing at all.Reporttakes nothing but the listener — it has no success argument to get wrong — and it does no I/O, so afinallycannot mask the connection's own exception. There is deliberately no blanketcatchin it: one would make the feature silently dead at the moment it broke.Which paths log, and which deliberately do not
"Logs on success and on failure" is true and is not the claim. The claim is it logs whenever a selection occurred, on either path, and it never records a selection that did not occur.
Information, naming the credential type — orDebug"(unchanged)" if it repeats a name already reported this processDebugline naming the caching as the expected cause, carrying no credential name.Debugis below the default minimum, so on a default install this path writes nothing at allPayload[0]failed the type-name shape checkDebugline saying the payload was not a type name, not carrying the valueEntraDefaultCredentialBeginreturnsnull,Reportno-ops, and the event source is never enabledSqlConnectionsites (bulk-add dialog, excluded-databases dialog, server-tab ad-hoc reads, plan fetcher)Three pins hold the "never records a selection that did not occur" half, each falsified: the not-observed line written at
Informationinstead ofDebug(Failed: 6), that line rewritten to claim a selection and name itunknown(Failed: 3), and the refused-payload line rewritten to name the refused value (which cannot leak, for a structural reason recorded in the file). TheDebugexplanation is deliberately not silence — an operator who lowers the level and goes looking should find "the event fires once per credential instance and the driver caches the instance" rather than an absence they have to explain.No server name on the line. The selection is scoped to the process and the driver's cache key, not to a server: the chain resolves from environment variables, an
az loginsession and machine identity, none of which are per-server. Naming the server that happened to observe it would assert a per-server fact that is not true.The gate keys off the builder, not the mode string.
SqlAuthenticationMethod.ActiveDirectoryDefaultis the keyword the driver acts on, so the gate cannot disagree with the connection string — and it follows a credential profile into this mode for free if one is ever offered there (today profiles offer SQL, service principal and managed identity only). The one degree of indirection that buys is closed by a test that runs the realServerConnection.ApplyAuthenticationfor every mode reflected offAuthenticationTypesand asserts exactly one attaches a listener.How the pin is falsified in both directions
Every pin raises the real event on the real event source:
Azure.Identity'sAzureIdentityEventSourceisinternal, so it is reached by reflection and its actual methods are invoked. The id, the level, the payload shape and the source name are the ones that ship.OnEventSourceCreatedis reached from theEventListenerbase constructor: the path on which a derived field assigned in a constructor body is still null and the listener silently never enables. Both values that callback needs areconstfor exactly that reason.ManagedIdentityCredentialSelected, whose payload passes every check but the id;TenantIdDiscoveredAndUsed, which carries tenant ids;UserAssignedManagedIdentityNotSupported, atWarning), none forwarded — then event 13, which must be. A listener that simply received nothing fails the last assertion, so the first three are evidence of filtering rather than of silence.Azure-Identityand nothing else — a bystanderEventSourcemust be un-enabled whileAzure-Identitydemonstrably is. A dropped name check would turn this into a listener that enables every source in the process, and every other pin here would stay green.[Event]id and level read off the shipped assembly; and that no event declaresKeywords, with the event count as the control. This is the pin that makes Azure.Identity is unpinned and transitive, so #3218's broker-free guarantee rests on a dependency-graph fact nothing tracks #3219's unpinned transitive dependency loud instead of silent.credentialType, by NAME, on a real written event — read offEventWrittenEventArgs.PayloadNamesrather than the reflected signature, because the name the runtime attaches to that position is what the positional read actually gets. The runtime does report payload names for this event (measured). A parameter added or reordered ahead ofcredentialTypeis declined at runtime by the shape check; this is what makes it loud rather than merely survivable.StripCommentsAndStrings' output, because both sites carry a comment namingEntraCredentialSelectionLogand a raw substring search would be satisfied by the comment with every call deleted (Record and classify Entra MFA broker failures instead of showing one line and logging nothing (#3196) #3201's defect). Three discriminating assertions:Beginbefore the open (aReportplaced first captures nothing forever while reading as fully instrumented);Reportafter it; and atry/finallyin the window between them, becauseBegin-before-open plusReport-after-open is satisfied by aReporton the success tail. Asserted on the window rather than the method body on purpose —RunConnectionTestAsyncalready had an unrelatedfinallyfor re-enabling its buttons, which would have satisfied a body-wide check.AppLogger, at the default level — the first observation must reach the log; the unchanged repeat must not. Nothing else would catch aReportthat wrote every line atDebug: the messages would still be correct and the whole feature would be invisible on every shipped install.Mutation table
Baseline before every mutation, in a
net10.0xunit.v3 harness compiling the real shipped.csunderAssemblyName=Lite.Tests(soInternalsVisibleToapplies) with versions fromDirectory.Packages.props:Total: 57, Errors: 0, Failed: 0, Skipped: 0, Not Run: 0for the table below, andTotal: 58, Errors: 0, Failed: 0, Skipped: 0, Not Run: 0after the payload-name pin was added, for the three rows that follow it.Each mutation was applied by unique-anchor byte replacement, confirmed present by
git diff --numstatand by content occurrence count, rebuilt, run, restored withgit checkout --, and confirmed gone by content plus an emptygit status.EventIdremovedOnEventSourceCreatedconstinto a constructor-body field, read from the callbackReportwrites every line atDebugReportnever persists the reported nameBeginreturns a listener for every authentication methodDecidereports an unchanged repeat atInformationDecidetrusts the listener's bool instead of re-reading the ruleServerManagerreports BEFORE the openAddServerDialog's whole scope deleted, its explaining comment keptRaise()made a no-op, so no real event is written>= 3→>= 0AND the listener never enablesServerManagerreverted to reporting on the success tail (the defect review found)AddServerDialogreverted to reporting on the success tailReportgains a success flag and gates on itfinallyassertion reads the whole method body instead of theBegin→Reportwindow, and the dialog revertsInterlocked.ExchangeServerManagerstops attaching the listener at allBaseline:
Total: 59for thefinallyrows,Total: 61for the ordering rows,Errors: 0, Failed: 0, Skipped: 0, Not Run: 0in both cases.Re-run in full against the head, after the
finallymoveMoving a call changes which paths the pins cover, so the whole table was regenerated against the current source and re-run rather than assumed to survive — 27 mutations, baseline
Total: 61, Errors: 0, Failed: 0, Skipped: 0, Not Run: 0. 23 red. The four green are the same four disclosed above (Interlocked.Exchange, and the three instrument demonstrations); nothing went vacuous from the move, and in particular the success-tail revert, the whole-scope deletion and the success-flag mutation are all red at both sites.Three more on the no-selection path, baseline
Total: 62:Informationinstead ofDebugunknownThe ordering rows are the clean three-way discrimination, and getting there took a correction worth recording: my first attempt at inverting the loop moved the collector call to just after the connection check and I read the resulting green as a vacuous pin. It was a vacuous mutation. The real inversion fails (Failed: 1); the same inversion with the strip removed and one comment added naming
CheckAllConnectionsAsyncpasses (Failed: 0); and the same inversion with the strip and the comment fails again (Failed: 1) — so it is the strip, not the absence of the comment, doing the work. No comment in that file names either identifier today, so the strip is invisible until someone reordering the loop writes a one-line ordering note, which is exactly the case the pin exists for.Three more rows are green and each is disclosed rather than explained away. The body-wide
finallycheck is the demonstration that the window matters:RunConnectionTestAsync's own button-re-enablingfinallysatisfies it while the selection is dropped on every failed open. And theInterlocked.Exchangeclaim is not pinned by anything — the race is two concurrent first connections capturing one delivered event, which is not reproducible on demand, so the argument is the exchange's atomicity and the sequential form of the same path is what the suite covers. Said here rather than left to look tested.The keywords row is green on purpose and is the demonstration, not an escape: with the event-count control removed and the enumeration aimed at a type carrying no events, "no event declares
Keywords" passes while asserting nothing. That control is why the real pin is not decoration.Two pins went green that should have been red, and mutation is what found them
Every rejection case for the shape check was also missing a dot, so the dot requirement alone rejected it and the character allowlist was never under test — widening it by a hyphen left all 44 tests green. Fixed by ten rows that are each a well-formed dotted name differing from an acceptable one by exactly one illegal character, so each character in the allowlist is load-bearing.
Decidere-reads the shape rule rather than trusting the listener's verdict, and nothing exercised that re-read — keying the refusal off the bool alone was green. Fixed by handingDecidea value that is not a type name withrejectedPayload: false, which is the shape a listener that stopped applying the rule would produce.A third mutation — interpolating the refused value into
Decide's message — also went green, and that one is not a gap: the refused value never leavesOnEventWritten(the listener stores a bool, not the string), soDecidestructurally cannot name it. The test'sDoesNotContainthere is a consequence of the design rather than an independent check of it, and now says so in the file.Mutation also corrected a claim I had written about a test. I had called the payload-name pin's
names.Count > 0assertion a control against a vacuous index check; discarding the names and deleting the assertion still failed, becausenames[0]on an empty list throws. It is not a control — it names the cause instead of leaving an index-out-of-range to diagnose — and the file now says that rather than the flattering version.Counts in comments
Every count in the new file names the version it was measured at, because a count in prose is a partial list with a numeral welded on. One was simply wrong on the first pass: the cost note said
Azure.Identityhas sevenif (IsEnabled(…))guards, and at 1.18.0 it has 21, plus 10when IsEnabled(…)switch arms. Corrected, and the event histogram now records that it was counted two ways.Review
Third round.
claude[bot]found thatEntraCredentialSelectionTestsreads and mutatesAppLogger's process-wide statics — it drains the buffer and asserts on what came out, and one pin moves the minimum — without joining theapp-logger-staticsxUnit collection thatLiteLogLevelGateTestscreated for exactly that. Verified: that class's own remarks state the invariant this PR falsified, in so many words — "this class is the only reader of the sink anywhere in the suite" — and name the trigger, "the condition that would make it bite is any of those five reading the log, and then they joinapp-logger-statics".Both mechanisms are real and neither is reproducible on demand:
DrainBufferedLinesis destructive, so a concurrent sweep dequeues the line this class is about to look for and tag-filtering on the log source cannot recover it; and those sweeps set the minimum as far asNone, so theInformationline this class expects to be admitted is never enqueued at all. Two green CI runs do not disprove a flake. Fixed by joining the collection — which means the name now serialises two classes rather than nothing — and by correcting the invariant inLiteLogLevelGateTests' own remarks, rather than leaving a false claim for the next person to reason from. And the listener needed the same collection, for a second reason. TheEventListener/EventSourcepair is process-wide too: enabling a source is a global effect, and a raised event is delivered to every listener attached to it anywhere in the process.EntraCredentialSelectionModeGateTestscallsBegin, so it constructs real listeners on the same source thatEntraCredentialSelectionTestsraises real events on — so it joins, not for theAppLoggerreason (it touches none) but because a class can belong to only one collection, and a separate name for the event-source hazard would have failed to serialise it against the class that matters. It changes no assertion there today, because those pins only ask whetherBeginreturned a listener and never read what one captured; the condition that would make it bite is readingSelectedCredentialType/RejectedPayloador callingReport. That widening is recorded in the collection's home file as well, so the name is not the only thing describing what it covers.Measured rather than assumed: these are the only
EventListenersubclasses and the onlyAzure-Identityconsumers anywhere in the repository, so no third party can observe the events raised here today.EntraCredentialSelectionOrderingTestsstays out, and is the case that shows the rule is about reach rather than subject matter: it is entirely about this feature and reads source files only — noAppLogger, no listener, no event source, nothing process-wide to share.First round.
claude[bot]found a real defect and it is fixed above:Reportreached only the success path, which drops the selection in the one case the feature exists for. Both call sites now report from afinally, and the call-site pin was extended to require it — the previous pin passed with the defect restored, which is the part worth recording.Its second finding —
s_lastReportedwritten with onlyvolatilewhileCheckAllConnectionsAsyncruns concurrently — is also fixed, though the analysis was slightly off in a way that makes it worse than "cosmetic": one raised event is delivered to every attached listener, so two concurrent first connections do not merely race on the write, they both legitimately capture the same name.Interlocked.Exchangenow claims it.The one CI failure, and what it says about local verification
buildwent red onc849c9f13atDarling.Tests.DocCommentHygieneTests.NoMemberCarriesTwoStackedSummaryBlocks— pointing atLite.Tests/EntraCredentialSelectionTests.cs:792.Run Lite testsin the same job passed; the failure was Darling's whole-tree doc guard reading Lite source.The cause is worth naming because it is a mechanism, not a typo: two separate later insertions anchored on
private sealed class RecordingListener : EventListener— the class declaration — rather than on the line above its doc comment. Each insertion therefore pushed that comment further up and left the class undocumented behind a stack of summaries. XML docs take the last summary, so tooling read correctly and only a human reading the file was misled. The guard's own message warns against deleting the first block for exactly this reason, so it was moved back onto the member it documents, not removed.It also exposed a gap in my local verification, now closed: I had been running my own suite and not the whole-tree guards that read my files from the other app's suite. Fixed by compiling the real
DocCommentHygieneTests.csplusCSharpSourceWalker.csinto a throwawaynet10.0console shim — the guard walks up from the test binary looking forPerformanceMonitor.sln, so the shim has to live inside the tree (Darling/tools/pg-harnesses/, which is gitignored). 76 of that class's 77 tests pass; the 77th,TheDerivedProjectListCoversEveryProjectInTheTree, fails naming the shim's own.csproj, because it enumerates every.csprojon disk. The shim was deleted afterwards for that reason — leaving it would fail an unrelated real guard on the next local run.Verified red-first: restoring the displaced state fails with the same two line numbers CI reported (792, 801); the fix passes.
What only CI can confirm, and what nobody here can
Lite.Teststargetsnet10.0-windowsand cannot run on macOS.EntraCredentialSelectionModeGateTestsis a separate file because it needsServerConnection, whose closure reaches Windows-only credential storage — so the mode →ApplyAuthentication→ gate link ran inbuildonly, not locally. Everything else ran locally against the realAzure.Identity1.18.0 assembly.Execution in CI confirmed by test-count delta, since MTP prints no
Passedlines:Lite.Testsreports 3568 ondevat91c0a919a(run 34371264224) and 3631 here,Failed: 0, Not Run: 0. The delta of 63 is exactly 19[Fact]+ 30[InlineData]rows + 10[MemberData]rows inEntraCredentialSelectionTests, 2 inEntraCredentialSelectionOrderingTestsand 2 inEntraCredentialSelectionModeGateTests.And a note on which check to read:
Darling Linux build(11s) andDarling PostgreSQL tests(17s) both take build.yml's path fast path, because the diff touches noDarling/**and no shared project. Their green says nothing about this change.buildis the check that ran my content, at ~8.5 minutes.And the part no test reaches: that a live Entra tenant issues a token to a real
az loginsession through this path, and that the type name which then lands in the log is the one a user would recognise. Still unverified against a live tenant — the limit #3196, #3214 and #3218 all had.CHANGELOG
Not edited, per lane convention. Entry text:
DefaultAzureCredentialselected for an Existing Sign-In (az login) connection ([Add a broker-free Entra sign-in path: ActiveDirectoryDefault returns before the WAM broker is built (#3214) #3218] follow-up, and see [Azure.Identity is unpinned and transitive, so #3218's broker-free guarantee rests on a dependency-graph fact nothing tracks #3219]) - the mode signs in as whichever identity the machine already has, in an order the driver owns and the app cannot narrow, which the connection dialog and the README warned about and nothing recorded. A narrowly-scopedEventListenerenablesAzure.Identity'sAzure-Identityevent source for exactly the duration of one connection open, forwards event 13 and nothing else, and writes the selected credential's type name -AzureCliCredential,EnvironmentCredential,ManagedIdentityCredentialand so on - throughAppLogger. The filter is an allowlist on the event id rather than a level or a keyword because neither can narrow this: event 13 is itselfInformational, so that is the lowest level which delivers it,EnableEventsadmits everything at or above the level requested (28 of that source's 29 events), and not one event in it declaresKeywords- so the callback filter is the only barrier between the app's log and the siblings carrying tenant ids, account details and raw exceptions.Payload[0]is the only value read and only if it has the shape of a CLR type name, which is what survives a future payload reorder. Expect one line per run of the app:Azure.Identityreports the selection once per credential instance and the driver caches that instance in a process-wide static, so later connections have nothing new to report and say so atDebugrather than silently. Observability only - the connection path, the connection string and the credential chain are unchanged, and no other authentication mode enables the event source or pays anything for this