diff --git a/src/MeshWeaver.PluginCatalog/InstanceAutoRegistrationService.cs b/src/MeshWeaver.PluginCatalog/InstanceAutoRegistrationService.cs index a1ef0e96e6..70c0707f5d 100644 --- a/src/MeshWeaver.PluginCatalog/InstanceAutoRegistrationService.cs +++ b/src/MeshWeaver.PluginCatalog/InstanceAutoRegistrationService.cs @@ -844,8 +844,8 @@ private IObservable InstallDefaults(PluginCatalogOptions + "Source/* pattern and match nothing: [{Malformed}]. The list is therefore not an explicit " + "package set, and the preInstalled baseline is kept.", malformed.Count, string.Join(", ", malformed)); - var baseline = BaselineApplies(options.InstallPreInstalledPackagesConfigured, options.InstallByDefault); - if (options.InstallPreInstalledPackagesConfigured is null && !baseline) + var baseline = BaselineApplies(options.EffectiveInstallPreInstalledPackages, options.InstallByDefault); + if (options.EffectiveInstallPreInstalledPackages is null && !baseline) logger.LogInformation( "[DefaultInstall] PluginCatalog:InstallByDefault declares an EXPLICIT package set [{Wanted}] — " + "exactly those packages and what they declare in `requires` are selected; the " diff --git a/src/MeshWeaver.PluginCatalog/PluginCatalogOptions.cs b/src/MeshWeaver.PluginCatalog/PluginCatalogOptions.cs index e779526074..820fa83496 100644 --- a/src/MeshWeaver.PluginCatalog/PluginCatalogOptions.cs +++ b/src/MeshWeaver.PluginCatalog/PluginCatalogOptions.cs @@ -152,27 +152,47 @@ public sealed class PluginCatalogOptions /// declares an EXPLICIT set — every entry naming a package (Source/Package), none a whole /// source (Source/*). A deployment that names its packages gets exactly those plus their /// declared requires; until this, the baseline added every preInstalled package of - /// every source on top (a fresh control instance naming five packages selected 48). Set it - /// true to keep the baseline beside a declared set, false to suppress it - /// everywhere — an explicit value always wins. The getter keeps reading true while - /// unset (the surface and its default are unchanged); the decision reads - /// , which tells "unset" from "true". + /// every source on top (a fresh control instance naming five packages selected 48). Configure the + /// key true to keep the baseline beside a declared set, false to suppress it + /// everywhere — an explicit configured value always wins. 🚨 In CODE, assigning this property + /// true is indistinguishable from its default (the configuration binder writes the + /// default back through the setter on every bind, so "set to true" cannot be told from "not + /// set"); code that wants the baseline beside an explicit set assigns + /// = true. Assigning false + /// here still suppresses it. This property keeps its surface and default + /// (true); "unset" is read from , which + /// the configuration binds from the SAME key. /// - public bool InstallPreInstalledPackages - { - get => installPreInstalledPackages ?? true; - set => installPreInstalledPackages = value; - } + public bool InstallPreInstalledPackages { get; set; } = true; - private bool? installPreInstalledPackages; + /// + /// PluginCatalog:InstallPreInstalledPackages exactly as CONFIGURED — null when the key is + /// absent. Bound from the same key as + /// (). + /// + /// 🚨 Why a second, nullable property and not a nullable backing field behind the + /// bool. ConfigurationBinder reads every bindable property's CURRENT value and writes + /// it back through the setter whether or not the key exists (it skips only a null value). A bool + /// whose getter answered true while unset was therefore always "set to true" by binding, + /// and the explicit-set rule never fired on a deployed portal — measured on a fresh control + /// instance running the first fix: the pass still selected 48 packages. A bool? that is + /// null is left alone by the binder, so null here really means "not configured". + /// + /// Code that builds the options by hand may set either: an explicit false on + /// still suppresses the baseline; to keep the baseline + /// beside an explicit set in code, set this property to true. + /// + [Microsoft.Extensions.Configuration.ConfigurationKeyName(nameof(InstallPreInstalledPackages))] + public bool? InstallPreInstalledPackagesConfigured { get; set; } /// - /// as CONFIGURED: null when nothing set it (no - /// configuration key, no assignment), else the value set. What the default install's baseline - /// decision reads — an explicit value wins, an unset one follows - /// . + /// The baseline decision's input: when the + /// configuration states it, else false when code set + /// to false, else null (unset — follow + /// ). /// - public bool? InstallPreInstalledPackagesConfigured => installPreInstalledPackages; + public bool? EffectiveInstallPreInstalledPackages => + InstallPreInstalledPackagesConfigured ?? (InstallPreInstalledPackages ? null : false); /// This installation's public base URL, recorded on the instance node (advisory). /// 🚨 Since #3650 it is also where a registry DELIVERS its module-published broadcast /// (): an installation that records none is reached by the safety diff --git a/test/Memex.Portal.Shared.Test/StoreBootstrapLandsModulesFirstTest.cs b/test/Memex.Portal.Shared.Test/StoreBootstrapLandsModulesFirstTest.cs index 16c50e1c6e..531500547a 100644 --- a/test/Memex.Portal.Shared.Test/StoreBootstrapLandsModulesFirstTest.cs +++ b/test/Memex.Portal.Shared.Test/StoreBootstrapLandsModulesFirstTest.cs @@ -19,6 +19,7 @@ using MeshWeaver.Mesh.Services; using MeshWeaver.Plugin.Packaging; using MeshWeaver.PluginCatalog; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Xunit; using PackagingManifest = MeshWeaver.Plugin.Packaging.PluginManifest; @@ -529,6 +530,46 @@ public void NoPatterns_OrAWholeSource_KeepsTheBaseline() InstanceAutoRegistrationService.BaselineApplies(false, Parse()).Should().BeFalse(); } + /// + /// 🚨 The decision as a DEPLOYED portal reaches it: options BOUND from configuration, the way + /// MemexConfiguration builds them. The first fix read a nullable backing field behind the + /// bool; ConfigurationBinder writes every property's current value back through its setter, + /// so the field was always "true" and a fresh control instance naming five packages still selected + /// 48. Unset must stay unset through binding. + /// + [Theory] + [InlineData(null, false)] + [InlineData("true", true)] + [InlineData("false", false)] + public void BoundFromConfiguration_AnExplicitSet_FollowsTheKeyOnly(string? key, bool expectedBaseline) + { + var entries = new Dictionary + { + ["PluginCatalog:InstallByDefault:0"] = "Plugins/EntityViews", + ["PluginCatalog:InstallByDefault:1"] = "Plugins/Chat", + }; + if (key is not null) + entries["PluginCatalog:InstallPreInstalledPackages"] = key; + var options = new Microsoft.Extensions.Configuration.ConfigurationBuilder() + .AddInMemoryCollection(entries).Build() + .GetSection(PluginCatalogOptions.SectionName).Get()!; + + InstanceAutoRegistrationService.BaselineApplies(options.EffectiveInstallPreInstalledPackages, options.InstallByDefault) + .Should().Be(expectedBaseline); + } + + [Fact] + public void BoundFromConfiguration_NoPatterns_KeepsTheBaseline() + { + var options = new Microsoft.Extensions.Configuration.ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["PluginCatalog:HomeUrl"] = "https://x.example" }) + .Build().GetSection(PluginCatalogOptions.SectionName).Get()!; + InstanceAutoRegistrationService.BaselineApplies(options.EffectiveInstallPreInstalledPackages, options.InstallByDefault) + .Should().BeTrue(); + new PluginCatalogOptions { InstallPreInstalledPackages = false }.EffectiveInstallPreInstalledPackages + .Should().Be(false, "code that sets the bool to false still suppresses"); + } + [Fact] public void AMalformedEntry_IsNotAnExplicitSet_SoTheBaselineStays() {