Repository navigation
fix: four silent defects from the issue sweep (#2729, #2731, #2793, #2797) - #2800
Conversation
…e content on every hub Two silent production defects, both "the code acted on a value nobody wrote". ## A pinned install rolled on every pod restart (#2731, #2797) `CreatePolicySource` prepended the deployment's CONFIGURED DEFAULT to the policy stream before the persisted `Admin/UpdatePolicy` value arrived, and the startup pass ran a full evaluation on that seed — registry listing, candidate selection, Deployment PATCH. memex-cloud has read `policy: None` since 2026-08-28 and still rolled twice on 08-30 (ci.6664 at 11:54Z, ci.6739 at 15:03Z); the node's history records a `Startup` verdict that evaluated a candidate followed seconds later by a `PolicyChange` verdict reading "updates are disabled on this install", after the roll had been issued. The seed is gone. Stage 1 already guarantees the node EXISTS, so the live read is the first emission on its own — the poller now waits for a policy it has actually read. `ReadPolicyStream` also drops a null node rather than parsing it: `UpdatePolicyContent.Policy` defaults to `Continuous` and `Continuous` is enum 0, so an absent node parsed to exactly the verdict a pinned install must never reach. Pinned by `StartupCheck_OnAPinnedInstall_RunsUnderThePersistedPolicy_...`, which asserts on the REGISTRY LISTING rather than a log-line spelling: the `None` branch returns its verdict without listing, so `acr.Calls == 0` proves which policy decided. Mutation-checked — restoring the seed fails it with "found 1". `ABuildCompletionDrivenCheck_NeverWarnsAboutTheEventChannel` published its build before the watch was subscribed, so the publication was absorbed as the watch's BASELINE; it passed only because the synthetic seed made the subscription fast. It now waits for the startup check line first — the same positive signal the resilience test already documents. ## Node content read as raw JSON on any hub but its own (#2729) A node type's `WithContentType<T>()` configures its own hub. Seven built-in types declared one and registered its `$type` discriminator nowhere else, so a reader elsewhere in the mesh got an untyped `JsonElement` and a SILENT null — no exception, no log, the value renders empty and reactive waits time out. memex logged it at boot for `Admin/_GraphSubscription/inbox`: that node carries the inbound-mail subscription state, so the renewal could not see its own record and inbound mail was dead on an install whose every signal was green. `EaCredential`, `GraphSubscriptionState`, `Invitation`, `MemexClientContent`, `NotificationChannel`, `NotificationRule` and `TeamsConversation` now register. `NodeTypeContentDiscriminatorGuard` keeps them registered: the two halves of this contract are joined by nothing and omitting one compiles, boots, serves and passes every test. It reads both halves out of the source, carries a floor so it cannot pass on a broken scan, and self-tests its own classifier. Mutation-checked — deleting one registration fails it by name. Fixes #2729 Fixes #2731 Fixes #2797 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FhP7Jg65q8cAATnFfeaTK
There was a problem hiding this comment.
Pull request overview
Fixes two classes of silent production defects: (1) self-update checks running under a synthetic default policy before the persisted Admin/UpdatePolicy value is actually read (causing pinned installs to roll on restart), and (2) node content types declared via WithContentType<T>() not being registered mesh-wide, causing cross-hub reads to deserialize as untyped JSON and yield silent nulls.
Changes:
- Self-update policy source now waits for a real
Admin/UpdatePolicystream emission (and ignores null nodes) instead of prepending the configured default into the decision stream. - Registers
$typediscriminators mesh-wide for the affected built-in node content types so any hub can read them typed. - Adds/strengthens tests and introduces a governance guard to prevent future drift; adds “What’s New” entries documenting both fixes.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateHostedService.cs | Removes default-policy seeding from the live policy stream and gates parsing on non-null node emissions to avoid wrong-policy startup decisions. |
| test/MeshWeaver.Hosting.Monolith.Test/SelfUpdatePollerResilienceTest.cs | Tightens assertions to ensure the startup pass never evaluates tags under a non-persisted policy. |
| test/MeshWeaver.Hosting.Monolith.Test/SelfUpdateCheckVerdictTest.cs | Ensures build-completion publication happens only after the watch is live; adds a pinned-install startup test asserting zero registry listings under None. |
| test/MeshWeaver.Documentation.Test/NodeTypeContentDiscriminatorGuard.cs | New governance test that enforces “declared content type implies discriminator registered” across src/ + memex/. |
| src/MeshWeaver.Graph/Configuration/EaCredentialNodeType.cs | Registers EaCredential discriminator mesh-wide. |
| src/MeshWeaver.Graph/Configuration/GraphSubscriptionNodeType.cs | Registers GraphSubscriptionState discriminator mesh-wide. |
| src/MeshWeaver.Graph/Configuration/InvitationNodeType.cs | Registers Invitation discriminator mesh-wide. |
| src/MeshWeaver.Graph/Configuration/MemexClientNodeType.cs | Registers MemexClientContent discriminator mesh-wide. |
| src/MeshWeaver.Graph/Configuration/NotificationChannelNodeType.cs | Registers NotificationChannel discriminator mesh-wide. |
| src/MeshWeaver.Graph/Configuration/NotificationRuleNodeType.cs | Registers NotificationRule discriminator mesh-wide. |
| src/MeshWeaver.Graph/Configuration/TeamsConversationNodeType.cs | Registers TeamsConversation discriminator mesh-wide. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-30-a-pinned-install-stays-pinned.md | Documents the pinned-install restart behavior fix. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-30-node-content-reads-typed-on-every-hub.md | Documents the mesh-wide discriminator registration fix and the new guard. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…pause StreamAttachTransientRetryTest polled ThrowingStreamProvider.GetStreamCalls and treated two equal readings 25 ms apart as "settled". The attach retry hops through the thread-pool scheduler between attempts (AttachBackoff is collapsed to zero in the test, but a zero-delay Observable.Timer still needs a pool thread), so on a loaded shard that hop exceeds 25 ms, the poll reads the count mid-hop and returns. The value it returns is 1 — which is exactly the #2633 regression signature the test documents. A false RED that spells out the regression's own signature is worse than no test: the correct response to it is to hunt a bug that is not there. Observed on main at f52ff52 (run 33318685970, shard 3), on a merge that cannot reach Orleans. "Unchanged for 25 ms" is not the condition under test; "the attach gave up" is. RegisterStream already records that: subscriptionReady[address] completes when the attach has attached, given up on an exhausted budget, or been cancelled, and it never faults. Exposed as the internal AttachSettled(address) seam — the same InternalsVisibleTo discipline as AttachBackoff — and awaited with a 30 s BACKSTOP (not the measurement: real elapsed time is milliseconds). The counter is read once, after the terminal. Captured before the readiness gate opens, so there is no window in which the attach could finish unobserved, and a missing seam throws rather than silently falling back to a poll. dotnet build test/MeshWeaver.Hosting.Orleans.Test -c Release -warnaserror 0 Error(s) dotnet test --filter StreamAttachTransientRetryTest 4 passed, 0 failed, 39 ms Fixes #2793 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FhP7Jg65q8cAATnFfeaTK
…switch, and guard the arithmetic MeshWeaver.PluginCatalog.Test measured 315/320/315 s across three consecutive runs — 66-72% of the `timeout 8m` = 480 s per-project cap — then went 480 s exit=124 TIMEOUT on e12697e (run 33302269352, shard 0), with the SAME tree passing at 320 s on the very next run. No change was responsible. The five "test failures" that came with it were timing casualties of the kill, so the red named tests that had not failed and cost a full investigation to dismiss — and per this repo's rules it must not simply be re-run. Raising the cap is not the fix: the cap is what turns a wedge into a bounded, attributable failure. Splitting lowers the numerator, and costs nothing because the parts land on different runners. ## The rule that missed it shard-assign.sh already had a split rule and it is about BALANCE — split when a project is the binding long pole. PluginCatalog passed that rule comfortably (348 s against a ~421 s ideal shard load) and was still one slow runner from exit=124. Headroom against the cap is an INDEPENDENT trigger; the header now states both, and says which one bites in production. ## The guard found the second one ShardWeightHeadroomGuard divides each entry's weight by its part count and fails above 60% of the cap. On its first run it named Hosting.Monolith.Test: 663 s ÷ 2 = 332 s per half, 69% of the cap — the same zone that killed PluginCatalog, and recorded in the file's own header as the sharding floor. Split into 3 (~221 s). The cap is READ from dotnet-test.yml, never restated, so the guard cannot quietly disagree with the budget it guards; the parse of both the cap and the parts column is self-tested, and a short parse fails rather than passing on an empty table. PluginCatalog 348 s -> 2 parts, ~174 s each (36% of cap) Monolith 663 s -> 3 parts, ~221 s each (46% of cap) shard loads 380 379 379 379 379 379 (was 380 379 379 379 379 378) all five heavy units land on five distinct shards Fixes #2747 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FhP7Jg65q8cAATnFfeaTK
Test Results (shard 0)787 tests - 391 783 ✅ - 202 4m 23s ⏱️ - 1m 5s Results for commit 5556794. ± Comparison against base commit a7cc0a7. This pull request removes 1178 and adds 787 tests. Note that renamed tests count towards both.This pull request removes 193 skipped tests and adds 4 skipped tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
Test Results (shard 4) 7 files - 3 7 suites - 3 5m 32s ⏱️ -8s Results for commit 5556794. ± Comparison against base commit a7cc0a7. This pull request removes 2683 and adds 684 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
Test Results (shard 1)963 tests - 25 963 ✅ - 25 5m 2s ⏱️ - 1m 3s Results for commit 5556794. ± Comparison against base commit a7cc0a7. This pull request removes 844 and adds 819 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
Test Results (shard 2)2 998 tests +2 374 2 806 ✅ +2 182 6m 1s ⏱️ ±0s Results for commit 5556794. ± Comparison against base commit a7cc0a7. This pull request removes 294 and adds 2668 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
Test Results (shard 5) 8 files - 2 8 suites - 2 6m 1s ⏱️ -24s Results for commit 5556794. ± Comparison against base commit a7cc0a7. This pull request removes 1472 and adds 809 tests. Note that renamed tests count towards both.This pull request removes 4 skipped tests and adds 1 skipped test. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
Test Results (shard 3)1 153 tests +709 1 153 ✅ +709 7m 46s ⏱️ + 3m 22s Results for commit 5556794. ± Comparison against base commit a7cc0a7. This pull request removes 385 and adds 1094 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
Test Results 44 files + 2 44 suites +2 34m 48s ⏱️ +43s Results for commit 5556794. ± Comparison against base commit a7cc0a7. This pull request removes 1 and adds 6 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
… ejected #2800 from the queue PodHubClaimLifetimeTest.WaitUntilSettled was the SAME settle-by-silence poll this PR removes from StreamAttachTransientRetryTest — its own comment cited that one as precedent ("the same technique as StreamAttachTransientRetryTest"). The retry hops through the thread-pool scheduler between attempts, so on a loaded shard that hop exceeds the 25 ms window, the poll reads the count during a PAUSE and returns it. Measured, not inferred: this PR's own merge-queue build (run 33322976985, shard 0, gh-readonly-queue/main/pr-2800-afb5a5ec9) failed with AClaimThatCannotLand_WhereNoGrainCanBeHosted_StopsAtTheInitialBudget_AtDebug Expected value to be 6 ... but found 4 and the queue ejected the PR. 4 is a number this test would otherwise force someone to interpret as a real defect in the claim's budget. "Unchanged for 25 ms" was never the condition under test; "the claim ended" is. The production code already says so: the impossibility terminal logs "did not land in this process" at Debug from the attach's terminal onError, after every permitted attempt is spent. The test has a RecordingLogger, so that line is available as a positive signal — wait for it, then read the count once. The loop bound is a backstop against a hang, not the measurement. The sibling helper WaitForAttempts is left alone: it waits for `count >= target`, which is already a positive signal. dotnet build test/MeshWeaver.Hosting.Orleans.Test -c Release -warnaserror 0 Error(s) dotnet test --filter PodHubClaimLifetimeTest|StreamAttachTransientRetryTest 9 passed, 0 failed, 1 s Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FhP7Jg65q8cAATnFfeaTK
…t a pause (#2793 sibling) The SECOND settle-by-silence poll, found by the merge queue itself: PodHubClaimLifetimeTest.AClaimThatCannotLand_WhereNoGrainCanBeHosted_ StopsAtTheInitialBudget_AtDebug failed the queue entry for #2800 with "Expected value to be 6 … but found 1" — the identical shape, and the identical value, as StreamAttachTransientRetryTest in #2793. Its WaitUntilSettled polled factory.AttachCalls every 25 ms and called two equal readings "settled". The claim's RetryWhen hops through the thread-pool scheduler between attempts; on a loaded runner that hop exceeds 25 ms, so the poll reads the counter MID-HOP and returns 1 — which is exactly the value unfixed code produces. A false RED that spells the regression's own signature is worse than no test, and the file's own doc comment even said "there is no completion signal to await for a claim that ends". There is one now. OrleansRoutingService.PodHubClaimSettled(address) is the sibling of #2800's AttachSettled: a per-address AsyncSubject<Unit>, an INSTANCE field on the routing service (never static), armed BEFORE the claim is subscribed so it cannot terminate unobserved, and completed in BOTH terminal arms of the claim's Subscribe — it landed, or it hit the one terminal that is impossibility rather than a budget (a process that cannot host a grain can never win the claim). A claim that is still retrying never completes it, which is the honest answer for a lifetime with no give-up: that case keeps WaitForAttempts, which was already a positive signal. The subject is removed with the registration. Verified: - 5/5 green, and in 1 s rather than the poll's seconds-per-test. - MUTATION-PROVED, not merely observed green: collapsing the budget (`attempt >= PodHubAttachRetries` → `attempt >= 0`) makes the test FAIL with "found 1", so the new wait still detects the regression it exists for. Restored and re-verified after the probe. Folded into this PR rather than opened as its own, because the queue builds each entry on the ones ahead of it: #2800 alone still meets the unfixed pod-hub test and ejects (16:48Z), while #2803 alone meets the unfixed stream attach and ejected too (17:00Z). Neither sibling can land without the other, so they belong in ONE entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Folded in a fifth defect: the OTHER settle-by-silence poll (
|
| entry | failed on | fixed by |
|---|---|---|
pr-2800 (16:48Z) |
PodHubClaimLifetimeTest |
only this commit |
pr-2803 (17:00Z) |
StreamAttachTransientRetryTest |
only this PR |
Neither sibling could land alone. One entry, both fixes.
Verified
- 5/5 green in 1 s (the poll took seconds per test).
- Mutation-proved, not merely observed green: collapsing the budget (
attempt >= PodHubAttachRetries→attempt >= 0) makes the test FAIL with found 1 — the new wait still detects the regression it exists for. Source restored and re-verified afterwards. - The What's New entry now covers both siblings rather than one.
I dequeued this PR to push (GitHub declines a push to a branch with a live queue entry) — please re-queue it; it is CLEAN and the required check was green on the previous head.
Superseded by a better fix on #2800, from the session that owns the queue. We both found the same defect from opposite ends within minutes — I found it because it ejected #2800, they found it because it and StreamAttachTransientRetryTest were deadlocking the queue against each other (an entry is built only on the entries ahead of it, so pr-2800 ejected on PodHubClaimLifetimeTest and pr-2803 ejected on StreamAttachTransientRetryTest; neither could land alone). Theirs is the one to keep. Mine waited for the production code's Debug line "did not land in this process" — a positive terminal, but one that pins the test to a LOG MESSAGE SPELLING, which is precisely the contract shape #2795 (in this PR) exists to warn about. Theirs adds OrleansRoutingService.PodHubClaimSettled(address): a per-address AsyncSubject on an INSTANCE field, armed before the claim is subscribed and completed in both terminal arms — the exact sibling of AttachSettled, and symmetric with it. Reverting here so main takes one version rather than resolving a conflict between two correct fixes to the same method. #2800 must land before this PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FhP7Jg65q8cAATnFfeaTK
…emoves
CI red on run 33324723607:
InertContentIndexTest.Search_WithInertServices_ReturnsTheReason_InBothQueryShapes
Assert.Contains() Failure: Sub-string not found
It asserted `"count":0` on the envelope for an inert index — i.e. for a search that
never ran. That assertion IS the defect #2741 reports, written down as a
requirement: `count: 0` is byte-identical to "I searched and found nothing", and
`count` is the field a caller reads, so the AGENTS-mandated pre-deletion sweep
returned a PASS having verified nothing.
Updated to the new contract — no `count` at all, `searched:false`, the
`search-not-performed` discriminator, and the reason still present, since an
envelope that says nothing was searched must still say what to configure.
## How it got past me
I ran `--filter ContentChunkSearchTest` — my own new tests — and not the project.
The whole point of a contract change is that it breaks the OTHER tests holding the
old contract, which is exactly the set a filter excludes. Corrected here by running
each touched project in full:
MeshWeaver.ContentCollections.Indexing.Test 62 passed, 0 failed
MeshWeaver.Messaging.Hub.Test 341 passed, 0 failed
MeshWeaver.Data.Test 403 passed, 0 failed
MeshWeaver.Documentation.Test 121 passed, 0 failed
## Merged main (dbf7204)
The other reds on that run were `MeshWeaver.Hosting.Monolith.Test (part 1/2)
exit=124 TIMEOUT (8m wall-clock cap hit) elapsed=480s` and its downstream noise —
"part 1/2", so the run predates the 3-way split that #2800 landed. Merging main
takes the branch to `1/3` at ~46% of the cap. This is a re-sync for a reason the
diff cannot reach, not a routine catch-up.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FhP7Jg65q8cAATnFfeaTK
…ecting releases on them (#2777, #2700) Since the merge queue went live every entry builds on a loaded runner BY CONSTRUCTION, and three releases have now been ejected by three different tests whose assertion is a bound rather than a condition: #2800 PodHubClaimLifetimeTest (fixed, merged) #2803 StreamAttachTransientRetryTest (fixed by #2800) #2803 DataContextInitFaultedTest ← this commit, ejected 17:32Z 1. SelfUpdateAvailabilityGateTest (#2777) NeverRolled_WithTheGateUnavailable_StillHolds waited up to 30s for a HELD marker to land on Admin/UpdatePolicy. The service is event-driven, so "the hold is not there yet" and "the first check has not run yet" are indistinguishable from outside — and on a loaded shard it is the second. The issue's own diagnosis, adopted verbatim: make the first poll deterministic rather than widen the bound. SelfUpdateHostedService now publishes ChecksReported — one emission per check that has been evaluated AND reported, an INSTANCE Subject (never static), completed in StopAsync so a subscriber on a stopped service is released rather than left hanging. The tests await the first emission, then assert on the state it produced. Production never subscribes; nothing about what a check does or when it runs changes. The member is protected internal and Hosting.Monolith.Test is not in Memex.Portal.Shared's InternalsVisibleTo list, so the test's own subclass forwards it (`Evaluations`) — cheaper than widening the production assembly's friend list for one file. 2. DataContextInitFaultedTest.FaultedInit_ReachesTerminalFailedState_AndAnswersFast 🚨 ONE bound was doing TWO jobs that want OPPOSITE values. The test asserts liveness ("is a faulted init answered at all" — wants a generous bound, so a timeout means NEVER) and latency (the AndAnswersFast in its name — wants a tight one). A single 15s wait conflated them: a loaded runner pushed the rejection past it, and the assertion read the resulting TimeoutException as "the gate is shut" — the defect's own signature, for a defect that was not there. It ejected #2803, a PR touching only Mesh.Contract/Security and an operator script, which cannot reach a DataContext. Merely widening to 60s would have DELETED the latency half in silence: the test would still pass if the answer took 55s, which is precisely what its name guards. So they are separated — a generous 60s wait (a timeout now means never), plus an elapsed-time assertion of its own against a threshold DERIVED FROM THE MECHANISM rather than a feeling of "fast": the hub's own 30s request budget, which is what a request that FELL THROUGH the faulted arm waits out. A slow answer and an absent one now fail with different messages, because they are different defects. (The split is owed to the reviewing session, who caught that my first pass would have quietly dropped the latency half.) Verified: Memex.Portal.Shared, Hosting.Monolith.Test and Data.Test each -c Release -warnaserror → 0 Error(s); SelfUpdateAvailabilityGateTest 6/6 and FaultedInit_ReachesTerminalFailedState 1/1 green. 🚨 Trap worth recording: `--filter "FullyQualifiedName~DataContextInitWatchdogTest"` matched NOTHING and exited 0 — the file is DataContextInitWatchdogTest.cs but the CLASS is DataContextInitFaultedTest. A filter that matches nothing is a silent pass; filter on the method name when the class name is inferred from a filename. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two silent production defects from the issue sweep, both of the shape "the code acted on a value nobody wrote".
1. A pinned install rolled on every pod restart — #2731, #2797
CreatePolicySourceprepended the deployment's configured default to the policy stream before the persistedAdmin/UpdatePolicyvalue arrived, and the startup pass ran a full evaluation on that seed: registry listing, candidate selection, Deployment PATCH.memex-cloud has read
policy: Nonesince 2026-08-28 and still rolled twice on 08-30 (ci.666411:54Z,ci.673915:03Z). The node's own history records the sequence — aStartupverdict that evaluated a candidate, then seconds later aPolicyChangeverdict reading "updates are disabled on this install", after the roll had been issued.The seed is deleted. Stage 1 (
EnsureExists) already guarantees the node exists, so the live read is the first emission on its own.ReadPolicyStreamalso drops a null node instead of parsing it —UpdatePolicyContent.Policydefaults toContinuousandContinuousis enum0, so an absent node parsed to exactly the verdict a pinned install must never reach.Pinned by a test that asserts on the registry listing, not a log-line spelling: the
Nonebranch ofRunOncereturns its verdict without listing, soacr.Calls == 0proves which policy decided. Mutation-checked — restoring the seed fails it withbut found 1.ABuildCompletionDrivenCheck_NeverWarnsAboutTheEventChannelpublished its build before the watch was subscribed, so the publication was absorbed as the watch's baseline; it passed only because the synthetic seed made that subscription fast. It now waits for the startup check line first — the same positive signal the resilience test already documents.2. Node content read as raw JSON on any hub but its own — #2729
A node type's
WithContentType<T>()configures its own hub. Seven built-in types declared one and registered its$typediscriminator nowhere else, so a reader elsewhere in the mesh got an untypedJsonElementand a silent null — no exception, no log; the value renders empty and reactive waits time out.memex logged it at boot for
Admin/_GraphSubscription/inbox. That node carries the inbound-mail subscription state, so the renewal could not see its own record and inbound mail was dead on an install whose every signal was green.Registered:
EaCredential,GraphSubscriptionState,Invitation,MemexClientContent,NotificationChannel,NotificationRule,TeamsConversation.NodeTypeContentDiscriminatorGuardkeeps them registered. The two halves of this contract are joined by nothing, and omitting one compiles, boots, serves and passes every test. The guard reads both halves out of the source, carries a floor so it cannot pass on a broken scan, and self-tests its own classifier. Mutation-checked — deleting one registration fails it by name.Verification
Fixes #2729
Fixes #2731
Fixes #2797
🤖 Generated with Claude Code
https://claude.ai/code/session_014FhP7Jg65q8cAATnFfeaTK
3. A retry test read a scheduler pause as the regression — #2793
StreamAttachTransientRetryTestpolled the attempt counter and treated two equal readings 25 ms apart as settled. The attach retry hops through the thread-pool scheduler between attempts, so on a loaded shard that hop exceeds 25 ms — the poll reads mid-hop and returns 1, which is exactly the #2633 regression signature the test documents. Observed on main atf52ff52e3(run 33318685970, shard 3), on a merge that cannot reach Orleans.RegisterStreamalready records the real condition:subscriptionReady[address]completes when the attach has attached, given up, or been cancelled, and never faults. Exposed as the internalAttachSettled(address)seam (sameInternalsVisibleTodiscipline asAttachBackoff), awaited with a 30 s backstop — real elapsed time is milliseconds. Captured before the readiness gate opens so the attach cannot finish unobserved, and a missing seam throws rather than silently falling back to a poll.Fixes #2793