Skip to content

fix(plugin-catalog): an unattended update binds to the repository the install came from (#6526) - #6541

Merged
rbuergi merged 4 commits into
mainfrom
fix/6526-update-binds-to-install-origin
Oct 12, 2026
Merged

rbuergi merged 4 commits into
mainfrom
fix/6526-update-binds-to-install-origin

Conversation

@rbuergi

@rbuergi rbuergi commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

What was wrong

PluginUpdateWatcher handed a watched repository's listing to PackageUpdateReconciler.ReconcileInstalled, which matched install records by package id alone. A second watched repository listing the same id could therefore update (or remind about) a package it never installed.

The change

  • The install record names where it came from. PackageManifest.Origin is the canonical identity of the source the install ran through (InstallOrigin): repository URL without scheme, .git or trailing /, lower-cased, plus #subdir; a registry is registry:<url>. It is attached to the source where it is built from its configured repository and subdirectory (PackageSources.FromRepo) or by RegistryPackageSource. The one install funnel, CatalogLayoutAreas.InstallOrUpdate, stamps it onto the record and overwrites any origin a manifest authors. So the catalog click, the boot install and the unattended apply all record it. PackageInstaller.SeedOrigin carries it forward when a re-stamp's source names none. This is the same "the source's identity, never an authored field" rule as fix(plugin-catalog): the repair lane re-asserts only from a source trusted to be the recorded one #6533 (Repair lane: bind the re-assert candidate to a provenance-trusted source, not an authored one #6512) applies to Source.
  • The watcher binds each pass to its repository (InstallOriginBinding). The check sits after the content-identity gate and before the floor and policy decisions. A candidate is taken only for a record whose Origin is that repository. A candidate from any other repository is ignored: one Information line, no hold stamped, no reminder.
  • Records with no recorded origin are adopted or refused (the maintainer's decision). On the first changed candidate the watcher counts which watched repositories (catalog nodes with a repository URL) list the id:
    • exactly one: the record adopts it as its origin and proceeds;
    • two or more: the update is refused, with a Warning plus a platform-bell notification Update refused: <name> naming the package and every candidate repository (new i18n keys, en + de);
    • a watched repository that cannot be listed: nothing is adopted or updated that pass, because an adoption over an incomplete count could adopt the impostor.
  • Not bound: pinned packages, where nothing moves, and the registry lane (RegistryUpdateReconciler): its single configured registry is the consumer's trust root and stamps provenance itself. A package installed through a registry records registry:<url>, so a watcher on the same instance never adopts it for a repository.

Tests (Memex.Portal.Shared.Test)

UpdateBindsToTheInstallOriginTest:

  • canonical form + the pure decision table;
  • adopt with one candidate; then an impostor listing the same id after adoption is ignored (no reminder, marker unchanged, census never read); then a control: the same candidate from the honest repository is taken;
  • refuse with two: no adoption, and the refusal notification names the package and both repositories; an incomplete count adopts nothing;
  • negative control: an unbound pass still takes the impostor's candidate. This reproduces the defect, so the arms above are not vacuous.

AnInstallRecordsItsOriginTest: a manual install through the funnel records the source's origin over an origin the manifest authors.

Local: dotnet build -c Release -warnaserror on MeshWeaver.PluginCatalog, MeshWeaver.Messaging.Hub, MeshWeaver.Documentation, Memex.Portal.Shared.Test, MeshWeaver.Messaging.Hub.Test: 0 warnings, 0 errors. After merging main (with #6533): the new tests + ARepairBindsToATrustedSourceTest, IncompleteInstallIsReassertedTest, PackagesAutoUpdateTest: 10/10. Before the merge: PackageUpdateReminderIdempotenceTest, HeldUpdatesConvergeTest, RegistryUpdateReconcilerTest, UpdateKeepsTheSyncClaimTest, MinMeshVersionHoldsTheUpdateTest: 21/21. LocalizationTest etc.: 66/66.

Doc: Doc/Architecture/PluginUpdateOnGreenBuild, new section An update comes only from the repository the install came from (#6526).

Closes #6526

Pairs-with: none — no public type or member removed (one public property added: PackageManifest.Origin).
Mirror-sync: npm run sync:i18n -- --ref <merged core sha> in MeshWeaver.Plugins after merge; two keys added (notification.packageUpdate.originAmbiguous.title / .body).

🤖 Generated with Claude Code

rbuergi and others added 2 commits October 12, 2026 06:30
… install came from (#6526)

The update watcher handed a watched repository's listing to the reconciler, which matched
install records by package id alone, so a second watched repository listing the same id could
update a package it never installed.

- The install record carries `Origin`: the canonical identity of the SOURCE the install ran
  through (`InstallOrigin`), attached where the source is built from its configured repository
  and subdirectory (`PackageSources.FromRepo`) or by the registry client, and stamped by the one
  install funnel (`CatalogLayoutAreas.InstallOrUpdate`) over anything a manifest authors.
- The watcher binds each pass to its repository: a candidate is taken only for a record whose
  origin is that repository. A record that predates the field adopts the repository when it is
  the only watched one listing the package; two or more are refused by name (Warning + platform
  bell); an unreadable watched repository adopts nothing that pass.

Tests: UpdateBindsToTheInstallOriginTest (adopt, impostor ignored after adoption + control,
refuse naming both, incomplete census, unbound negative control), AnInstallRecordsItsOriginTest.
Doc: PluginUpdateOnGreenBuild.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 12, 2026 04:32
@rbuergi
rbuergi enabled auto-merge (squash) October 12, 2026 04:32

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

Pinned-package handling, concurrent origin adoption, and one timing-sensitive test remain incorrect.

3 open findings
What changed in this PR

Binds plugin updates to their installation source, preventing same-ID packages from unrelated repositories from being applied.

Changes:

  • Adds canonical installation-origin tracking and enforcement.
  • Handles legacy records through origin adoption or localized refusal.
  • Adds integration tests and architecture documentation.
File Description
test/​Memex.Portal.Shared.Test/​UpdateBindsToTheInstallOriginTest.cs Tests origin recording, adoption, refusal, and impostor handling.
src/​MeshWeaver.PluginCatalog/​RegistryPackageSource.cs Exposes registry source identity.
src/​MeshWeaver.PluginCatalog/​PluginUpdateWatcher.cs Binds repository update passes and builds origin censuses.
src/​MeshWeaver.PluginCatalog/​PackageUpdateReconciler.cs Enforces origin decisions and raises ambiguity notifications.
src/​MeshWeaver.PluginCatalog/​PackageSources.cs Attaches origins to repository sources.
src/​MeshWeaver.PluginCatalog/​PackageInstaller.cs Persists or carries forward installation origins.
src/​MeshWeaver.PluginCatalog/​Package.cs Adds PackageManifest.Origin.
src/​MeshWeaver.PluginCatalog/​InstallOrigin.cs Defines canonicalization and binding decisions.
src/​MeshWeaver.PluginCatalog/​CatalogLayoutAreas.cs Stamps trusted source origins during installation.
src/​MeshWeaver.Messaging.Hub/​Localization/​strings.en.json Adds English refusal text.
src/​MeshWeaver.Messaging.Hub/​Localization/​strings.de.json Adds German refusal text.
src/​MeshWeaver.Documentation/​Data/​Architecture/​PluginUpdateOnGreenBuild.md Documents origin-bound updates.

🧠 Review effort: Balanced


💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/MeshWeaver.PluginCatalog/PackageUpdateReconciler.cs Outdated
Comment thread src/MeshWeaver.PluginCatalog/PackageUpdateReconciler.cs Outdated
Comment thread test/Memex.Portal.Shared.Test/UpdateBindsToTheInstallOriginTest.cs Outdated
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    20 files   -   4      20 suites   - 4   54m 24s ⏱️ + 1m 8s
11 712 tests  - 300  11 712 ✅  - 107  0 💤  - 193  0 ❌ ±0 
11 726 runs   - 300  11 726 ✅  - 107  0 💤  - 193  0 ❌ ±0 

Results for commit c0f8c4b. ± Comparison against base commit 70362c4.

This pull request removes 347 and adds 23 tests. Note that renamed tests count towards both.

   --- End of inner exception stack trace ---
   --- End of inner exception stack trace ---, expected: True)
   --- End of inner exception stack trace ---, isDenial: True)
 ---> (Inner Exception #1) MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.<---
 ---> (Inner Exception #1) MeshWeaver.Messaging.Hub.Test.InfrastructureFaultTest+ProviderException (0x80004005): Failed to connect to 10.42.18.4:5432<---
 ---> (Inner Exception #1) System.ArgumentException: Value does not fall within the expected range.<---
 ---> (Inner Exception #1) System.InvalidOperationException: boom<---
 ---> (Inner Exception #1) System.InvalidOperationException: source B is misconfigured<---
 ---> (Inner Exception #1) System.Net.Sockets.SocketException (0xFFFDFFFF): Name or service not known<---
…
Memex.Portal.Shared.Test.AnInstallRecordsItsOriginTest ‑ AManualInstall_RecordsTheSourcesOrigin_OverAnAuthoredOne
Memex.Portal.Shared.Test.InstanceIdRulesMatchTheRegistryTest ‑ TheSetupHostAgreesWithTheRegistry(candidate: "c0c3af32-cbec-4fea-8f8a-8be83390620a")
Memex.Portal.Shared.Test.SessionDenialIsAnAnswerTest ‑ OnlyAVerdictReadsAsADenial(shape: "the same verdict nested, as a late denial dispatch"···, failure: System.InvalidOperationException: write failed
 ---> System.UnauthorizedAccessException: Access denied
   --- End of inner exception stack trace ---, isDenial: True)
Memex.Portal.Shared.Test.UpdateBindsToTheInstallOriginTest ‑ ALegacyRecordAdoptsTheOnlyListingRepository_AndAnImpostorIsIgnoredAfterwards
Memex.Portal.Shared.Test.UpdateBindsToTheInstallOriginTest ‑ ALegacyRecordListedByTwoRepositories_IsRefusedByName_AndNothingIsAdopted
Memex.Portal.Shared.Test.UpdateBindsToTheInstallOriginTest ‑ APinnedRecord_IsNotHeldByAForeignCandidate
Memex.Portal.Shared.Test.UpdateBindsToTheInstallOriginTest ‑ AnAdoptionLostToAConcurrentInstall_TakesNothing
Memex.Portal.Shared.Test.UpdateBindsToTheInstallOriginTest ‑ NegativeControl_AnUnboundPassStillTakesTheImpostorsCandidate
Memex.Portal.Shared.Test.UpdateBindsToTheInstallOriginTest ‑ TheOriginIsCanonical_WhicheverSpellingOfTheRepositoryBuiltTheSource
MeshWeaver.Compiler.Pipeline.Test.SourcesWatcherStallBackoffTest ‑ The_production_classifier_recognises_a_stall_through_every_wrapping(because: "a stall as an aggregate's FIRST member", fault: System.AggregateException: One or more errors occurred. (Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.)
 ---> MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.
   --- End of inner exception stack trace ---, expected: True)
…

♻️ This comment has been updated with latest results.

rbuergi and others added 2 commits October 12, 2026 09:53
…an adoption decides on the record it wrote

Review on #6541:
- A pinned record went straight to DecideBound, whose floor gate runs before the policy switch, so
  a candidate from any watched repository (a foreign one included) could stamp or clear HeldUpdate
  and dispatch a blocking ticket for a pinned package. A bound pass now returns before it.
- AdoptOrigin forwarded its pre-write snapshot. When a concurrent install had stamped another origin
  first, the candidate was still taken. It now decides on the content the update emitted and takes
  nothing unless that names this repository.
- The test's Reminders helper waits for a row when one is expected, so the query's initial snapshot
  cannot read as none.

Tests: APinnedRecord_IsNotHeldByAForeignCandidate, AnAdoptionLostToAConcurrentInstall_TakesNothing.
Both fail without the fix.

Refs #6526

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 12, 2026 07:54
@meshweaver-cloud
meshweaver-cloud Bot disabled auto-merge October 12, 2026 07:55

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.

Comment on lines +75 to +76
ILogger? logger,
InstallOriginBinding? binding = null)
Comment on lines +72 to +73
private ImmutableDictionary<string, PluginCatalogContent> watchedOrigins =
ImmutableDictionary<string, PluginCatalogContent>.Empty.WithComparers(StringComparer.Ordinal);
Comment on lines +40 to +44
if (PackageSources.IsUrl(repo))
{
var scheme = repo.IndexOf("://", StringComparison.Ordinal);
repo = (scheme >= 0 ? repo[(scheme + 3)..] : repo).ToLowerInvariant();
}
Comment on lines +299 to +303
// 🚨 The record the update EMITTED, never the pre-write snapshot: a concurrent install or
// policy edit may have won, and deciding on the snapshot would act on stale policy and
// authorization fields — or bind a candidate to an origin the record no longer names
// (review on MeshWeaver#6541).
.Select(node => node.ContentAs<PackageManifest>(hub.JsonSerializerOptions))
@rbuergi
rbuergi enabled auto-merge October 12, 2026 09:51
@rbuergi
rbuergi merged commit 8bc4fe0 into main Oct 12, 2026
87 of 89 checks passed
@rbuergi
rbuergi deleted the fix/6526-update-binds-to-install-origin branch October 12, 2026 17:36
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.

Update watcher: bind an unattended update to the repository the install came from, not the package id

2 participants