Repository navigation
fix(#3213): an update reminder is a STATE — told once per candidate, not once per poll - #3230
Conversation
…not once per poll
`PackageUpdateReconciler.Notify` minted a fresh `Notification` node on every reconcile that
still saw a pending update. Measured on memex.meshweaver.cloud 2026-09-03: 124 of the newest
200 notification rows were this one emitter, four rows for the same package in a day, two
packages accounting for over half of everything the mesh had written in four days — and those
rows are the bell's row count for every viewer who can read the plugin catalogue.
TWO independent halves, and fixing either alone still duplicates:
1. The silence gate asked a question that could never become true. `Decide`'s content-identity
gate asks "is the candidate INSTALLED?" — self-silencing on the AutoUpdate path (the apply
advances `record.ModuleVersion`) and UNSATISFIABLE on the notify path, which installs nothing
by design. So it evaluated false on every subsequent reconcile, forever, and the reconcile
runs at boot, on every feed read that drains a deferred registry, and on every build webhook.
→ `PackageManifest.NotifiedModuleVersion` records the candidate the user was last reminded
about, on the durable install record; `Notify` compares against it and writes it. Writing it
is what makes the gate BECOME true. On the record rather than in a cache because a cache
loses its memory on every pod start and re-notifies — which is the symptom itself.
2. The notification had no identity for a repeat to collide with. Every
`NotificationService.CreateNotification` call minted a fresh GUID, so a repeat landed at a
new path rather than overwriting.
→ an optional `identity` derives the node id deterministically from (mainNodePath, identity)
via `PartitionSourceFingerprint` — the platform's existing content-addressing helper, the
same shape as the `_Activity/import-{fingerprint}` marker — and makes the write an atomic
`CreateOrUpdateNode`. The reconciler keys it on (record, kind, candidate version).
The gate sits inside `Notify`, deliberately not in front of `Apply`: an unattended apply must
keep retrying every reconcile so a restored admin grant heals itself. Only the TELLING is once
per candidate. The bell is written first and the marker second — a marker written first whose
notification then failed would silence the reminder permanently, whereas a bell written first
whose marker then failed costs one repeat, which the deterministic identity absorbs into the
same node. A genuinely newer candidate changes both the comparison and the derived id, so it
still gets its own unread bell.
No poll-count cap, no time window, no de-dup cache.
Verified by mutation, both halves, each with its own failing arm:
- disable the marker gate → "the second pass re-raised a reminder the user had already
dismissed" (counting rows CANNOT catch this half — the deterministic upsert keeps the count
at one either way, so the fixture dismisses the bell and asserts it stays dismissed)
- disable the deterministic id → Assert.Single failure, 2 rows
A fourth arm proves the suppression is not too coarse: a new candidate version still raises its
own unread reminder. And the negative control asserts zero registry round-trips across four
reconciles.
Also converts `Notify`'s `Observable.Using(ImpersonateAsSystem, …)` to `RunAsSystem` (#1790) and
lowers the impersonation ratchet: PackageUpdateReconciler.cs 2 → 1, TotalBudget 69 → 68.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The new test awaits IObservable chains directly; per MeshWeaver.Messaging.ObservableAwait it should use .Await() to avoid Rx inline-resumption continuation hazards.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR fixes a notification storm in the plugin update reconciler by making “Update available” reminders idempotent per candidate version, so a standing update condition produces one durable reminder (state) rather than a new notification node on every reconcile poll.
Changes:
- Adds a durable “already notified” marker (
PackageManifest.NotifiedModuleVersion) and uses it inPackageUpdateReconciler.Notifyto suppress repeat reminders for the same candidate. - Extends
NotificationService.CreateNotificationwith an optional deterministicidentitythat switches the write to an atomic upsert (stable node id per condition) instead of minting a new GUID each time. - Adds an idempotence regression test and updates documentation/What’s New to capture the new “reminder is state” behavior.
File summaries
| File | Description |
|---|---|
| test/MeshWeaver.Documentation.Test/ImpersonationScopeSiteRatchetGuard.cs | Lowers impersonation-site budget after reducing a production root. |
| test/Memex.Portal.Shared.Test/PackageUpdateReminderIdempotenceTest.cs | New regression test exercising both suppression (marker) and deterministic identity behaviors. |
| test/ImpersonationScopeSites.allow | Updates allow-list counts for PackageUpdateReconciler. |
| src/MeshWeaver.PluginCatalog/PackageUpdateReconciler.cs | Adds marker-based silence gate + deterministic notification identity; replaces Observable.Using(ImpersonateAsSystem, …) with RunAsSystem. |
| src/MeshWeaver.PluginCatalog/Package.cs | Adds PackageManifest.NotifiedModuleVersion marker field. |
| src/MeshWeaver.Graph/NotificationService.cs | Adds optional identity parameter and uses PartitionSourceFingerprint + CreateOrUpdateNode for deterministic, idempotent notifications. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-03-an-update-reminder-is-told-once-not-every-hour.md | What’s New entry describing the user-visible behavior change. |
| src/MeshWeaver.Documentation/Data/Architecture/PluginUpdateOnGreenBuild.md | Documents why the content-identity gate cannot silence reminders and how the new gate works. |
| src/MeshWeaver.Documentation/Data/Architecture/AddressedNotifications.md | Marks the unbounded plugin-update notification issue as fixed (suppression half). |
Review details
Suppressed comments (10)
test/Memex.Portal.Shared.Test/PackageUpdateReminderIdempotenceTest.cs:123
- Avoid awaiting an IObservable directly here; use
ObservableAwait.Await()to prevent inline continuation resumption on the signalling thread.
var afterSecond = await Reminders().FirstAsync().Timeout(TestTimeouts.Convergence);
test/Memex.Portal.Shared.Test/PackageUpdateReminderIdempotenceTest.cs:145
- Avoid awaiting an IObservable directly here; use
ObservableAwait.Await()to prevent inline continuation resumption on the signalling thread.
var afterThird = await Reminders().FirstAsync().Timeout(TestTimeouts.Convergence);
test/Memex.Portal.Shared.Test/PackageUpdateReminderIdempotenceTest.cs:162
- This awaits an IObservable directly; prefer
...Timeout(...).Await()(MeshWeaver.Messaging.ObservableAwait) so the continuation is scheduled asynchronously.
var afterNew = await Reminders()
.Where(ns => ns.Any(n => n.Path != reminderPath))
.FirstAsync().Timeout(TestTimeouts.Convergence);
test/Memex.Portal.Shared.Test/PackageUpdateReminderIdempotenceTest.cs:217
- This awaits an IObservable directly; use
.Await()to bridge the observable to a Task without inline continuation resumption on the signalling thread.
await access.RunAsSystem(() => NodeFactory.CreateOrUpdateNode(record))
.Timeout(TestTimeouts.Convergence);
test/Memex.Portal.Shared.Test/PackageUpdateReminderIdempotenceTest.cs:236
- This awaits an IObservable directly; use
.Await()to bridge the observable to a Task without inline continuation resumption on the signalling thread.
await access
.RunAsSystem(() => Mesh.GetMeshNodeStream(notificationPath)
.Update<Notification>(current => current with { IsRead = true }))
.Timeout(TestTimeouts.Convergence);
test/Memex.Portal.Shared.Test/PackageUpdateReminderIdempotenceTest.cs:249
- This awaits an IObservable directly; use
.Await()(MeshWeaver.Messaging.ObservableAwait) to avoid Rx's inline-resumption awaiter.
private async Task AwaitMarker(string expected) =>
await Mesh.GetMeshNodeStream(RecordPath)
.Select(n => n.ContentAs<PackageManifest>(Json)?.NotifiedModuleVersion)
.Where(v => string.Equals(v, expected, StringComparison.Ordinal))
.FirstAsync().Timeout(TestTimeouts.Convergence);
test/Memex.Portal.Shared.Test/PackageUpdateReminderIdempotenceTest.cs:185
PackageUpdateReconciler.ReconcileInstalled(...).Timeout(...)is an IObservable; awaiting it directly uses Rx's awaiter. Use.Await()to avoid inline continuation resumption (same defect class as.ToTask()).
=> await PackageUpdateReconciler
.ReconcileInstalled(Mesh, source, "HEAD", [Candidate(candidate)], provenance, logger)
.Timeout(TestTimeouts.Convergence);
test/Memex.Portal.Shared.Test/PackageUpdateReminderIdempotenceTest.cs:226
- This awaits an IObservable directly; use
.Await()to bridge the observable to a Task without inline continuation resumption on the signalling thread.
await access
.RunAsSystem(() => Mesh.GetMeshNodeStream(RecordPath)
.Update<PackageManifest>(current => current with { NotifiedModuleVersion = value }))
.Timeout(TestTimeouts.Convergence);
test/Memex.Portal.Shared.Test/PackageUpdateReminderIdempotenceTest.cs:243
- This awaits an IObservable directly; use
.Await()(MeshWeaver.Messaging.ObservableAwait) to avoid Rx's inline-resumption awaiter.
private async Task AwaitRead(string notificationPath, bool expected) =>
await Mesh.GetMeshNodeStream(notificationPath)
.Select(n => n.ContentAs<Notification>(Json)?.IsRead)
.Where(v => v == expected)
.FirstAsync().Timeout(TestTimeouts.Convergence);
test/Memex.Portal.Shared.Test/PackageUpdateReminderIdempotenceTest.cs:172
- This awaits an IObservable directly; prefer
...Timeout(...).Await()(MeshWeaver.Messaging.ObservableAwait) so the continuation is scheduled asynchronously.
Assert.Equal(2, (await Reminders().FirstAsync().Timeout(TestTimeouts.Convergence)).Count);
- Files reviewed: 9/9 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.
Test Results (shard 3)346 tests 344 ✅ 37s ⏱️ Results for commit 117a43b. ♻️ This comment has been updated with latest results. |
Test Results (shard 1)459 tests 459 ✅ 1m 2s ⏱️ Results for commit 117a43b. ♻️ This comment has been updated with latest results. |
Test Results (shard 0)243 tests 243 ✅ 2m 18s ⏱️ Results for commit 117a43b. ♻️ This comment has been updated with latest results. |
Test Results (shard 5) 4 files 4 suites 1m 29s ⏱️ Results for commit 117a43b. ♻️ This comment has been updated with latest results. |
Test Results (shard 2)578 tests 386 ✅ 2m 38s ⏱️ Results for commit 117a43b. ♻️ This comment has been updated with latest results. |
Test Results (shard 4)2 201 tests 2 201 ✅ 2m 34s ⏱️ Results for commit 117a43b. ♻️ This comment has been updated with latest results. |
Test Results 15 files 15 suites 10m 39s ⏱️ Results for commit 117a43b. ♻️ This comment has been updated with latest results. |
…inline awaiter Copilot review on #3230, and it is right — ObservableAwait's own remarks say so: "Awaiting the observable DIRECTLY (await source) is the same defect wearing different clothes — Rx's awaiter is an AsyncSubject and resumes inline too." The continuation lands on whichever thread signalled, still inside Rx's trampoline (Producer.SubscribeRaw), and every later await in the method inherits that scheduler — so the bridge changes what the test measures (#2301, #2377). 108 test files already use .Await(); this one was modelled on RegistryReconcileDeferralTest, which is a straggler that still awaits directly. The mutation proof is re-run against the new bridge, because the bridge is exactly what changes scheduling: with the marker gate disabled the fixture still reddens on "the second pass re-raised a reminder the user had already dismissed". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
ℹ️ Merge-queue steward: no action — removed from the queue with reason |
Closes #3213
The defect
PackageUpdateReconciler.Notifyminted a freshNotificationnode on every reconcile thatstill saw a pending update. Measured on memex.meshweaver.cloud on 2026-09-03: 124 of the newest
200 notification rows were this one emitter — four rows for the same package in a single day, two
packages accounting for over half of everything the mesh had written in four days. Those rows are
the bell's row count for everyone who can read the plugin catalogue, so it was a cost paid on every
bell render, not just clutter.
Two independent halves — fixing either alone still duplicates
Both were confirmed against the source before changing anything.
Half 1 — the silence gate asked a question that could never become true
Decidegates on content identity:record.ModuleVersion == pkg.ModuleVersion, i.e. "is thecandidate INSTALLED?".
AutoUpdatepath that is self-silencing —Applyinstalls, the record'sModuleVersionadvances, the next reconcile compares equal.Notifyinstalls nothing — by design, the user hasnot acted — so
ModuleVersionnever advances and the comparison evaluated false on everysubsequent reconcile, forever. And the reconcile runs often: at boot, whenever a feed read drains
a deferred registry, and on every build webhook.
Fix:
PackageManifest.NotifiedModuleVersion— the candidate this installation has already toldthe user about, on the durable install record.
Notifycompares against it and then writes it, sowriting it is what makes the gate become true, which is precisely what the version comparison
could never do here. It lives on the node rather than in a cache because a cache loses its memory on
every pod start and re-notifies — that is the symptom.
Half 2 — the notification had no identity for a repeat to collide with
NotificationService.CreateNotificationmintedGuid.NewGuid()per call, so a repeat landed at anew path rather than overwriting one.
Fix: an optional
identityparameter. When given, the node id is derived deterministically from(mainNodePath, identity) through
PartitionSourceFingerprint— the platform's existingcontent-addressing helper, the same shape as the
{Partition}/_Activity/import-{fingerprint}marker— and the write becomes an atomic
CreateOrUpdateNode(the owner's single upsert verb, not aclient-side create-then-update split, which races). The reconciler keys it on (record, kind,
candidate version). Default
nullkeeps every existing caller on the historic behaviour.Why the gate is inside
Notifyand not in front ofApplyAn unattended apply must keep retrying every reconcile so a restored admin grant heals itself.
Only the telling is once per candidate — which is also why the commercial-refusal notification
("Update needs a Global Admin") is gated by the same marker while
Applystill runs each pass.Order within the notify path is deliberate: bell first, marker second. A marker written first
whose notification then failed would silence the reminder permanently; a bell written first whose
marker then failed costs one repeat, which the deterministic identity absorbs into the same node.
A genuinely newer candidate changes both the comparison and the derived id, so it still gets its own
unread bell. No poll-count cap, no time window, no de-dup cache.
Verification — proved by mutation, both halves
test/Memex.Portal.Shared.Test/PackageUpdateReminderIdempotenceTest.csdrives the real reconcileragainst a real monolith mesh and a real
RegistryPackageSourceover four reconcile passes.if (false && …))var deterministic = false && …)Assert.Single() Failure: The collection contained 2 items🚨 Counting rows cannot test half 1, and a fixture that only counted would pass having checked
nothing: with the deterministic identity in place an unsuppressed second pass lands on the same
node, so the count stays 1 either way. What separates suppressed from absorbed is what the user
sees — so the fixture dismisses the bell first and asserts it stays dismissed (that re-lit bell
is the defect a reader experiences four times a day). The first attempt at this test passed under
the half-1 mutation for exactly that reason; it was rewritten rather than accepted.
A fourth arm is the falsifier the suppression needs: a genuinely new candidate version must
still raise its own unread reminder — a gate keyed too coarsely would pass every other arm while
breaking the feature. The negative control asserts zero registry round-trips across all four
reconciles ("no notification, and not one file fetched").
Local runs, all green:
dotnet build -c Release -warnaserror(whole solution) — 0 Warning(s), 0 Error(s)Memex.Portal.Shared.Test— 698/698 (includesRegistryReconcileDeferralTest, which exercisesthis same notify path)
MeshWeaver.Graph.Test— 1269/1269 (includesNotificationServiceTest,NotificationDispatchTest,NotificationDispatchIdentityLatchTest)ImpersonationScopeSiteRatchetGuard,WhatsNewEntryIntegrityTest,DocumentationLinkIntegrityTest,NoConflictMarkersGuard— 6/6Also in this change
Notify'sObservable.Using(ImpersonateAsSystem, …)→RunAsSystem(ActivityLogLogger.SealOverflowLocked latches system-security on the script thread (same shape as #1784's review finding) #1790 — the subscribingthread was left latched as System). The impersonation ratchet shrinks:
PackageUpdateReconciler.cs2 → 1,TotalBudget69 → 68.PluginUpdateOnGreenBuild
("A reminder is told ONCE — the gate the content-identity check cannot be"), a failure-mode row,
and the
AddressedNotifications§5 bullet marked fixed. The addressing half of this emitter(Deliver notifications to the recipient's partition so the bell stops fanning out over all schemas (444 unions / 5 min on memex-cloud) #3156 §7 step 1 — re-address to
Admin) is deliberately still open; it is independent ofsuppression.
Category: Fix.Pairs-with: none— this change removes no public type or member; it only addsPackageManifest.NotifiedModuleVersionand an optional parameter onNotificationService.CreateNotification(source-compatible for every existing caller).🤖 Generated with Claude Code