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")