"Mailbox not connected" is sometimes a lie

Issue Systemorph/MeshWeaver.Plugins#1398, measured on memex.meshweaver.cloud on 2026-09-06. An Executive Assistant round ran ~20 parallel SearchMail calls successfully, went silent, and then re-executed the same message from scratch — at which point every mail tool answered

I don't have access to your mailbox and calendar yet. Please connect them here: /auth/ea/connect

while Auth/_EaCredential/rbuergi was present, carried a refreshTokenEncrypted, held the right scopes, and had been refreshed two and a half minutes earlier. A new thread on the same portal, with no user action in between, worked again five minutes later.

This page records the reading of that path. The credential-read half is fixed and shipped — core MeshWeaver#3446 landed the seam and this repo's #1416 landed the consumer, both on 2026-09-06. A read that does not complete is now its own answer and no longer offers a consent link.

Where the architecture lives now. The durable description of the seam — the three connection states, why every entry point returns IObservable<T>, and the three sites that collapsed into one null — is core's /Doc/Architecture/ExecutiveAssistantCredentialReads. Read that for how it works. What is kept here is the incident reading: the measurement, what was excluded and how, and the parts of #1398 nobody has explained yet.

Still open. Plugins#1398what re-drove the round, and why the thread node never left Executing. Neither is explained here and neither is touched by the fix above.

The path a mail tool took, per call

(As it stood on 2026-09-06. The seam is now IObservable<EaGraphAccess> IEaGraphAuth.GetAccessToken. The …Async names below are the pre-#3446 ones; they survive as transitional default-implementation forwarders on the interface — deliberately not [Obsolete], so this repo's -warnaserror build does not break on them — and core's HubReachableAsyncGuard carries them as debt until the last caller moves.)

Every tool in ExecutiveAssistantPlugin opened with ClientAsync(), which called IEaGraphAuth.GetAccessTokenAsync. That lands in core, memex/Memex.Portal.Shared/Authentication/EaGraphAuth.cs, and did all of this, every time:

  1. LoadAsync — read the credential node Auth/_EaCredential/{user};
  2. PostTokenAsync — a fresh grant_type=refresh_token redemption against Entra;
  3. on rotation (Entra returns a new refresh token essentially every time) StoreAsynca write of that same node, itself preceded by another LoadAsync.

There is no token cache anywhere on this path. Nothing is remembered between calls — and that is still true after the fix, deliberately: see What landed below.

The lie (the code as it was)

// LoadAsync
node = await ws.GetMeshNodeStream(PathFor(userObjectId))
    .Take(1).Timeout(TimeSpan.FromSeconds(10)).FirstAsync() …
…
catch (Exception ex)
{
    logger?.LogWarning(ex, "EaGraphAuth: load failed for {User}", userObjectId);
    return (null, null);          // a FAULT, shaped exactly like an ANSWER
}

// GetAccessTokenAsync
var (_, cred) = await LoadAsync(userObjectId, ct);
var refresh = protector.Unprotect(cred?.RefreshTokenEncrypted);
if (string.IsNullOrEmpty(refresh)) return null;   // → "not connected"

A 10-second read timeout — or any other read fault — is caught, logged at Warning, and returned as (null, null), which is byte-for-byte indistinguishable from "this user has never connected". The caller has no way to tell the two apart, so a transient becomes a confident, wrong statement about the user's account, and the round's twenty successful searches are discarded to make it.

This is the fail-closed fallback that forges a correct-looking bug: a guard that fails safe must still fail legibly. Refusing to act on an unreadable credential is right; claiming the credential is absent is not. That is the defect #3446 removed.

A second mechanism — which the author later RETRACTED as required

The faulting round issued ~20 parallel SearchMail calls. By the shape above that is ~20 concurrent Entra redemptions and up to ~20 writes of ONE mesh node — whose owning hub is single-threaded — with ~20 more reads queued behind them. The credential write observed at 12:29:49.375 (version 101) is one of those, landing 372 ms after the round went silent at 12:29:49.003.

🚨 This page originally led with that reading, and leading with it pointed the fix at the wrong place. On MeshWeaver#3433 the issue author retracted it:

The root cause is async/await/Task<T> in hub-reachable code. Contention is not needed to explain the failure at all. […] My "~20 parallel calls contend on one single-threaded node hub" framing was a plausible-sounding second mechanism that is not required to produce the observed failure — and stating it first pointed the fix at a token cache when the fix is to stop being async.

The contention above is real arithmetic and is kept for that reason. It is not the explanation, and no token cache was added.

What was excluded, and how

Hypothesis Verdict Why
The plugin caches the refresh outcome and latches a transient failure (the ReplaySubject shape of Plugins#1369) Excluded There is no cache on this path at all — load, redeem and (on rotation) store run in full on every single tool call. Nothing is remembered, so nothing can latch.
Refresh-token rotation raced the faulting turn and the stored token lost Weakened; not the cause The redemption sends a client_secret, i.e. a confidential client. Single-use refresh tokens with reuse detection are the public/SPA client behaviour; for a confidential client the previous refresh token stays valid, so a lost StoreAsync does not invalidate the credential. Rotation matters here for the node contention it creates, not for token validity.
The credential was genuinely absent or its content unreadable Excluded by direct read Read at 12:35: node present, refreshTokenEncrypted present, scopes correct, acquiredAt 12:29:49.
Entra answered non-2xx and PostTokenAsync returned null Not excluded It fits the evidence equally well and is distinguishable only in the log. It is still a transient rendered as "you never consented", so the same fix applies either way.

The one measurement that settles it

Both surviving candidates log at Warning, from the same class, in the same window:

{app="memex-portal"} |= "EaGraphAuth", 2026-09-06 12:29:40Z–12:33:00Z. Whoever reproduces this should capture it before the window rolls — it names which of the two, and it costs one query.

🚨 It has NOT been run, and one nearby zero must not be quoted as if it had. No Loki read was made for this page. What was run is a mesh query against the Hosting/LogEntry mirror — namespace:Hosting scope:descendants nodeType:LogEntry EaGraphAuthcount: 0 — and that is not evidence the log line is absent: that mirror is populated by the module's ingest-logs script over a bounded window rather than by a live tail, so a zero there carries no denominator and no statement about what the window covered. When the Loki query is finally run, use an explicit start/end plus an oldest-line control: since= is capped in practice at about an hour and has returned a false zero for a window it silently never reached.

🚨 Can a re-executed round duplicate a user-visible effect?

Asked because #1398's other half is a round that re-executes from scratch on the same message.

The 2026-09-06 round happened to be read-only, so nothing was duplicated. The same fault on a drafting round would have duplicated.

What landed

One defect, one fix, and it is not the one this page led with.

Stop reporting a fault as an answer. The read now distinguishes "there is no credential" from "I could not read the credential", and the tool says which. IEaGraphAuth answers IObservable<EaGraphAccess> carrying a three-state EaConnectionConnected, NotConnected, Undetermined — and ExecutiveAssistantPlugin.ClientAsync branches on it:

Sending someone to re-consent is harmless in itself; telling them their mailbox is not connected when it is — and discarding the round's work to say it — is not.

All three collapsing sites went with it, not just the outer catch: the two catches and the _ => null switch arm, which was never an error handler at all but a hand-rolled shape test, now replaced by the house accessor. Core's page carries that table.

The seam is reactive end to end (IObservable<T>, IIoPool for the Entra POST, never Observable.FromAsync), and the one bridge left is the consent controller's, through ObserveCompletion — never .ToTask(). ExecutiveAssistantPlugin also reports a credential fault that arrives after its wait has settled, rather than orphaning it.

🚨 No token cache was added, and none is wanted for this. The "acquire once per round instead of once per tool call" item this page used to lead with was the retracted framing; per the author's own comment the fix is to stop being async, and contention is not needed to explain the failure. If a round-scoped acquisition is ever wanted it is a performance change on its own merits, argued separately — an instance cache keyed by the acting identity, misses sharing one in-flight acquisition through IIoPool (pool.Run in a promise slot — never a SemaphoreSlim, never a bare ConcurrentDictionary<key, IObservable<T>>), with no entry outliving the token's exp.

Coverage. ExecutiveAssistantConnectionStateTests (#1416) asserts the three states through ExecutiveAssistantPlugin with a stub IEaGraphAuth: collapse Undetermined into NotConnected and the tests go red. That is one of the module's two consumers of the seam — GraphEmailSender's probe and its SendAsUserAsync path are still uncovered, tracked on #1428.

Still unexplained (do not read this page as a full diagnosis of #1398)

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.