Skip to content

fix(#1318): a registry-only consumer learns a module changed by READING the registry, not by waiting - #1467

Merged
rbuergi merged 2 commits into
mainfrom
feat/1318-registry-update-signal
Aug 13, 2026
Merged

rbuergi merged 2 commits into
mainfrom
feat/1318-registry-update-signal

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

The gap, stated exactly

PluginUpdateWatcher was the whole of "an installation finds out a module changed", and it works by
subscribing to a BuildCompletion node. Across the entire tree that node is constructed in exactly
one place
— GitHubWebhookProcessor, handling a GitHub workflow_run webhook — and the watcher
only opens a subscription at all once a catalog node names a source repo.

An installation that installs from a registry over HTTP has neither: it deliberately holds no
GitHub credential, receives no webhooks, and has no catalog node. The watcher is registered
unconditionally, so on such an installation it was live and inert — zero subscriptions, forever.
Auto-update was dead end to end: PackageManifest.AutoUpdate is stamped at install time and nothing
ever fired it. That is why client-i's Hosting sat at its installed version until a human pressed
Provision — not a config gap, the absence of an input.

What the consumer subscribes to: nothing. It reads the feed it already installs from.

There is nothing to subscribe to. The registry is a different deployment with a different
database
, so no durable row is shared; and there is no cross-process change feed either —
PostgreSqlChangeListener is registered and never started in either partitioned-PG overload. A push
signal would therefore mean a second wire protocol (subscription registry, shared secret, inbox) kept
in step with the feed that already exists.

It does not need one. GET /api/plugins already returns every package's ModuleVersion — the
same content identity the install records carry — so comparing the two answers "has anything changed"
outright. That is precisely the shape BuildProtocolDriver.FollowGo arrived at for the cross-cluster
case (#1440/#1450): end on a fact you can read, not on a notification that cannot reach you.

RegistryUpdateReconciler does that, sequenced after InstanceAutoRegistrationService.Completed —
the ordering idiom ModuleDiscoveryService already uses, and necessary because that service writes
the same partitions and mints the instance key this one authenticates with.

🚨 No poll timer

It runs on an event the deployment already has — this process starting — not on a clock. #1366
removed a staleness clock for the same reason: a timer answers "how stale am I willing to be", a
question nobody asked, and turns one misconfiguration into permanent background load.

On these deployments the restart is the fan-out: plugin content and the framework image are
published by the same CI, and the portals self-update onto each new image, so a green plugin build
and a pod roll already arrive together. The honest bound, stated plainly: a consumer learns on its
next boot
— plus immediately, on demand, whenever somebody opens the catalog page, which reads the
same feed and offers the same Update. An installation that never restarts also never picks up
framework fixes, which is a louder problem with its own alarm.

One decision, two inputs

The per-module verdict moves into PackageUpdateReconciler, which both paths now call: content
identity as the gate (equal ModuleVersion ⇒ silent, not one file fetched), reminder by default,
unattended only for a record that opted in, and commercial re-authorization against the recorded
principal on every unattended apply (#830). The watcher keeps the webhook half and loses its copy of
the decision, so the two can never disagree about what "changed" means.

learns from how when
Registry instance (holds the GitHub credential) its plugin repos subscribes to Admin/_Build/{owner}.{repo} on every green build
Consumer (registry token, no GitHub) its registry reads GET /api/plugins at startup

On BuildCompletion being live-and-inert

It stays registered and unconditional, deliberately. The inertness was never the defect — the
defect was that nothing else covered the consumer. The watcher is how a REGISTRY learns from GitHub;
this is how a CONSUMER learns from the registry. They are complements, and each is inert on the shape
it does not serve, at zero cost. Gating either on config would hide a misconfiguration rather than fix
one.

An installation with no registry configured resolves no registry source and does nothing at all,
so the git path stays exactly as it was for the registry instance, which legitimately reads GitHub.
That also keeps Plugins #460's git fallback honest: nothing here changes which source content comes
from, only whether anybody ever looks.

A missing content hash is refused, loudly

A module with no manifest.lock has no content identity, so "has it changed" is unanswerable.
Answering "yes" would act on the event rather than the content — the webhook would re-install on
every green build of the repo, and the boot reconcile on every single pod start. It is refused and
logged at Warning naming the module and which side is empty, so it cannot become a quiet "my plugin
never updates". Recorded in the doc's failure-mode table.

Verification

  • MeshWeaver.PluginCatalog.Test — 277/277 (5 new), full project, DOTNET_PROCESSOR_COUNT=4.
  • MeshWeaver.Documentation.Test — 20/20 (link integrity for the doc edit).
  • dotnet build -c Release -warnaserror — clean, 0 warnings.

The first run caught a real defect in my own test: AutoUpdate is seeded from the deployment
default at install time
, not taken from the manifest handed to the installer
(PackageInstaller.SeedAutoUpdate). So the opt-in test failed honestly, and the reminder test had
been passing for the wrong reason — asserting "nothing was installed" against a path that was never
eligible to install. Both are now driven through the real seed.

The tests pin the decision (applies on change, fetches nothing on no-change, reminds without an
opt-in, ignores a package not installed here) and the wiring (mesh-scoped singleton + the
IHostedService forward that actually runs it — the same pin the watcher has, because registration
alone does not start it).

Not covered by a test: the HTTP hop itself. RegistryUpdateReconciler constructs its
RegistryPackageSource from options, so exercising it end to end needs a live registry; the feed read
is the already-proven RegistryPackageSource path (measured live: GET /api/plugins with client-i's
instance token → 200, 20 packages).

Fixes #1318

🤖 Generated with Claude Code

…registry, not by waiting

`PluginUpdateWatcher` was the whole of "an installation finds out a module changed", and it works by
subscribing to a `BuildCompletion` node. Across the entire tree that node is constructed in exactly
ONE place — `GitHubWebhookProcessor`, handling a GitHub `workflow_run` webhook — and the watcher only
opens a subscription once a catalog node names a source repo.

An installation that installs from a registry over HTTP has neither: it deliberately holds no GitHub
credential, receives no webhooks, and has no catalog node. The watcher is registered unconditionally,
so on such an installation it was live and INERT — zero subscriptions, forever. Auto-update was
therefore dead end to end: `PackageManifest.AutoUpdate` is stamped at install time and nothing ever
fired it, which is why atioz's `Hosting` sat at its installed version until a human pressed Provision.
Not a config gap; the absence of an input.

## What the consumer subscribes to: nothing — it reads the feed it already installs from

There is nothing for it to subscribe to. The registry is a DIFFERENT deployment with a different
database, so no durable row is shared, and there is no cross-process change feed either
(`PostgreSqlChangeListener` is registered and never started in either partitioned-PG overload). A
signal would mean a second wire protocol — subscription registry, shared secret, inbox — to keep in
step with the feed that already exists.

It does not need one. `GET /api/plugins` already returns every package's `ModuleVersion`, the same
content identity the install records carry, so comparing the two answers "has anything changed"
outright. That is the shape `BuildProtocolDriver.FollowGo` reached for the cross-cluster case
(#1440/#1450): end on a fact you can read, not on a notification that cannot arrive.

`RegistryUpdateReconciler` does exactly that, sequenced after `InstanceAutoRegistrationService`
.Completed — the ordering idiom `ModuleDiscoveryService` already uses, and necessary because that
service writes the same partitions and mints the instance key this one authenticates with.

## No poll timer

It runs on an event the deployment already has — this process starting — not on a clock. #1366
removed a staleness clock for the same reason. On these deployments the restart IS the fan-out:
plugin content and the framework image are published by the same CI and the portals self-update onto
each new image, so a green plugin build and a pod roll already arrive together. The honest bound is
"a consumer learns on its next boot", plus on demand whenever somebody opens the catalog page, which
reads the same feed. An installation that never restarts also never picks up framework fixes.

## One decision, two inputs

The per-module verdict moves into `PackageUpdateReconciler`, which both paths now call: content
identity as the gate (equal `ModuleVersion` ⇒ silent, not one file fetched), reminder by default,
unattended only for a record that opted in, and commercial re-authorization against the recorded
principal on every unattended apply (#830). The watcher keeps the webhook half and loses its copy of
the decision, so the two can never disagree.

`BuildCompletion` stays registered and unconditional, deliberately: the watcher is how a REGISTRY
learns from GitHub and this is how a CONSUMER learns from the registry. They are complements, and
each is inert on the shape it does not serve. Gating either on config would hide a misconfiguration
rather than fix one. An installation with no registry configured resolves no registry source and does
nothing at all, so the git path stays exactly as it was.

Verification: 277/277 in `MeshWeaver.PluginCatalog.Test` (5 new), 20/20 in
`MeshWeaver.Documentation.Test` for the doc edit, `-c Release -warnaserror` clean with 0 warnings.
The first run caught a real defect in my own test — `AutoUpdate` is seeded from the deployment default
at install time, not taken from the manifest, so the reminder test had been passing against a path
that was never eligible to install.

Fixes #1318

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 13, 2026 20:02

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.

Pull request overview

This PR closes the auto-update gap for registry-only plugin consumers by adding a boot-time reconciliation path that reads the registry feed (GET /api/plugins) and reuses the same per-module update decision logic as the existing GitHub webhook–driven watcher.

Changes:

  • Add RegistryUpdateReconciler (hosted service) to reconcile installed plugin records against configured registries at startup.
  • Centralize “module changed → remind vs auto-apply” into PackageUpdateReconciler, used by both PluginUpdateWatcher (webhook path) and RegistryUpdateReconciler (feed-read path).
  • Add consumer-side tests and update documentation/whats-new to describe the two-inputs/one-decision model.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
test/MeshWeaver.PluginCatalog.Test/RegistryUpdateReconcileTest.cs New tests pin consumer startup reconcile behavior and “content identity” gating.
src/MeshWeaver.PluginCatalog/RegistryUpdateReconciler.cs New hosted service that reads configured registries on startup and reconciles installed records.
src/MeshWeaver.PluginCatalog/PluginUpdateWatcher.cs Removes duplicated decision logic; delegates to PackageUpdateReconciler.
src/MeshWeaver.PluginCatalog/PluginCatalogConfigurationExtensions.cs Registers RegistryUpdateReconciler + IHostedService forward alongside the watcher.
src/MeshWeaver.PluginCatalog/PackageUpdateReconciler.cs New shared reconciler implementing the per-module decision and applying/notification behavior.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-13-registry-plugin-updates.md WhatsNew entry for registry-consumer update propagation.
src/MeshWeaver.Documentation/Data/Architecture/PluginUpdateOnGreenBuild.md Doc update describing the registry-consumer startup reconcile input and shared decision.

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

Comment thread src/MeshWeaver.PluginCatalog/PackageUpdateReconciler.cs Outdated
Comment thread test/MeshWeaver.PluginCatalog.Test/RegistryUpdateReconcileTest.cs
@github-actions

github-actions Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

723 tests  ±0   723 ✅ ±0   10m 27s ⏱️ -43s
 10 suites ±0     0 💤 ±0 
 10 files   ±0     0 ❌ ±0 

Results for commit 607c90f. ± Comparison against base commit beb528c.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 915 tests  +2   1 723 ✅ +2   6m 5s ⏱️ +5s
   11 suites ±0     192 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 607c90f. ± Comparison against base commit beb528c.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   11 files  ±0     11 suites  ±0   4m 17s ⏱️ -59s
1 834 tests ±0  1 731 ✅ ±0  103 💤 ±0  0 ❌ ±0 
2 162 runs  +2  2 059 ✅ +2  103 💤 ±0  0 ❌ ±0 

Results for commit 607c90f. ± Comparison against base commit beb528c.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 930 tests  +6   1 920 ✅ +6   8m 25s ⏱️ + 1m 5s
   11 suites ±0      10 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 607c90f. ± Comparison against base commit beb528c.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 514 tests  +54   1 514 ✅ +54   7m 56s ⏱️ +26s
   11 suites ± 0       0 💤 ± 0 
   11 files   ± 0       0 ❌ ± 0 

Results for commit 607c90f. ± Comparison against base commit beb528c.

This pull request removes 129 and adds 183 tests. Note that renamed tests count towards both.
MeshWeaver.Hosting.Monolith.Test.MonotonicWriteGuardWindowTest ‑ StaleWriteInsideTheHighWaterWindow_IsRefused_AndTheStoreNeverMovesBackward
MeshWeaver.Hosting.Monolith.Test.NodeAlcUnloadTeardownOrderingTest ‑ NodeAssemblyContexts_AreUnloaded_Immediately_WhenTheMeshKeepsRunning
MeshWeaver.Hosting.Monolith.Test.NodeAlcUnloadTeardownOrderingTest ‑ NodeAssemblyContexts_AreUnloaded_OnlyAfterTheIoPoolsAreDrained
MeshWeaver.Hosting.Monolith.Test.NodeOperationIssuingHubTest ‑ WorkIssuedWhileHoldingTheRouter_NeverMakesTheRouterAnEnd
MeshWeaver.Hosting.Monolith.Test.NodeOperationTargetTest ‑ ChildOfAnExecutionHub_WalksUpToThatHub_NotPastItToTheRouter
MeshWeaver.Hosting.Monolith.Test.NodeOperationTargetTest ‑ HubWithHandlersButNoExecutionOptIn_StillFallsBackToTheSharedExecutionHub
MeshWeaver.Hosting.Monolith.Test.NodeOperationTargetTest ‑ HubWithoutTheOptIn_FallsBackToTheDedicatedExecutionHub_NeverTheRouter
MeshWeaver.Hosting.Monolith.Test.NodeOperationTargetTest ‑ PortalHubThatDeclaredExecution_TargetsItself_NotTheRouter
MeshWeaver.Hosting.Monolith.Test.NodeRedirectResolutionTest ‑ A_chain_within_the_hop_cap_lands_on_the_final_target
MeshWeaver.Hosting.Monolith.Test.NodeRedirectResolutionTest ‑ A_cycle_fails_loudly_instead_of_hanging
…
MeshWeaver.Hosting.Monolith.Test.MonolithMeshTest ‑ PingPong
MeshWeaver.Hosting.Monolith.Test.MonolithMeshTest ‑ PingToNonExistentHub_ThrowsDeliveryFailure
MeshWeaver.Hosting.Monolith.Test.MonolithMeshTest ‑ StreamToNonExistentHub_ThrowsDeliveryFailure
MeshWeaver.Hosting.Monolith.Test.NextLevelNavigationTest ‑ NextLevel_AddingNearerNode_CollapsesFrontier
MeshWeaver.Hosting.Monolith.Test.NextLevelNavigationTest ‑ NextLevel_SkipsEmptyNamespaces_AndSuppressesDeeperNodes
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_FromMcpShapedSessionHubWithoutNodeOperationHandlers
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_OverwritesExistingWhenForced
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_PreservesContent
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_SkipsExistingWhenNotForced
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_ThrowsWhenSourceNotFound
…

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

2 483 tests   - 45   2 482 ✅  - 45   7m 6s ⏱️ -44s
   12 suites ± 0       1 💤 ± 0 
   12 files   ± 0       0 ❌ ± 0 

Results for commit 607c90f. ± Comparison against base commit beb528c.

This pull request removes 183 and adds 138 tests. Note that renamed tests count towards both.
MeshWeaver.Hosting.Monolith.Test.MonolithMeshTest ‑ PingPong
MeshWeaver.Hosting.Monolith.Test.MonolithMeshTest ‑ PingToNonExistentHub_ThrowsDeliveryFailure
MeshWeaver.Hosting.Monolith.Test.MonolithMeshTest ‑ StreamToNonExistentHub_ThrowsDeliveryFailure
MeshWeaver.Hosting.Monolith.Test.NextLevelNavigationTest ‑ NextLevel_AddingNearerNode_CollapsesFrontier
MeshWeaver.Hosting.Monolith.Test.NextLevelNavigationTest ‑ NextLevel_SkipsEmptyNamespaces_AndSuppressesDeeperNodes
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_FromMcpShapedSessionHubWithoutNodeOperationHandlers
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_OverwritesExistingWhenForced
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_PreservesContent
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_SkipsExistingWhenNotForced
MeshWeaver.Hosting.Monolith.Test.NodeCopyHelperTest ‑ CopyNodeTree_ThrowsWhenSourceNotFound
…
MeshWeaver.Hosting.Monolith.Test.MissingReferenceAreaTest ‑ AreaReferencingAMissingNode_RendersAClassifiedMissingReferenceFrame
MeshWeaver.Hosting.Monolith.Test.MissingReferenceAreaTest ‑ MissingReference_Compiling_AndAreaNotFound_AreMechanicallyDistinguishable
MeshWeaver.Hosting.Monolith.Test.MissingReferenceAreaTest ‑ TheFrameNamesTheBrokenPath_ButNeverTheRoutingDiagnostic
MeshWeaver.Hosting.Monolith.Test.MonotonicWriteGuardWindowTest ‑ StaleWriteInsideTheHighWaterWindow_IsRefused_AndTheStoreNeverMovesBackward
MeshWeaver.Hosting.Monolith.Test.NodeAlcUnloadTeardownOrderingTest ‑ NodeAssemblyContexts_AreUnloaded_Immediately_WhenTheMeshKeepsRunning
MeshWeaver.Hosting.Monolith.Test.NodeAlcUnloadTeardownOrderingTest ‑ NodeAssemblyContexts_AreUnloaded_OnlyAfterTheIoPoolsAreDrained
MeshWeaver.Hosting.Monolith.Test.NodeOperationIssuingHubTest ‑ WorkIssuedWhileHoldingTheRouter_NeverMakesTheRouterAnEnd
MeshWeaver.Hosting.Monolith.Test.NodeOperationTargetTest ‑ ChildOfAnExecutionHub_WalksUpToThatHub_NotPastItToTheRouter
MeshWeaver.Hosting.Monolith.Test.NodeOperationTargetTest ‑ HubWithHandlersButNoExecutionOptIn_StillFallsBackToTheSharedExecutionHub
MeshWeaver.Hosting.Monolith.Test.NodeOperationTargetTest ‑ HubWithoutTheOptIn_FallsBackToTheDedicatedExecutionHub_NeverTheRouter
…

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    66 files  ± 0      66 suites  ±0   44m 17s ⏱️ -52s
10 399 tests +17  10 093 ✅ +17  306 💤 ±0  0 ❌ ±0 
10 727 runs  +19  10 421 ✅ +19  306 💤 ±0  0 ❌ ±0 

Results for commit 607c90f. ± Comparison against base commit beb528c.

♻️ This comment has been updated with latest results.

…ot swallowed by it

Copilot on #1467: `Decide` returned early when the two ModuleVersions were equal — and `null ==
null` is equal — so the commonest shape of the missing-identity defect (a module whose CI emits no
manifest.lock, on both sides) was absorbed into 'nothing changed' and never produced the warning the
failure mode promises. The operator would be left with a plugin that never updates and no line in
the log saying why. The missing-identity check now runs first.

New test pins the ordering. Writing it caught a second, subtler thing: the install record's hash
comes from the module's manifest.lock FILE, not from the manifest handed to the installer, so a
fixture that only blanks the manifest field installs a record that HAS a hash — the test would then
have exercised the ordinary changed/unchanged path while claiming to exercise this one. The
fixture-validity assertion is what caught it, and it stays.

Copilot's second finding (CS1998 on a test marked async with no await) does not apply to this repo:
`dotnet build test/MeshWeaver.PluginCatalog.Test -c Release -warnaserror` is clean with 0 warnings,
and the pre-existing sibling test in BuildCompletionSubscriptionTest has the identical shape.

278/278 in MeshWeaver.PluginCatalog.Test; Release/-warnaserror clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi
rbuergi merged commit 43af175 into main Aug 13, 2026
19 checks passed
@rbuergi
rbuergi deleted the feat/1318-registry-update-signal branch August 19, 2026 10:30
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.

A registry-only consumer never learns a module changed: BuildCompletion is written only by the GitHub webhook, and nothing polls

2 participants