Skip to content

fix(#2543): a response continuation no longer runs on the responding hub's action block - #3759

Closed
rbuergi wants to merge 5 commits into
mainfrom
diag/2543-stale-callback-names-the-target-hub
Closed

rbuergi wants to merge 5 commits into
mainfrom
diag/2543-stale-callback-names-the-target-hub

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Node CRUD in a mesh was serialised through one actor's queue. Every create/delete/move/copy
without an opted-in ancestor executes on portal/nodeops-<meshId>, and a create parked in its
validator held that hub so completely that a second, unrelated create posted to the same hub sat
unprocessed at Queue(buffer=1,…) for its entire budget.

The root cause is not in the node-CRUD path. A response subject is signalled from inside the
turn that handled the response, and Rx runs a continuation on the thread that signalled it — so
every hub.Observe(x).SelectMany(…) chain in the framework ran its whole remainder on that hub's
action block, inside that turn
, and the turn could not end until the chain did.

The create handler is detached and correct — HANDLER_EXIT state=Processed at +1 ms on the fate
trail. The pipeline then climbed back onto the block when its partition bootstrap's nested
response came home, and ran validators, save and change feed there. Delete, move and copy inherit
the same shape, which is why one line fixes all of them.

MessageHub.ContinueOffBlockRestoringUserContext now hops the continuation off the block before
restoring the caller's identity. The order matters: .Do(SetContext) must run on the thread
that will run the continuation, or the chain resumes unauthenticated.

This PR started as the opposite claim — that half is corrected, not deleted

It originally argued that the create pipeline never holds the block, reading Executing(T, N ms) as
time-on-the-block. That field is cleared in the handler observable's .Finally, so it measures
the handler's in-flight lifetime, including hops the chain already left the block for. With a
create parked it read Executing(CreateNodeResponse, 36001ms) and tracked the observer's own
sampling window exactly.

So #2543's Executing(CreateNodeRequest, 24888ms) never established that the block was held for
24.9 s
— the buffer=45 beside it is the part that was real. BakeSealNodeOpsSaturation.md keeps
the original analysis and adds a correction section saying which two inferences failed and how they
were falsified; the drainsInFlight table there inherits the same error and is corrected with it.

Evidence

Measured behaviourally, because the snapshot fields cannot decide it:

test hop disabled hop enabled
probe: an independent create completes while one is parked FAIL PASS
TwoCreates_AreInFlightSimultaneously (two validators entered before either is released) FAIL PASS
positive control: the same probe with nothing parked PASS PASS

The control passing in both worlds is what makes the other two mean "the pump is held" rather than
"this probe never completes anyway". Both now fail deterministically in isolation (11 s / 21 s)
rather than only under assembly load, which is how the original red hid: isolated it passed 10/10.

Verification

  • 4,426 tests, 8 projects, 0 failures — Graph 1454, Compiler.Pipeline 735, Hosting 514,
    Data 496, Layout 455, Messaging.Hub 383, Documentation 361, ContentCollections 28
  • Full solution dotnet build -c Release -p:CIRun=true -warnaserror — 0 Error(s), 0 Warning(s)

Blast radius, stated plainly

This changes continuation threading for every request/response pair in the framework, not just
node CRUD. Anything that implicitly relied on continuations being serialised per-hub can now run
concurrently. That is the point of the change, and it is why the verification above is broad rather
than scoped to the touched path.

Related: #2543.

…G ON

#2543 has needed `portal/nodeops`' own queue state since 2026-08-28 and has had
exactly one capture of it. Not because the state is hard to read — because no
diagnostic in the system prints the TARGET's.

Measured over every CD bake job 2026-09-07T00:00Z -> 2026-09-08T18:45Z (61 jobs,
61/61 logs fetched, 1,512,136 lines):

  * the `Hub <addr> ... exec=...` form (GetPendingRequestDiagnostics) fires 0 times
    — it is written on a disposal stall, which a mesh saturating mid-bake never
    reaches. `exec=` appears nowhere, so searching for it reads as "no dumps".
  * the form that DOES fire is BuildTimeoutMessage's `This hub: ...` clause —
    34 occurrences in 20 of 61 jobs — and all 34 describe the REQUESTER, which is
    idle in 34 of 34 (`buffer=0 ... drainsInFlight=0,draining=False`, no
    `Executing(`). 32 of them name `-> portal/nodeops-...`.

That message already ends "This message cannot distinguish them; the target's own
RunLevel and queue can." Nothing took that measurement.

`[STALE-CALLBACK]` fires every 5 s and 300+ times in a failing bake, and had the
same defect: every field on it — address, pending list, pool counters — belongs to
the hub that is waiting, and a waiting hub is idle by construction. It now resolves
each distinct target to a local hub (a pure read, HostedHubCreation.Never — a
diagnostic must never construct a hub, nor take the creation lock a wedged mesh may
be contending on) and appends that hub's turn-loop state:

  TARGET portal/nodeops-... RunLevel=Started
    Queue(buffer=45,deferred=0,drainsInFlight=1,openGates=0,draining=True)
    Executing(CreateNodeRequest, 24888ms)

drainsInFlight>0 with Executing = a thread is blocked inside the handler;
drainsInFlight=0 with Executing = no thread there and the turn's observable never
terminated; drainsInFlight=0, no Executing, draining=True, buffer>0 = the drain was
scheduled and never ran. Four different defects, indistinguishable until #3593 made
`exec` a measurement instead of a literal 0 — which landed 2026-09-07T15:05Z, an
hour after the last comment on #2543, so nothing in that thread used the fixed
instrument.

Compact snapshot, not GetPendingRequestDiagnostics: the target's own callback list
runs to kilobytes and multiplying it by 300 lines is how a hub once emitted a
~100 KB line that broke the TRX parsers. Capped at three DESCRIBED targets — the
cap counts what was described, not what was considered, so unresolvable addresses
cannot spend the budget and push the one hub that matters into "not described".

`AReplyIsOwedByAShuttingDownLocalHub` now shares the resolution walk rather than
carrying a second copy of it.

No bound raised, nothing retried, no watchdog. This adds a measurement.

Tests
  * TurnLoopSnapshotIsMeasuredTest — POSITIVE control (a handler genuinely holding
    the block must report drainsInFlight non-zero + Executing) beside a negative
    one. A field frozen at 0 passes every assertion that only sees an idle hub;
    that is exactly how the literal survived.
  * NodeCrudDoesNotOccupyTheExecutionBlockTest — measured, and it refuted my own
    first draft: with a create parked inside its own validator the node-CRUD
    execution hub reads completely idle, because storage reads go through IIoPool
    and the chain leaves the block at that hop. So a create holding the block for
    24.9 s is an ANOMALY, not the cost of the pipeline.

Doc: Bake Seal — NodeOps Saturation gains the instrument correction, the two-form
distinction, the 61-job denominator (6 GATE FAILED, not 11 — five of the failures
are a prebuilt-bundles shelf collision, so counting reds overstates this by ~2x),
and the eliminations, so the next session does not re-derive them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The updated documentation references a non-existent test (NodeCrudTurnStateIsMeasuredTest), which makes the calibration guidance inaccurate and should be corrected before merge.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Improves MeshWeaver hub saturation diagnostics by extending [STALE-CALLBACK] logs to include the target hub’s compact turn-loop snapshot (run level + queue shape + currently executing turn), so investigation can distinguish “blocked handler” vs “non-terminating observable” vs “scheduler stall” vs “idle target/reply-leg issue” without needing a disposal-stall dump.

Changes:

  • Extend [STALE-CALLBACK] logging to resolve distinct callback targets to local hubs (pure read, HostedHubCreation.Never) and append each target’s GetTurnLoopSnapshot() (capped per line).
  • Add MessageHub.GetTurnLoopSnapshot() plus shared target-to-local-hub resolution (ResolveLocalHub) and reuse it in AReplyIsOwedByAShuttingDownLocalHub.
  • Add regression tests that (1) positively prove drainsInFlight/Executing(...) are measured under a truly blocked turn and (2) show node CRUD does not normally occupy the node-ops execution block; update the bake-saturation doc with the corrected instrument reading.
File summaries
File Description
test/MeshWeaver.Messaging.Hub.Test/TurnLoopSnapshotIsMeasuredTest.cs Positive/negative controls ensuring turn-loop snapshot fields (drainsInFlight, Executing) actually move under load vs idle.
test/MeshWeaver.Graph.Test/NodeCrudDoesNotOccupyTheExecutionBlockTest.cs Calibration test: a real create blocked inside its validator should not occupy the node-CRUD execution hub’s action block.
src/MeshWeaver.Messaging.Hub/MessageHub.cs Appends resolved target hub snapshots to [STALE-CALLBACK] logs; introduces GetTurnLoopSnapshot and shared local-hub resolution; deduplicates shutdown-local-hub resolution logic.
src/MeshWeaver.Documentation/Data/Architecture/BakeSealNodeOpsSaturation.md Documents the corrected instrument semantics and the new target-snapshot emission on stale-callback lines.
Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +361 to +367
**Calibration, so the reading is not itself a guess.** `NodeCrudTurnStateIsMeasuredTest` parks a real
`CreateNodeRequest` inside the node-CRUD execution hub's turn (an `INodeValidator` that blocks —
the same harness [The /api/content 503](../ContentRoute503) uses to establish that a validator runs
*inside* that turn) and asserts the hub then reports `drainsInFlight` non-zero with
`Executing(CreateNodeRequest, …)`; a negative control asserts an idle hub reports neither. That pair
is what stops the field quietly becoming a constant again — which is the whole defect above, and
the reason a field that cannot fail is not a measurement.
@github-actions

github-actions Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

496 tests  ±0   496 ✅ ±0   39s ⏱️ +2s
  1 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit 8738db0. ± Comparison against base commit e0ad051.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

254 tests  ±0   254 ✅ ±0   2m 19s ⏱️ -1s
  1 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit 8738db0. ± Comparison against base commit e0ad051.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

389 tests   - 707   389 ✅  - 707   54s ⏱️ -42s
  2 suites ±  0     0 💤 ±  0 
  2 files   ±  0     0 ❌ ±  0 

Results for commit 8738db0. ± Comparison against base commit e0ad051.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 860 tests  +165   1 859 ✅ +358   3m 9s ⏱️ + 1m 0s
    4 suites ±  0       0 💤  - 194 
    4 files   ±  0       1 ❌ +  1 

For more details on these failures, see this check.

Results for commit 8738db0. ± Comparison against base commit e0ad051.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    6 files  +  1      6 suites  +1   3m 43s ⏱️ +36s
2 473 tests +704  2 281 ✅ +512  192 💤 +192  0 ❌ ±0 
2 474 runs  +704  2 282 ✅ +512  192 💤 +192  0 ❌ ±0 

Results for commit 8738db0. ± Comparison against base commit e0ad051.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 143 tests   - 119   1 141 ✅  - 121   3m 44s ⏱️ -41s
    3 suites ±  0       2 💤 +  2 
    3 files   ±  0       0 ❌ ±  0 

Results for commit 8738db0. ± Comparison against base commit e0ad051.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   17 files  + 1     17 suites  +1   14m 30s ⏱️ +16s
6 615 tests +43  6 420 ✅ +42  194 💤 ±0  1 ❌ +1 
6 616 runs  +43  6 421 ✅ +42  194 💤 ±0  1 ❌ +1 

For more details on these failures, see this check.

Results for commit 8738db0. ± Comparison against base commit e0ad051.

♻️ This comment has been updated with latest results.

…hub's block

Node CRUD in a mesh was serialised through one actor's queue. A create parked
in its validator held `portal/nodeops` so completely that a second, unrelated
create posted to the same hub sat unprocessed at `Queue(buffer=1,…)` for its
whole budget. Delete, move and copy shared it.

ROOT CAUSE, and it is not in the node-CRUD path. A response subject is
signalled from INSIDE the turn that handled the response, and Rx runs a
continuation on the thread that signalled it — so every
`hub.Observe(x).SelectMany(…)` chain in the framework ran its entire remainder
on that hub's action block, inside that turn, and the turn could not end until
the chain did. The create handler is detached and correct (`Processed()` at
+1 ms); the pipeline then climbed back onto the block when its partition
bootstrap's nested response came home, and ran validators, save and change feed
there.

`ContinueOffBlockRestoringUserContext` now hops the continuation off the block
before restoring the caller's identity. The order matters: `.Do(SetContext)`
must run on the thread that will run the continuation, or the chain resumes
unauthenticated.

WHY THE OLD TEST WAS RED. It read `Executing(…)` as time-on-the-block. That
field is cleared in the handler OBSERVABLE's `.Finally`, so it measures the
handler's in-flight lifetime — with a create parked it read
`Executing(CreateNodeResponse, 36001ms)` and tracked the observer's own
sampling window. #2543's `Executing(CreateNodeRequest, 24888ms)` therefore
never established that the block was held; the `buffer=45` beside it did.

EVIDENCE. The claim is measured behaviourally, since those fields cannot decide
it: with a create parked, an independent create must still complete. Three
tests — the probe, a POSITIVE CONTROL (the same probe with nothing parked), and
`TwoCreates_AreInFlightSimultaneously`, which requires two validators entered
before either is released. With the hop removed the first and third fail and
the control still passes; that pairing is the evidence, not the green.

4,426 tests across 8 projects green; solution clean under -warnaserror.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi rbuergi changed the title diag(#2543): the stale-callback report describes the hub it is WAITING ON fix(#2543): a response continuation no longer runs on the responding hub's action block Sep 8, 2026
@rbuergi
rbuergi marked this pull request as draft September 8, 2026 20:25
auto-merge was automatically disabled September 8, 2026 20:25

Pull request was converted to draft

rbuergi and others added 3 commits September 8, 2026 22:28
The field reads like a statement about the pump and is not one: it is cleared
in the `.Finally` of the handler's returned OBSERVABLE, so a handler that
correctly detaches keeps it set, with a growing elapsed, for as long as its
chain is in flight anywhere.

Recorded on the field itself and in DebuggingMessageFlow, because both readers
who met it today — including #2543's own headline capture — read it as block
time. `buffer=N` is the occupancy signal; a behavioural probe with a positive
control is the reading that cannot be misinterpreted.

This half stands on its own: it is true whether or not the continuation hop on
this branch ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d the block was providing

The hop in the previous commit removed THREE guarantees and replaced one. CI
found the other two: Memex.Portal.Shared.Test ran 1156 tests with ZERO failures
and the host then exited 2 (MASKED), with 7 [FATAL ERROR]s, on two consecutive
heads.

1. A FAULT BOUNDARY. A continuation fault used to be thrown inside
   HandleMessageAsync's chain, on the block, where MessageService's catch arm
   logged it and called ReportFailure — a reported failure, never a crash. Off
   the block nothing is above it: Rx rethrows on the scheduler thread, it goes
   unhandled, and the process dies. GuardContinuationFaults restores the
   previous DISPOSITION of that exception; it does not invent a new one. Debug
   once this hub is shutting down (a continuation outliving the scope it
   resolves from is the race LayoutAreaHost already reports as transient),
   Error while it is still serving, where it means application code threw.

2. A BOUND. ObserveOn asks the scheduler for long-running support and, when it
   is offered, dedicates a drain worker PER SUBSCRIPTION — a thread per request
   in flight once the hop is on every hub.Observe. DefaultScheduler and
   TaskPoolScheduler both offer it, so neither can be used directly;
   PooledContinuationScheduler exists only to WITHHOLD that one capability and
   keep Rx on the per-item pooled path. Do not "complete" it by implementing
   ISchedulerLongRunning — the omission is the fix.

🚨 The crash does NOT reproduce locally: the same suite on the same head with
the original hop passes 1152/1152 with zero fatals. CI is the only instrument
that can judge this, so this push is the measurement, not a claim.

Local (regression cover only, 4,455 tests, 0 fatals): Portal.Shared 1152,
Graph 1455, Messaging.Hub 383, Data 496, Hosting 514, Layout 455; solution
clean under -warnaserror.

OPEN, deliberately not decided here: when a continuation loses its scope during
teardown the guard turns a crash into a log line, and the caller's write simply
does not complete. Whether that should instead NACK the caller is a separate
question from stopping the crash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit's guard caught a continuation fault, logged it, and then
neither emitted nor errored — so anything awaiting that response waited for its
full timeout instead of learning immediately that the work had failed. That is a
HANG where the block used to fail fast, and it is the band-aid failure mode: the
crash was stopped by making the symptom quieter rather than by giving the fault
somewhere correct to go.

MessageService did not swallow either — it logged AND called ReportFailure, so
the caller got an answer. The guard now does the same: report, then end the
sequence with the fault.

Suspected cause of CI's Two_concurrent_logons_run_a_once_action_exactly_once
timing out on the previous head. Not claimed as proven: neither that failure nor
the host crash reproduces locally (the same suites pass here on both heads), so
CI remains the only instrument and this push is the measurement.

Local regression cover, 3,486 tests, 0 fatals: Data 496, Graph 1455,
Portal.Shared 1152, Messaging.Hub 383; solution clean under -warnaserror.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi

rbuergi commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

CI after the terminating guard: one failure left, and it names a SIXTH invariant

Run 34316509035 on 8738db0f7:

  • ✅ the host crash is gone (shard 5 clean, no MASKED, no [FATAL ERROR])
  • ✅ ReadDuringDisposalWindowTest — the NACK-path failure did not recur
  • ❌ LogonActionFrameworkTest.Two_concurrent_logons_run_a_once_action_exactly_once — still a clean
    30 s TimeoutException (05:56:21.740 → 05:56:51.750). Nothing emitted, nothing errored.

So the terminating guard was necessary but is not the cause of this one.

What this test is actually standing on

LogonActionRunner.RunFor carries a comment that describes, in advance, precisely the thing this PR
changes:

🚨 The identity is scoped with RunAs, NEVER Observable.Using(access.ImpersonateAsSystem, …).
Impersonation is an AsyncLocal store/restore pair; Observable.Using opens it on the
SUBSCRIBING thread and disposes it when the inner observable TERMINATES — for a cross-hub write,
the owning hub's response thread — so the two halves land on different threads and the
subscriber is left latched as the impersonated identity (#1790). RunAs opens and closes inside
one synchronous Subscribe.
Each individual write re-establishes it too, because Concat
subscribes a later action on whichever thread the previous one completed on.

That is a design that reasons explicitly about which thread a response continuation completes on,
because the identity is AsyncLocal. This PR moves every response continuation onto a pool thread.
ContinueOffBlockRestoringUserContext re-seeds accessService.SetContext(capturedCtx) after the hop
— which is why the hop is placed before the restore — but AccessService.RunAs is a different
mechanism from the hub's captured context, and re-seeding the captured identity is not obviously the
same as preserving a RunAs scope that was opened synchronously around a Subscribe.

Running tally of what the action block was underwriting

  1. serialisation of continuations (removed deliberately — this is the parallelism)
  2. a fault boundary → replaced by GuardContinuationFaults
  3. ordering with DI-scope disposal → logged as transient during teardown
  4. fail-fast rather than hang → the guard must TERMINATE, not just report (previous commit)
  5. which NACK path answers a teardown race (did not recur; unexplained)
  6. thread affinity for AsyncLocal identity scopes ← this one

Position

This is the fourth CI run to surface an invariant nobody had written down, and #6 is the first that
is about correctness of identity rather than liveness. I am not going to keep discovering these
one run at a time on a framework-wide change.

Staying DRAFT. The scope question I put to the maintainer stands and this sharpens it: either
enumerate the invariants up front and stage the general decoupling deliberately, or narrow the hop to
the node-CRUD path where the parallelism win is concrete (proven here by
TwoCreates_AreInFlightSimultaneously) and the surface is small.

The two findings that stand regardless of that decision, and are already merged or committed:
Executing(T, N ms) is a handler lifetime rather than block occupancy (in MessageService's field
comment and Doc/Architecture/DebuggingMessageFlow), and buffer=N is the occupancy signal.

@rbuergi

rbuergi commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #3802 — and this branch must not be merged as-is

Two reasons, one of them mechanical and load-bearing.

1. This branch is 14,689 deletions behind main. git diff origin/main --stat on it: 175 files changed, 2,340 insertions, 14,689 deletions. Five PRs merged today (#3795, #3797, #3798, #3799, #3800) and this branch predates all of them, so a PR from it would revert them — including files it shows as deletions that are its own author's work from this morning. Merging main in would mean resolving conflicts in MessageHub.cs and MessageService.cs, which are exactly the files #3798 changed.

2. The scope call this PR surfaced is resolved, the conservative way. The choice was "fix the general decoupling forward (error boundary + disposal-aware + bounded pool) or narrow the hop to the node-CRUD path". #3802 takes the second: IDetachedResponseContinuation is declared by the six node-CRUD requests and by nothing else, so every other request keeps the semantics it has always had.

Everything this branch established is carried over — the seven host crashes and the six invariants they named, PooledContinuationScheduler withholding ISchedulerLongRunning, the guard that REPORTS AND TERMINATES (a first version only reported, which turned a crash into a hang), and the behavioural test with its positive control. What is not carried over is the global hop, which is what broke LogonActionFrameworkTest and reddened ReadDuringDisposalWindowTest; both are green in #3802 and re-run by name.

The findings are now durable rather than living in this thread: Doc/Architecture/DetachedResponseContinuations.

Closing this draft in favour of #3802.

@rbuergi rbuergi closed this Sep 9, 2026
rbuergi added a commit that referenced this pull request Sep 9, 2026
…y mesh

The first cut registered PlatformMetricsHostedService in
MeshHostApplicationBuilder, which puts a hosted service resolving the
root hub into EVERY mesh in the fleet — including the several thousand a
test run builds, none of which has a collector.

CI then failed on
LogonActionFrameworkTest.Two_concurrent_logons_run_a_once_action_exactly_once
with a 30 s TimeoutException — the same signature as invariant #6 of the
six broken by the global continuation hop in the abandoned #3759. Three
local runs of that test on this branch were green, main is green on its
last five runs, and the test is not in known-flakes.json; so the evidence
did not settle it either way.

Rather than re-push and call it a flake, remove the reason for the doubt.
Instrumentation that alters the startup path of hosts that will never be
scraped is cost without a reader, and it makes every timing-sensitive
test's failure a question about the meter. The arming now lives beside
the OpenTelemetry subscription in the portal's service defaults — both
halves of the wiring in one place, in the host that actually collects —
and a test mesh is untouched by this PR at all.

Exactly one site arms it (verified by grep across src/, memex/, test/,
tools/), and it is the collecting host's.

Verified on this branch: solution build Release -warnaserror 0 warnings
0 errors; Graph.Test 1486 passed, Hosting.Test 539 passed,
Messaging.Hub.Test 387 passed.

Co-Authored-By: Claude Opus 5 (1M context) <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.

2 participants