diff --git a/deploy/helm/templates/memex-portal/config.yaml b/deploy/helm/templates/memex-portal/config.yaml
index fee710435b..735d992ab2 100644
--- a/deploy/helm/templates/memex-portal/config.yaml
+++ b/deploy/helm/templates/memex-portal/config.yaml
@@ -900,6 +900,10 @@ data:
# seed only; a record's own AutoUpdate flag stays the runtime authority thereafter.
# See Doc/Architecture/PluginUpdateOnGreenBuild.md.
PluginCatalog__AutoUpdateByDefault: "{{ .Values.config.memex_portal.PluginCatalog__AutoUpdateByDefault | default "true" }}"
+ # The per-PACKAGE default policy (Auto | Notify | None), seeded onto every fresh install record —
+ # separate from the platform's own image policy (Admin/UpdatePolicy) since 2026-09-14. Empty keeps
+ # the legacy AutoUpdateByDefault mapping (true → Auto, false → Notify).
+ PluginCatalog__DefaultUpdatePolicy: "{{ .Values.config.memex_portal.PluginCatalog__DefaultUpdatePolicy | default "" }}"
# ---- Plugin catalog: which registry this install CONSUMES, and (on a registry install) which
# git repos it SERVES. Both were previously un-templated, so an env overlay that set them was
# silently dropped — the catalog then reads "not configured" with the values file looking right,
diff --git a/memex/Memex.Portal.Shared/SelfUpdate/PlatformModuleUpdatePolicy.cs b/memex/Memex.Portal.Shared/SelfUpdate/PlatformModuleUpdatePolicy.cs
deleted file mode 100644
index 9fd14b41c1..0000000000
--- a/memex/Memex.Portal.Shared/SelfUpdate/PlatformModuleUpdatePolicy.cs
+++ /dev/null
@@ -1,66 +0,0 @@
-using System.Reactive.Linq;
-using MeshWeaver.Mesh.Services;
-using MeshWeaver.Messaging;
-using MeshWeaver.PluginCatalog;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using MeshWeaver.Hosting.SelfUpdate;
-
-namespace Memex.Portal.Shared.SelfUpdate;
-
-///
-/// Wires the module lane's unattended-landing gate (, #1664) to
-/// the platform's EXISTING update-policy surface — the admin-editable Admin/UpdatePolicy
-/// node that already governs the image roll. There is deliberately NO module-specific knob:
-///
-///
-/// - Continuous — unattended module landing is allowed: store-installed modules track
-/// their registry the same way the platform tracks its image. The record's version PATTERN
-/// governs the platform IMAGE only (): modules carry their
-/// own versions, so a Continuous record without a pattern still lands modules
-/// unattended while its platform stays on clean releases.
-/// - Stable (the platform default since 2026-09-08) / None (what an ABSENT
-/// node reads as, #3542) — declined: a deployment that pins its image takes updates
-/// deliberately, and its modules must not run ahead of that choice. The catalog card's manual
-/// Update (and any explicit install) still lands the module — the gate covers only the
-/// background reconcile.
-///
-///
-/// Read through the storage adapter (authoritative point read — the same primitive the
-/// package reconcile uses for install records); an unreadable policy DECLINES with the error as the
-/// reason, because "could not check the operator's choice" must fail toward not acting.
-///
-public sealed class PlatformModuleUpdatePolicy(IMessageHub hub, ILogger logger)
- : IModuleUpdatePolicy
-{
- ///
- public IObservable DeclineUnattendedLanding()
- {
- var storage = hub.ServiceProvider.GetService();
- if (storage is null)
- // No storage = no persisted policy = nothing an operator could have pinned: allowed
- // (a host without storage has no image roll to run ahead of either).
- return Observable.Return(null);
-
- return storage.Read(UpdatePolicyNodeType.NodePath, hub.JsonSerializerOptions)
- .Take(1)
- .Select(node =>
- {
- var policy = UpdatePolicyNodeType.Parse(node, hub.JsonSerializerOptions).Policy;
- return policy == UpdatePolicyKind.Continuous
- ? null
- : $"the deployment's update policy is {policy} "
- + $"({UpdatePolicyNodeType.NodePath}) — unattended module updates ride the "
- + "Continuous channel only; use the catalog's manual Update instead";
- })
- .Catch((Exception ex) =>
- {
- logger.LogWarning(ex,
- "[SelfUpdate] could not read {Path} — declining unattended module landing "
- + "this pass.", UpdatePolicyNodeType.NodePath);
- return Observable.Return(
- $"the update policy at {UpdatePolicyNodeType.NodePath} could not be read "
- + $"({ex.GetType().Name})");
- });
- }
-}
diff --git a/memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateConfiguration.cs b/memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateConfiguration.cs
index 7962c516ee..fe5d8e068f 100644
--- a/memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateConfiguration.cs
+++ b/memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateConfiguration.cs
@@ -20,12 +20,10 @@ public static TBuilder AddSelfUpdate(this TBuilder builder, SelfUpdate
builder.AddUpdatePolicyType();
builder.ConfigureServices(services =>
{
- // The module lane's unattended-landing gate (#1664) rides the SAME Admin/UpdatePolicy
- // node this feature owns — Continuous (default) lands store-installed modules
- // unattended, Stable/None decline. Registered here, beside the node type, so a host
- // without self-update simply has no policy provider and the PluginCatalog default
- // (allowed) applies. Platform-neutral (a storage read), so no browser guard.
- services.AddSingleton();
+ // Admin/UpdatePolicy governs the PLATFORM IMAGE roll only. Until 2026-09-14 it also
+ // gated every module's unattended landing (PlatformModuleUpdatePolicy → the retired
+ // IModuleUpdatePolicy seam); each package now carries its own policy on its install
+ // record (PackageManifest.UpdatePolicy) — "separate for the platform and for each module".
// The deployment gate (#1754): "may this environment be rolled to that release?".
// Registered unconditionally — the SAME verdict has to be readable by all three paths
// that roll a version (the poller below, CD's post-promote assertion and a manual
diff --git a/src/MeshWeaver.Deployment.Contract/DeploymentContent.cs b/src/MeshWeaver.Deployment.Contract/DeploymentContent.cs
index 407a0c953e..b9eb5fbfa3 100644
--- a/src/MeshWeaver.Deployment.Contract/DeploymentContent.cs
+++ b/src/MeshWeaver.Deployment.Contract/DeploymentContent.cs
@@ -64,9 +64,20 @@ public record DeploymentContent
[Description("Image pull Secret name in the instance namespace — required when the image registry is not ACR")]
public string? ImagePullSecret { get; init; }
- /// Self-update policy: Continuous (default), Stable, or None.
+ /// The PLATFORM image self-update policy: Continuous (default), Stable, or None. Governs the image roll only.
public string? UpdatePolicy { get; init; }
+ ///
+ /// The DEFAULT per-package (module) update policy this instance seeds onto every install
+ /// record it creates — Auto (track the registry unattended), Notify (remind,
+ /// a person clicks Update) or None (pinned). Renders as
+ /// PluginCatalog__DefaultUpdatePolicy. Separate from on
+ /// purpose: an instance may pin its platform and still track its modules, or the reverse.
+ /// A package's own record stays the runtime authority once installed.
+ ///
+ [Description("Default per-module update policy seeded at install: Auto, Notify or None")]
+ public string? ModuleUpdatePolicy { get; init; }
+
/// Who this deployment is for — a customer name, or "Systemorph" for our own.
public string? Owner { get; init; }
diff --git a/src/MeshWeaver.Deployment.Contract/DeploymentPortalConfig.cs b/src/MeshWeaver.Deployment.Contract/DeploymentPortalConfig.cs
index f6c0cd6c3c..ae4fade95d 100644
--- a/src/MeshWeaver.Deployment.Contract/DeploymentPortalConfig.cs
+++ b/src/MeshWeaver.Deployment.Contract/DeploymentPortalConfig.cs
@@ -346,6 +346,11 @@ public static SortedDictionary PortalConfig(DeploymentContent d,
Set("Deployment__Orleans__Clustering", OrleansClustering(d));
Set("SelfUpdate__MinRollInterval", string.IsNullOrWhiteSpace(d.MinRollInterval) ? DefaultMinRollInterval : d.MinRollInterval!.Trim());
+ // The per-PACKAGE default update policy (Auto | Notify | None) the instance seeds onto every
+ // install record — separate from the platform's own image policy (Admin/UpdatePolicy) since
+ // 2026-09-14. Absent renders nothing: the chart's default keeps the legacy AutoUpdateByDefault
+ // mapping (true → Auto).
+ Set("PluginCatalog__DefaultUpdatePolicy", string.IsNullOrWhiteSpace(d.ModuleUpdatePolicy) ? null : d.ModuleUpdatePolicy!.Trim());
SetBool("Modules__AutoRecycleOnStaleBuild", d.AutoRecycleOnStaleBuild);
foreach (var (slot, assembly) in ModuleSlots(d))
Set($"Modules__Required__{slot}", assembly);
diff --git a/src/MeshWeaver.Deployment.Contract/DeploymentRecordExtensions.cs b/src/MeshWeaver.Deployment.Contract/DeploymentRecordExtensions.cs
index a486db42f9..f075214669 100644
--- a/src/MeshWeaver.Deployment.Contract/DeploymentRecordExtensions.cs
+++ b/src/MeshWeaver.Deployment.Contract/DeploymentRecordExtensions.cs
@@ -109,6 +109,14 @@ d with
/// Self-update policy name (None, Continuous, Stable).
public static DeploymentContent WithUpdatePolicy(this DeploymentContent d, string policy) => d with { UpdatePolicy = policy };
+ ///
+ /// The default update policy every package installed on this instance is SEEDED with —
+ /// Auto, Notify or None (PluginCatalog:DefaultUpdatePolicy); a package
+ /// re-stamped later keeps its own. Independent of , which moves the
+ /// platform IMAGE only.
+ ///
+ public static DeploymentContent WithModuleUpdatePolicy(this DeploymentContent d, string? policy) => d with { ModuleUpdatePolicy = policy };
+
/// Minimum interval between self-update rolls (a string, e.g. 01:00:00).
public static DeploymentContent WithMinRollInterval(this DeploymentContent d, string? interval) => d with { MinRollInterval = interval };
diff --git a/src/MeshWeaver.Documentation/Data/Architecture/ConfiguringAnInstanceFromAspire.md b/src/MeshWeaver.Documentation/Data/Architecture/ConfiguringAnInstanceFromAspire.md
index 0dbb2fb32e..44440c7f10 100644
--- a/src/MeshWeaver.Documentation/Data/Architecture/ConfiguringAnInstanceFromAspire.md
+++ b/src/MeshWeaver.Documentation/Data/Architecture/ConfiguringAnInstanceFromAspire.md
@@ -160,6 +160,7 @@ configures no container.
| `WithImage(repository, tag, pullSecret, migrationRepository)` | `ImageRepository`, `PinnedImageTag`, `ImagePullSecret`, `MigrationImageRepository` | `portal.image`, `portal.imagePullSecret`, `migration.image`, `selfUpdate.registry` | — |
| `WithPinnedImageTag(tag)` | `PinnedImageTag` | `portal.image` (the tag; the pin IS the roll) | — |
| `WithUpdatePolicy(policy)` | `UpdatePolicy` | — (the self-updater reads the record) | — |
+| `WithModuleUpdatePolicy(policy)` | `ModuleUpdatePolicy` | — (rendered into the portal's config) | `PluginCatalog__DefaultUpdatePolicy` |
| `WithMinRollInterval(interval)` | `MinRollInterval` | `config.memex_portal.SelfUpdate__MinRollInterval` | `SelfUpdate__MinRollInterval` |
| `WithAutoRecycleOnStaleBuild(enabled)` | `AutoRecycleOnStaleBuild` | `config.memex_portal.Modules__AutoRecycleOnStaleBuild` | `Modules__AutoRecycleOnStaleBuild` |
| `WithPluginRepo(name, url, gitRef, isRegistrySource, secretName)` | `PluginRepos[].Name`, `PluginRepos[].Url`, `PluginRepos[].Ref`, `PluginRepos[].IsRegistrySource`, `PluginRepos[].SecretName` | `pluginCatalog.sources` (the operator's catalog file) | `PluginCatalog__*` (Aspire only) |
diff --git a/src/MeshWeaver.Documentation/Data/Architecture/ModuleAdoptionPolicy.md b/src/MeshWeaver.Documentation/Data/Architecture/ModuleAdoptionPolicy.md
index d3d4638362..c6d29a9c7e 100644
--- a/src/MeshWeaver.Documentation/Data/Architecture/ModuleAdoptionPolicy.md
+++ b/src/MeshWeaver.Documentation/Data/Architecture/ModuleAdoptionPolicy.md
@@ -17,6 +17,12 @@ still describes the older mechanism, it says so in a banner that names the imple
## The three rules
+> **Who may adopt unattended is a PER-PACKAGE choice** since 2026-09-14 — `updatePolicy` on the
+> install record (`Auto` / `Notify` / `None`), seeded from `PluginCatalog:DefaultUpdatePolicy`, and
+> independent of the platform's image policy on `Admin/UpdatePolicy`. The rules below say WHAT an
+> installation runs once a version is adopted; [Plugin Update on Green Build](@/Doc/Architecture/PluginUpdateOnGreenBuild)
+> says whether the adoption happens by itself.
+
**Maintainer clarification, 2026-09-09: compatibility follows the used API, not a platform pin.**
Keep a module usable across platform and dependency releases when the contracts it uses remain
compatible. A different version, build commit or MVID is not evidence of incompatibility. A
diff --git a/src/MeshWeaver.Documentation/Data/Architecture/PluginUpdateOnGreenBuild.md b/src/MeshWeaver.Documentation/Data/Architecture/PluginUpdateOnGreenBuild.md
index 50dff0fac3..0b2c072976 100644
--- a/src/MeshWeaver.Documentation/Data/Architecture/PluginUpdateOnGreenBuild.md
+++ b/src/MeshWeaver.Documentation/Data/Architecture/PluginUpdateOnGreenBuild.md
@@ -378,20 +378,42 @@ edit, so neither is evidence of equal content. It is the same reason
an unversioned partition instead of a version number, and why `ModuleLandingService.GenerationIdOf`
appends each file's bytes and not only its length.
-## 🚨 Reminder by default; unattended on opt-in — seeded per deployment
+## 🚨 One policy PER PACKAGE — separate from the platform's image policy (2026-09-14)
-The **platform default is explicit opt-in**: a changed module raises a `Notification` satellite on
-the install record (the bell surfaces it, the catalog card offers **Update**) and nothing installs
-until a human acts. A record whose `AutoUpdate` flag is set installs the delta unattended instead.
+> **Maintainer, 2026-09-14:** *"continuous update is set globally — it should be separate for the
+> platform and for each module."* Until that day `Admin/UpdatePolicy` — the record that decides
+> whether the platform IMAGE rolls — also gated every module's unattended landing
+> (`PlatformModuleUpdatePolicy`, the retired `IModuleUpdatePolicy` seam): a deployment that pinned
+> its image to a clean release silently stopped every module from tracking its registry, and one
+> that followed continuous builds could not pin a single module. The two are now independent.
-The opt-in is **stamped at install time from the deployment's policy**:
-`PluginCatalog:AutoUpdateByDefault` (default `false`) seeds every fresh install record. A
-deployment that wants plugins tracking their repos continuously sets it `true` — **our Helm
-deployments do**, so a plugin repo's green build reaches those portals with nobody clicking
-anything — while an installation that configures nothing stays review-first. Install-time seed
-only: the record's own flag is the runtime authority thereafter, in both directions — an update
-re-stamp carries it forward, and flipping the deployment default later changes nothing for
-already-installed packages.
+**Every install record carries its own `updatePolicy`** — `PackageManifest.UpdatePolicy`, one of:
+
+| `updatePolicy` | Content (this page's lane) | Compiled module (the registry's bundle lane) |
+|---|---|---|
+| **`Auto`** | the delta installs unattended | the newer bundle lands unattended (restart-as-activation) |
+| **`Notify`** | a `Notification` satellite on the record, told once per candidate; the catalog card offers **Update** | declined with the reason on the log; the card's **Update** lands it |
+| **`None`** (pinned) | nothing — no install, no reminder | nothing |
+
+Both lanes read the SAME value (`PackageManifest.EffectiveUpdatePolicy`), so a package never
+lands one half without the other. **The platform's own policy on `Admin/UpdatePolicy`
+(Continuous / Stable / None + the version pattern) governs the image roll and nothing else.**
+
+**The platform default is `Notify`** — explicit opt-in. The policy is **stamped at install time
+from the deployment's default**, `PluginCatalog:DefaultUpdatePolicy` (`Auto` | `Notify` | `None`;
+unset → the legacy `PluginCatalog:AutoUpdateByDefault` decides, `true` → `Auto`), seeds every
+fresh install record. **Our Helm deployments seed `Auto`**, so a plugin repo's green build reaches
+those portals with nobody clicking anything, while an installation that configures nothing stays
+review-first. Install-time seed only: the record's own policy is the runtime authority
+thereafter — an update re-stamp carries it forward (`PackageInstaller.SeedUpdatePolicy`), and
+flipping the deployment default later changes nothing for already-installed packages.
+
+**A global administrator changes ONE package's policy on its catalog card** (Auto · Notify ·
+Pinned, the current one accented — `PackageInstaller.SetUpdatePolicy`, a System write authorized by
+the card's admin gate) — and nowhere else: the record is System-owned, so a `patch` over MCP is
+refused by design. A record written before this field existed (`updatePolicy` absent) reads its
+legacy `autoUpdate` flag as `Auto` / `Notify`; the flag is kept consistent on every write for
+older readers.
An opted-in, unattended update is still fenced three ways, none of which depend on a human being
present:
@@ -474,12 +496,15 @@ is what associates a repository with a catalog, and the same value resolves the
a `workflow_run` payload carries the same `repository` object a `push` does. A catalog whose source
is a **local path** never matches a webhook, by construction.
-### 3. Opting in to unattended updates
+### 3. Choosing the per-package policy
-Per deployment: `PluginCatalog:AutoUpdateByDefault=true` seeds every FUTURE install record opted
-in (the Helm chart sets this for our portals). Per package: set `AutoUpdate` on an installed
-package's record. Both are edits to the record's own flag — the deployment key is only the
-install-time seed.
+Per deployment: `PluginCatalog:DefaultUpdatePolicy` = `Auto` | `Notify` | `None` seeds every FUTURE
+install record (the Helm chart's `PluginCatalog__DefaultUpdatePolicy`; a Hosting/Deployment record's
+`moduleUpdatePolicy` renders it). Legacy: `PluginCatalog:AutoUpdateByDefault=true` still reads as
+`Auto` when the new key is unset. Per package: the catalog card's **Updates** row (global
+administrators) — `Auto` · `Notify` · `Pinned`. The deployment key is only the install-time seed;
+the record's own `updatePolicy` is the authority. The platform's image policy on
+`Admin/UpdatePolicy` is a different knob and does not touch modules.
### 4. Receiving the module-published broadcast (consumers, #3650)
diff --git a/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json b/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json
index 3a8387372a..4b57f7eede 100644
--- a/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json
+++ b/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json
@@ -698,7 +698,7 @@
"ui.mdLoadingPrivacy": "_Datenschutzerklärung wird geladen…_",
"ui.mdLoadingRepoSettings": "_Repository-Einstellungen werden geladen…_",
"ui.mdLoadingUpdatePolicy": "_Update-Richtlinie wird geladen…_",
- "ui.mdUpdatePolicyIntro": "Die Plattform aktualisiert sich selbst gemäss der Strategie unten. **Stable** (Standard) wechselt nur auf saubere Releases wie `3.0.1`. **Continuous** wechselt auf die Continuous-Builds, die das **Versionsmuster** zulässt — zum Beispiel `3.0.1-ci*` — und verhält sich ohne Muster wie Stable. **None** schaltet die automatische Aktualisierung aus. **Nur auf CI-geprüfte (grüne) Builds aktualisieren** (standardmässig an) hält die Installation auf Builds, die CI bestanden haben — ausschalten, um auch ungeprüfte Edge-Builds zu akzeptieren. In der Flotte übergibt das Portal ein erkanntes Release an die Kontrollinstanz, die es ausrollt (ein Release jenseits des gepinnten wartet dort auf eine Freigabe); eine eigenständige Kubernetes-Installation patcht weiterhin ihr eigenes Deployment; sonst wird die neueste Version für ein manuelles Update angezeigt.",
+ "ui.mdUpdatePolicyIntro": "**Diese Richtlinie betrifft nur das Plattform-Image.** Jedes installierte Paket trägt seine eigene Update-Richtlinie (Automatisch · Benachrichtigen · Fixiert) auf seiner Katalogkarte. Die Plattform aktualisiert sich selbst gemäss der Strategie unten. **Stable** (Standard) wechselt nur auf saubere Releases wie `3.0.1`. **Continuous** wechselt auf die Continuous-Builds, die das **Versionsmuster** zulässt — zum Beispiel `3.0.1-ci*` — und verhält sich ohne Muster wie Stable. **None** schaltet die automatische Aktualisierung aus. **Nur auf CI-geprüfte (grüne) Builds aktualisieren** (standardmässig an) hält die Installation auf Builds, die CI bestanden haben — ausschalten, um auch ungeprüfte Edge-Builds zu akzeptieren. In der Flotte übergibt das Portal ein erkanntes Release an die Kontrollinstanz, die es ausrollt (ein Release jenseits des gepinnten wartet dort auf eine Freigabe); eine eigenständige Kubernetes-Installation patcht weiterhin ihr eigenes Deployment; sonst wird die neueste Version für ein manuelles Update angezeigt.",
"ui.mdNoCommits": "_Keine Commits._",
"ui.mdNoDocumentData": "_Keine Dokumentdaten._",
"ui.mdNoFileChanges": "_Keine Dateiänderungen._",
@@ -855,6 +855,11 @@
"ui.catalogRegistry": "Registry",
"ui.catalogInstall": "Installieren",
"ui.catalogUpdateTo": "Auf v{0} aktualisieren",
+ "ui.catalogUpdatePolicy": "Updates",
+ "ui.catalogUpdatePolicyAuto": "Automatisch",
+ "ui.catalogUpdatePolicyNotify": "Benachrichtigen",
+ "ui.catalogUpdatePolicyNone": "Fixiert",
+ "ui.catalogUpdatePolicyHint": "Wie dieses Paket Updates erhält — unabhängig von der Update-Richtlinie der Plattform.",
"ui.catalogInstalledVersion": "✓ Installiert v{0}",
"ui.releases": "Releases",
"ui.yourProgress": "Dein Fortschritt",
diff --git a/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json b/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json
index b72fc4a4a5..f6b513b14a 100644
--- a/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json
+++ b/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json
@@ -698,7 +698,7 @@
"ui.mdLoadingPrivacy": "_Loading privacy statement…_",
"ui.mdLoadingRepoSettings": "_Loading repository settings…_",
"ui.mdLoadingUpdatePolicy": "_Loading update policy…_",
- "ui.mdUpdatePolicyIntro": "The platform self-updates per the strategy below. **Stable** (the default) rolls only to clean releases such as `3.0.1`. **Continuous** rolls to the continuous builds the **version pattern** admits — for example `3.0.1-ci*` — and without a pattern it behaves like Stable. **None** disables auto-update. **Only update to CI-verified (green) builds** (default on) keeps the install on builds that passed CI — turn it off to also accept unverified edge builds. On the fleet the portal hands a detected release to the control instance, which rolls it (a release beyond the pinned one waits for an approval there); a standalone Kubernetes install still patches its own deployment; elsewhere the latest version is surfaced for a manual update.",
+ "ui.mdUpdatePolicyIntro": "**This policy moves the platform image only.** Each installed package carries its own update policy (Auto · Notify · Pinned) on its catalog card. The platform self-updates per the strategy below. **Stable** (the default) rolls only to clean releases such as `3.0.1`. **Continuous** rolls to the continuous builds the **version pattern** admits — for example `3.0.1-ci*` — and without a pattern it behaves like Stable. **None** disables auto-update. **Only update to CI-verified (green) builds** (default on) keeps the install on builds that passed CI — turn it off to also accept unverified edge builds. On the fleet the portal hands a detected release to the control instance, which rolls it (a release beyond the pinned one waits for an approval there); a standalone Kubernetes install still patches its own deployment; elsewhere the latest version is surfaced for a manual update.",
"ui.mdNoCommits": "_No commits._",
"ui.mdNoDocumentData": "_No document data._",
"ui.mdNoFileChanges": "_No file changes._",
@@ -855,6 +855,11 @@
"ui.catalogRegistry": "registry",
"ui.catalogInstall": "Install",
"ui.catalogUpdateTo": "Update to v{0}",
+ "ui.catalogUpdatePolicy": "Updates",
+ "ui.catalogUpdatePolicyAuto": "Auto",
+ "ui.catalogUpdatePolicyNotify": "Notify",
+ "ui.catalogUpdatePolicyNone": "Pinned",
+ "ui.catalogUpdatePolicyHint": "How this package takes updates — independent of the platform's own update policy.",
"ui.catalogInstalledVersion": "✓ Installed v{0}",
"ui.releases": "Releases",
"ui.yourProgress": "Your progress",
diff --git a/src/MeshWeaver.PluginCatalog/CatalogLayoutAreas.cs b/src/MeshWeaver.PluginCatalog/CatalogLayoutAreas.cs
index 82f1f1d696..32c44d9550 100644
--- a/src/MeshWeaver.PluginCatalog/CatalogLayoutAreas.cs
+++ b/src/MeshWeaver.PluginCatalog/CatalogLayoutAreas.cs
@@ -792,6 +792,13 @@ private static UiControl BuildCard(
}), "install");
}
+ // The per-PACKAGE update policy (Auto / Notify / Pinned) — a global administrator's control,
+ // rendered on an INSTALLED package only. Separate from the platform's own update policy on
+ // the Settings ▸ Update policy tab (2026-09-14): that one moves the image, this one moves
+ // this package. The click authorizes; the write runs as System (SetUpdatePolicy).
+ if (installed is not null && viewerIsGlobalAdmin)
+ card = card.WithView(UpdatePolicyControl(host, pkg.Id, installed.EffectiveUpdatePolicy), "updatePolicy");
+
// 🚨 The LAST STEP of the install, said out loud (#1979). Loading a module is
// restart-as-activation by design, so for a package that declares one the install is not
// finished when the content lands — and until this line existed nothing anywhere said so:
@@ -861,6 +868,41 @@ private static UiControl BuildCard(
return card;
}
+ ///
+ /// The per-package policy row: the label, the three choices as buttons with the current one
+ /// accented, and the one-line hint. A click writes the record under System and the record
+ /// stream re-emits, which re-renders the card with the new choice accented.
+ ///
+ private static UiControl UpdatePolicyControl(LayoutAreaHost host, string packageId, PackageUpdatePolicy current)
+ {
+ var logger = Logger(host);
+ UiControl Choice(PackageUpdatePolicy policy, string key) =>
+ Controls.Button(host.Localize(key))
+ .WithAppearance(policy == current ? Appearance.Accent : Appearance.Neutral)
+ .WithClickAction(_ =>
+ {
+ if (policy != current)
+ PackageInstaller.SetUpdatePolicy(host.Hub, packageId, policy, logger)
+ .Subscribe(
+ _ => { },
+ ex => logger?.LogWarning(ex, "Setting update policy {Policy} on {Id} failed.", policy, packageId));
+ return Task.CompletedTask;
+ });
+ var row = Controls.Stack
+ .WithOrientation(Orientation.Horizontal)
+ .WithHorizontalGap(6)
+ .WithStyle("align-items: center; margin-top: 8px;")
+ .WithView(Controls.Body(host.Localize("ui.catalogUpdatePolicy"))
+ .WithStyle("font-size: 12px; color: var(--neutral-foreground-hint); margin-right: 4px;"))
+ .WithView(Choice(PackageUpdatePolicy.Auto, "ui.catalogUpdatePolicyAuto"), "auto")
+ .WithView(Choice(PackageUpdatePolicy.Notify, "ui.catalogUpdatePolicyNotify"), "notify")
+ .WithView(Choice(PackageUpdatePolicy.None, "ui.catalogUpdatePolicyNone"), "none");
+ return Controls.Stack
+ .WithView(row)
+ .WithView(Controls.Body(host.Localize("ui.catalogUpdatePolicyHint"))
+ .WithStyle("font-size: 11px; color: var(--neutral-foreground-hint); display: block; margin-top: 2px;"));
+ }
+
///
/// Fire the install; the Plugins-registry stream re-emits on completion → the card flips.
///
diff --git a/src/MeshWeaver.PluginCatalog/ModuleUpdateDecision.cs b/src/MeshWeaver.PluginCatalog/ModuleUpdateDecision.cs
index d0df3e97fa..ac45b60773 100644
--- a/src/MeshWeaver.PluginCatalog/ModuleUpdateDecision.cs
+++ b/src/MeshWeaver.PluginCatalog/ModuleUpdateDecision.cs
@@ -1,26 +1,20 @@
+using System;
using MeshWeaver.Plugin.Packaging;
namespace MeshWeaver.PluginCatalog;
///
-/// The platform's gate on UNATTENDED module landing — the seam through which the module lane rides
-/// the EXISTING update-policy surface instead of growing a knob of its own (#1664).
-///
-/// The memex portals implement this over the admin-editable Admin/UpdatePolicy node
-/// (Continuous / Stable / None — the same single policy that governs the platform image roll):
-/// Continuous, the platform default, allows unattended module landing; Stable and
-/// None decline it, because a deployment that pins its image has chosen to take updates
-/// deliberately, and its modules must not run ahead of that choice. A host that registers NO
-/// implementation is the platform default: unattended landing is ALLOWED.
-///
-/// The gate covers only the RECONCILER's unattended lane. An explicit install (the Store's
-/// Provision funnel, the default-install seed) lands its module regardless — the operator asked
-/// for the package, and the module is part of what they asked for.
+/// RETIRED (2026-09-14): the module-update policy is PER PACKAGE —
+/// (), seeded from PluginCatalog:DefaultUpdatePolicy — and no
+/// longer a host-wide gate read off the platform's Admin/UpdatePolicy image policy. Nothing
+/// registers or consumes this interface any more; it stays as a public type because a module
+/// compiled against an earlier platform may still hold the TypeRef (Systemorph/MeshWeaver#2370),
+/// and a removed public type is binary-breaking for it the moment the platform rolls.
///
+[Obsolete("The module-update policy is per package (PackageManifest.UpdatePolicy); this host-wide gate is no longer registered or consulted.")]
public interface IModuleUpdatePolicy
{
- /// Why unattended module landing is currently declined, or null when it is allowed.
- /// Cold; emits exactly once per subscription.
+ /// Why unattended module landing is currently declined, or null when it is allowed. Cold; emits exactly once per subscription.
IObservable DeclineUnattendedLanding();
}
@@ -44,7 +38,7 @@ public enum ModuleUpdateAction
///
SkipPlatformBelowFloor,
- /// The deployment's update policy declines unattended landing (Stable/None).
+ /// The package's own update policy declines unattended landing (Notify / None).
SkipPolicy,
/// The registry serves no bundle for this package.
@@ -143,9 +137,10 @@ public static class ModuleUpdateDecision
/// This deployment's activation entry for the module, or null when it was
/// never landed (which includes "installed before the module lane existed" — those heal by
/// landing).
- /// Why the deployment's update policy declines unattended landing,
- /// or null when it allows it (; null when no policy is
- /// registered — the platform default is auto-update).
+ /// Why THIS package's own update policy declines unattended
+ /// landing ( is Notify or None — production
+ /// passes RegistryUpdateReconciler.PolicyDecline), or null when it is Auto. Per package
+ /// since 2026-09-14; the platform's image policy on Admin/UpdatePolicy plays no part.
///
/// 🚨 Whether the entry's landed assembly is actually ON DISK — production passes
/// bound to the module root, the same
diff --git a/src/MeshWeaver.PluginCatalog/Package.cs b/src/MeshWeaver.PluginCatalog/Package.cs
index b9b77631e6..c21347052a 100644
--- a/src/MeshWeaver.PluginCatalog/Package.cs
+++ b/src/MeshWeaver.PluginCatalog/Package.cs
@@ -4,6 +4,34 @@
namespace MeshWeaver.PluginCatalog;
+///
+/// How ONE installed package takes updates — the per-package policy, separate from the platform's
+/// image policy on Admin/UpdatePolicy (maintainer, 2026-09-14: "continuous update is set
+/// globally; it should be separate for the platform and for each module"). It governs BOTH
+/// halves of a package update — its node content () and its
+/// compiled module bundle () — so a package is either
+/// tracking its registry, reminding, or pinned, never one half without the other.
+///
+/// 🚨 The enum ORDER is binary contract: the hub serializer drops whichever member is zero on
+/// write (WhenWritingDefault), which is why is
+/// nullable rather than this enum being reordered. Do not reorder.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum PackageUpdatePolicy
+{
+ /// Land every change unattended — content and module — the moment the registry serves
+ /// a newer content identity or a newer bundle for this platform.
+ Auto,
+
+ /// Tell the administrators once per candidate (a notification on the install record;
+ /// the catalog card offers Update) and install nothing until a person acts.
+ Notify,
+
+ /// Pinned: neither land nor remind. Updates are applied only by an explicit install —
+ /// the catalog's Update, the Store's Provision — never by the reconciler.
+ None,
+}
+
///
/// What an installable package delivers into the mesh.
///
@@ -374,6 +402,32 @@ public record PackageManifest
///
public bool AutoUpdate { get; init; }
+ ///
+ /// THE per-package update policy — /
+ /// / — the
+ /// sole runtime authority for this package's unattended updates, content AND module, and
+ /// independent of the platform's own image policy (Admin/UpdatePolicy). Seeded at
+ /// install time from the deployment's
+ /// (PackageInstaller.SeedUpdatePolicy), carried forward untouched by every re-stamp,
+ /// and editable per package by a global administrator on the catalog card
+ /// (PackageInstaller.SetUpdatePolicy).
+ ///
+ /// null is the LEGACY shape — a record written before this field existed — and
+ /// reads through as what said:
+ /// true → Auto, false → Notify. Nullable on purpose (see the enum's order
+ /// contract): an explicit Auto, the zero member, must survive the round trip.
+ ///
+ public PackageUpdatePolicy? UpdatePolicy { get; init; }
+
+ ///
+ /// The policy this package actually follows: when declared, else
+ /// the legacy flag read as Auto / Notify. Every reconciler branches on
+ /// THIS, never on the flag. Pure.
+ ///
+ [JsonIgnore]
+ public PackageUpdatePolicy EffectiveUpdatePolicy =>
+ UpdatePolicy ?? (AutoUpdate ? PackageUpdatePolicy.Auto : PackageUpdatePolicy.Notify);
+
///
/// The candidate this installation has ALREADY told the user
/// about — the reminder path's own silence gate, and the second half of
diff --git a/src/MeshWeaver.PluginCatalog/PackageInstaller.cs b/src/MeshWeaver.PluginCatalog/PackageInstaller.cs
index 33936c635f..a6981c1a49 100644
--- a/src/MeshWeaver.PluginCatalog/PackageInstaller.cs
+++ b/src/MeshWeaver.PluginCatalog/PackageInstaller.cs
@@ -513,7 +513,75 @@ internal static MeshNode InstalledPartitionPolicy() =>
///
// Internal for the BuildCompletionSubscriptionTest pin (InternalsVisibleTo).
internal static bool SeedAutoUpdate(PackageManifest? existingRecord, PluginCatalogOptions? options) =>
- existingRecord?.AutoUpdate ?? options?.AutoUpdateByDefault ?? false;
+ SeedUpdatePolicy(existingRecord, options) == PackageUpdatePolicy.Auto;
+
+ ///
+ /// THE per-package policy seed: an existing record's own policy wins (declared, or its legacy
+ /// flag read as Auto / Notify), else the deployment's ,
+ /// else the legacy (true → Auto, else
+ /// Notify), else Notify — the platform default is reminder-only. Pure; pinned in
+ /// MeshWeaver.PluginCatalog.Test. An update re-stamp therefore never resets a policy an
+ /// administrator chose, and flipping the deployment default later changes nothing for
+ /// already-installed packages.
+ ///
+ internal static PackageUpdatePolicy SeedUpdatePolicy(PackageManifest? existingRecord, PluginCatalogOptions? options) =>
+ existingRecord?.EffectiveUpdatePolicy
+ ?? options?.DefaultUpdatePolicy
+ ?? (options?.AutoUpdateByDefault == true ? PackageUpdatePolicy.Auto : PackageUpdatePolicy.Notify);
+
+ ///
+ /// Sets ONE installed package's update policy — the catalog card's per-package control for a
+ /// global administrator. Reads the record, writes it back with
+ /// (and the legacy flag kept consistent for older
+ /// readers) under the SYSTEM identity: the install-records partition denies every user
+ /// identity a write by design, so the AUTHORIZATION is the global-admin gate on the surface
+ /// that offered the control, and the write runs as System — the same division the install
+ /// path uses. Cold; emits the written record; faults when the record does not exist.
+ ///
+ public static IObservable SetUpdatePolicy(
+ IMessageHub hub, string packageId, PackageUpdatePolicy policy, ILogger? logger = null)
+ {
+ ArgumentNullException.ThrowIfNull(hub);
+ if (string.IsNullOrWhiteSpace(packageId))
+ return Observable.Throw(new ArgumentException(
+ "A package id is required to set an update policy.", nameof(packageId)));
+ var persistence = hub.ServiceProvider.GetService();
+ var accessService = hub.ServiceProvider.GetService();
+ if (persistence is null || accessService is null)
+ return Observable.Throw(new InvalidOperationException(
+ "No storage adapter / AccessService is registered — cannot set an update policy: "
+ + "the write must run as System because Plugins/_Policy denies it to every ordinary caller."));
+ var recordPath = $"{InstalledPartition}/{packageId}";
+ var serializerOptions = hub.JsonSerializerOptions;
+ return persistence.Read(recordPath, serializerOptions)
+ .Take(1)
+ .SelectMany(node =>
+ {
+ var manifest = node?.ContentAs(serializerOptions);
+ if (node is null || manifest is null)
+ return Observable.Throw(new InvalidOperationException(
+ $"No install record at '{recordPath}' — the package is not installed here."));
+ var updated = node with
+ {
+ Content = manifest with
+ {
+ UpdatePolicy = policy,
+ AutoUpdate = policy == PackageUpdatePolicy.Auto,
+ // A new policy is a new question for the reminder path: forget what was
+ // told under the old one, so a switch back to Notify reminds again.
+ NotifiedModuleVersion = null,
+ },
+ };
+ logger?.LogInformation(
+ "Package {Id}: update policy set to {Policy} (was {Previous}).",
+ packageId, policy, manifest.EffectiveUpdatePolicy);
+ // RunAsSystem, never Observable.Using (#1790).
+ return accessService.RunAsSystem(() => hub.NodeOperationIssuingHub()
+ .Observe(new CreateOrUpdateNodeRequest(updated)))
+ .Take(1)
+ .Select(_ => updated);
+ });
+ }
/// How long one root gets to activate before the warm gives up on it.
private static readonly TimeSpan WarmTimeout = TimeSpan.FromSeconds(90);
@@ -2278,6 +2346,11 @@ private static IObservable WriteInstalledRecord(
// alone, exactly as ManifestFiles' own doc promises (Copilot catch: a full
// install passes the catalog manifest through, so without this it leaked in).
ManifestFiles = null,
+ // The per-package policy (Auto / Notify / None) — seeded once, carried
+ // forward on every re-stamp; the legacy flag is kept consistent for readers
+ // that still branch on it.
+ UpdatePolicy = SeedUpdatePolicy(
+ existingRecord, hub.ServiceProvider.GetService()),
AutoUpdate = SeedAutoUpdate(
existingRecord, hub.ServiceProvider.GetService()),
// WHO authorized this install — what an unattended update of a commercial
diff --git a/src/MeshWeaver.PluginCatalog/PackageUpdateReconciler.cs b/src/MeshWeaver.PluginCatalog/PackageUpdateReconciler.cs
index d63604e782..899dda5f3b 100644
--- a/src/MeshWeaver.PluginCatalog/PackageUpdateReconciler.cs
+++ b/src/MeshWeaver.PluginCatalog/PackageUpdateReconciler.cs
@@ -164,18 +164,26 @@ private static IObservable Decide(
var detail = Describe(pkg, record);
+ var policy = record.EffectiveUpdatePolicy;
logger?.LogInformation(
- "Package update: {Id} has an update ({Old} → {New}; {Detail}); autoUpdate={Auto}. {Provenance}",
- pkg.Id, record.ModuleVersion, pkg.ModuleVersion, detail, record.AutoUpdate, provenance);
+ "Package update: {Id} has an update ({Old} → {New}; {Detail}); updatePolicy={Policy}. {Provenance}",
+ pkg.Id, record.ModuleVersion, pkg.ModuleVersion, detail, policy, provenance);
- return record.AutoUpdate
- ? Apply(hub, meshService, accessService, source, sourceRef, pkg, record, recordPath,
- provenance, detail, logger)
- : Notify(
+ // The PACKAGE's own policy decides — never the platform's image policy (Admin/UpdatePolicy),
+ // which governs the image roll alone since 2026-09-14.
+ return policy switch
+ {
+ PackageUpdatePolicy.Auto => Apply(hub, meshService, accessService, source, sourceRef, pkg, record,
+ recordPath, provenance, detail, logger),
+ PackageUpdatePolicy.Notify => Notify(
hub, meshService, accessService, recordPath, pkg, record, UpdateAvailableKind,
$"Update available: {pkg.Name ?? pkg.Id}",
$"A new build of {pkg.Name ?? pkg.Id} is available ({detail}). {provenance}.",
- logger);
+ logger),
+ // Pinned: the update exists, the log says so, and nothing else moves — no notification
+ // either, because the administrator chose not to be reminded.
+ _ => Observable.Return(Unit.Default),
+ };
}
///
diff --git a/src/MeshWeaver.PluginCatalog/PluginBundleClient.cs b/src/MeshWeaver.PluginCatalog/PluginBundleClient.cs
index e39036b770..e1648e15f5 100644
--- a/src/MeshWeaver.PluginCatalog/PluginBundleClient.cs
+++ b/src/MeshWeaver.PluginCatalog/PluginBundleClient.cs
@@ -340,11 +340,16 @@ private string RequiredMessage(string pluginId, string kind, string? reason) =>
/// The module's entry-assembly name (the package manifest's
/// declaration).
/// The install record's mesh path, recorded on the activation entry.
- /// True on the reconciler's background lane — gates the landing on
- /// (the deployment's existing update-policy surface; absent =
- /// allowed, the platform default). An explicit install passes false: the operator asked.
+ /// True on the reconciler's background lane. An explicit install
+ /// passes false: the operator asked, and the module is part of what they asked for.
+ /// On the unattended lane, why THIS package's own update policy
+ /// declines an unattended landing ( / None on
+ /// its install record — ), or null when it
+ /// is . Per package, never the platform's image policy:
+ /// the two are separate since 2026-09-14. Ignored when is false.
public IObservable AdoptModule(
- string pluginId, string moduleName, string? packagePath = null, bool unattended = false)
+ string pluginId, string moduleName, string? packagePath = null, bool unattended = false,
+ string? policyDecline = null)
{
var landing = _hub.ServiceProvider.GetService();
if (landing is null)
@@ -355,14 +360,12 @@ public IObservable AdoptModule(
return Observable.Return(0);
}
- var policy = unattended ? _hub.ServiceProvider.GetService() : null;
- var policyDecline = policy?.DeclineUnattendedLanding().Take(1)
- ?? Observable.Return(null);
+ var declineOnLane = Observable.Return(unattended ? policyDecline : null);
return _index.GetOrCreate(FetchIndex)
.Take(1)
.SelectMany(index => landing.GetActivation().Take(1)
- .SelectMany(activation => policyDecline
+ .SelectMany(activation => declineOnLane
.SelectMany(declined =>
{
var bundle = index.Bundles?.FirstOrDefault(b =>
diff --git a/src/MeshWeaver.PluginCatalog/PluginCatalogOptions.cs b/src/MeshWeaver.PluginCatalog/PluginCatalogOptions.cs
index e697eeadb9..01982cc70e 100644
--- a/src/MeshWeaver.PluginCatalog/PluginCatalogOptions.cs
+++ b/src/MeshWeaver.PluginCatalog/PluginCatalogOptions.cs
@@ -58,6 +58,16 @@ public sealed class PluginCatalogOptions
///
public bool AutoUpdateByDefault { get; set; }
+ ///
+ /// The deployment's DEFAULT per-package update policy (PluginCatalog:DefaultUpdatePolicy
+ /// = Auto | Notify | None), consulted ONLY at install time: a fresh install
+ /// record is seeded with it (PackageInstaller.SeedUpdatePolicy). Unset → the legacy
+ /// decides (true → Auto, else Notify). Separate from
+ /// the platform's own image policy on Admin/UpdatePolicy by design: a deployment may
+ /// pin its platform and still track every module, or the reverse.
+ ///
+ public PackageUpdatePolicy? DefaultUpdatePolicy { get; set; }
+
///
/// A registration bootstrap key (mwr_…) for first-startup auto-registration: when set —
/// and no is configured and no instance key is stored yet — the
diff --git a/src/MeshWeaver.PluginCatalog/PluginUpdateWatcher.cs b/src/MeshWeaver.PluginCatalog/PluginUpdateWatcher.cs
index ab25d4703a..13abae9e51 100644
--- a/src/MeshWeaver.PluginCatalog/PluginUpdateWatcher.cs
+++ b/src/MeshWeaver.PluginCatalog/PluginUpdateWatcher.cs
@@ -294,7 +294,8 @@ private void ReconcileInstalled(
/// registry-driven path.
///
// Internal for the BuildCompletionSubscriptionTest pin (InternalsVisibleTo).
- internal static bool ShouldAutoApply(PackageManifest record) => record.AutoUpdate;
+ internal static bool ShouldAutoApply(PackageManifest record) =>
+ record.EffectiveUpdatePolicy == PackageUpdatePolicy.Auto;
private static (string Owner, string Repo) OctokitParse(string url)
{
diff --git a/src/MeshWeaver.PluginCatalog/RegistryUpdateReconciler.cs b/src/MeshWeaver.PluginCatalog/RegistryUpdateReconciler.cs
index 6029db7c92..0667d27e0a 100644
--- a/src/MeshWeaver.PluginCatalog/RegistryUpdateReconciler.cs
+++ b/src/MeshWeaver.PluginCatalog/RegistryUpdateReconciler.cs
@@ -925,11 +925,14 @@ private sealed class FeedReadExhaustedException(Exception cause, int attempts)
/// without a download, and skips a foreign-framework registry silently-with-log (it becomes
/// relevant after the next image roll).
///
- /// The policy gate is the deployment's EXISTING update policy
- /// ( — the memex portals wire it to Admin/UpdatePolicy):
- /// this lane passes unattended: true, so Continuous (the platform default, and the
- /// default when no policy is registered) lands unattended while Stable/None decline. There is
- /// deliberately NO module-specific knob.
+ /// The policy gate is the PACKAGE's own update policy
+ /// ( on its install record): this lane
+ /// passes unattended: true, so Auto lands unattended while Notify / None
+ /// decline — per package, and independent of the platform's image policy on
+ /// Admin/UpdatePolicy (separated 2026-09-14; the former IModuleUpdatePolicy seam
+ /// that tied the two together is gone). The content half of the same package follows the
+ /// same policy in , so a package never lands one half
+ /// without the other.
///
/// Sequential, and failure-tolerant per package — one unreachable bundle must not
/// withhold the rest, and already absorbs its own
@@ -956,7 +959,8 @@ private IObservable ReconcileModules(
.SelectMany(record => record is null
// Not installed here → somebody else's module; nothing to reconcile.
? Observable.Return(Unit.Default)
- : AdoptOne(bundles, registryName, pkg.Id, pkg.Module!, recordPath));
+ : AdoptOne(bundles, registryName, pkg.Id, pkg.Module!, recordPath,
+ record.ContentAs(hub.JsonSerializerOptions)));
})
.ToObservable()
.Concat()
@@ -1004,7 +1008,8 @@ internal IObservable ReconcileModule(PluginRegistryReference registry, str
name, packageId);
return Observable.Return(Unit.Default);
}
- var module = record.ContentAs(hub.JsonSerializerOptions)?.Module;
+ var manifest = record.ContentAs(hub.JsonSerializerOptions);
+ var module = manifest?.Module;
if (string.IsNullOrWhiteSpace(module))
{
logger.LogDebug(
@@ -1012,7 +1017,7 @@ internal IObservable ReconcileModule(PluginRegistryReference registry, str
name, packageId);
return Observable.Return(Unit.Default);
}
- return AdoptOne(bundles, name, packageId, module, recordPath);
+ return AdoptOne(bundles, name, packageId, module, recordPath, manifest);
});
})
// The wave of one: ends the same way every wave ends (#3395).
@@ -1026,8 +1031,10 @@ internal IObservable ReconcileModule(PluginRegistryReference registry, str
/// cannot differ in what "adopt" means.
///
private IObservable AdoptOne(
- PluginBundleClient bundles, string registryName, string packageId, string moduleName, string recordPath) =>
- bundles.AdoptModule(packageId, moduleName, recordPath, unattended: true)
+ PluginBundleClient bundles, string registryName, string packageId, string moduleName, string recordPath,
+ PackageManifest? record) =>
+ bundles.AdoptModule(packageId, moduleName, recordPath, unattended: true,
+ policyDecline: PolicyDecline(record))
// 🚨 A HANG is worse than a failure here: the packages run as one sequential Concat,
// so a single adopt that never answers (a wedged record read, a download that stalls)
// silently starves EVERY package after it — on memex.systemorph.com the Northwind
@@ -1048,6 +1055,21 @@ private IObservable AdoptOne(
})
.Select(_ => Unit.Default);
+ ///
+ /// Why THIS package's own policy declines an unattended module landing, or null when it is
+ /// . An unreadable record declines (Notify) — absence of
+ /// a readable policy must never read as "land". Pure; pinned in MeshWeaver.PluginCatalog.Test.
+ ///
+ internal static string? PolicyDecline(PackageManifest? record)
+ {
+ var policy = record?.EffectiveUpdatePolicy ?? PackageUpdatePolicy.Notify;
+ return policy == PackageUpdatePolicy.Auto
+ ? null
+ : $"the package's update policy is {policy} — unattended module landing rides the Auto "
+ + "policy only; use the catalog's manual Update instead (per-package policy, "
+ + "independent of the platform's Admin/UpdatePolicy)";
+ }
+
///
/// Closes the landing wave by proposing the module set the activation record now describes
/// (#3395). Never fails the reconcile: a proposal that cannot be written leaves the mesh on its
diff --git a/test/MeshWeaver.Deployment.Contract.Test/RendererParityTest.cs b/test/MeshWeaver.Deployment.Contract.Test/RendererParityTest.cs
index e410c08a89..3effb44656 100644
--- a/test/MeshWeaver.Deployment.Contract.Test/RendererParityTest.cs
+++ b/test/MeshWeaver.Deployment.Contract.Test/RendererParityTest.cs
@@ -158,7 +158,7 @@ internal static DeploymentContent FullyPopulated() =>
.WithGrafana("https://grafana.example.com")
.WithDatabase("portal", server: "pg-portal", username: "memex", host: "pg-portal.example.internal", port: 5432, connectionSecret: "portal-pg-connection")
.WithImage("ghcr.io/systemorph/memex-portal-ai", tag: "3.1.0", pullSecret: "ghcr-pull")
- .WithUpdatePolicy("stable").WithMinRollInterval("00:30:00").WithAutoRecycleOnStaleBuild(true)
+ .WithUpdatePolicy("stable").WithModuleUpdatePolicy("Notify").WithMinRollInterval("00:30:00").WithAutoRecycleOnStaleBuild(true)
.WithPluginRepo("plugins", "https://github.com/Systemorph/MeshWeaver.Plugins", gitRef: "main")
.PreInstall("MeshWeaver.Plugins/Hosting")
.WithRequiredModule("MeshWeaver.Hosting.Postgres")