Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ after the fetch, over each `manifest.lock` in the incoming tree:
| Order | Condition | Outcome | Written |
|---|---|---|---|
| 1 | the module's root `index.json` declares `content.minMeshVersion` **above** the running platform (`PlatformFloor.Evaluate` — the ONE floor decision every package consumer uses, policy `package-min-mesh-version`; unknown, unreadable or unorderable on either side, or a local `-ci.0` build, is accepted) | **Declined** — the reason names both versions | nothing for that module; its siblings sync |
| 1b | the module's root `index.json` declares a `content.requires` entry (`AI@^1.21.0`) that the dependency's **loaded** module does not satisfy (`ModuleSyncDecision.DeclineUnmetRequirements`, against `ILoadedPackageModules`; an unknown loaded version, an uninstalled dependency or an unreadable range is not judged) — #6067 | **Declined** — the reason names the requirement, the loaded module and its version; `UnmetRequirement` on the outcome | nothing for that module; its siblings sync |
| 1b | the module's root `index.json` declares a `content.requires` entry (`AI@^1.21.0`) that the dependency's **loaded** module does not satisfy (`ModuleSyncDecision.DeclineUnmetRequirements`, against `ILoadedPackageModules`; an unknown loaded version, an uninstalled dependency or an unreadable range is not judged; the image's OWN copy is judged at the version its `module.seed.json` stamp states — see below) — #6067 | **Declined** — the reason names the requirement, the loaded module and its version; `UnmetRequirement` on the outcome | nothing for that module; its siblings sync |
| 2 | incoming `moduleVersion` **equals** the one the Space recorded when that module last landed, and the import is not a reconcile or a force | **Unchanged** | nothing |
| 3 | anything else: changed, never recorded, or a manifest that states no hash | **Synced** | the module, at the incoming commit |

Expand All @@ -68,6 +68,18 @@ after the fetch, over each `manifest.lock` in the incoming tree:
`Declined` when a module was declined, and the fetched commit counts as seen.
- A tree with **no** `manifest.lock` (a course or content repository) states no module and imports
exactly as before.
- **Rule 1b judges the image's own copy too** (Systemorph/MeshWeaver.Plugins#2715). The loaded
reading (`LoadedPackageModuleReader.ResolveWithImageCopies`) states a landed generation at the
version its activation entry recorded, and a module that loaded from the IMAGE's copy at the
version that copy's `module.seed.json` states (`ImageModuleSeed`), keyed by the package the stamp
names. It used to leave the image copy out as "not judged", and that was the commonest copy there
is. Measured on the control instance on 2026-10-05: its image (Plugins `29bfaefb`) ships AI
stamped `1.21.1`; Hosting's tree declared `AI@^1.22.0` and called `LanePolicy`, which only AI 1.22
carries. With no reading for AI the import wrote Hosting's sources, and twenty Hosting NodeTypes,
`InstanceAction` among them, parked at `CS0246 LanePolicy`. A seed stamp can only UNDERSTATE the
image's bytes (an unsettled `manifest.lock` keeps its number while the sources move on), so this
can hold an import the image would have satisfied, but it never admits one the image cannot
satisfy. An image copy with no stamp, a substituted load and an ambiguous load stay unjudged.

**Recorded hashes** live on the sync config as `ModuleVersions` (module → `moduleVersion`). They
advance only when the import's nodes **all** landed (`MayAdvanceBaseline`), the
Expand Down
96 changes: 91 additions & 5 deletions src/MeshWeaver.PluginCatalog/LoadedPackageModuleReader.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Collections.Immutable;
using System.Reactive.Linq;
using MeshWeaver.GitSync;
using MeshWeaver.Mesh;

namespace MeshWeaver.PluginCatalog;

Expand All @@ -14,29 +15,70 @@ namespace MeshWeaver.PluginCatalog;
/// not there — the very shape this exists to stop. So the reading pairs each landed entry with the
/// generation leaf the loaded assembly actually came from
/// (<see cref="ModuleActivationStatus.LoadedModuleGenerations()"/>): the head's version when the
/// head loaded, the retained previous generation's when that one did, and NOTHING otherwise (an
/// image copy, a substituted load, an unloaded module) — absent means "not judged", never "met".</para>
/// head loaded, the retained previous generation's when that one did.</para>
///
/// <para>🚨 <b>The IMAGE's own copy is judged too, at the version its seed stamp states</b>
/// (<see cref="ImageModuleSeed"/>, Systemorph/MeshWeaver.Plugins#2715). It used to be left out —
/// "absent means not judged" — and that made the image copy the one case the floor could never
/// hold, although it is the commonest copy there is. Measured on the control instance on
/// 2026-10-05: the image (Plugins <c>29bfaefb</c>) ships AI stamped <c>1.21.1</c>; Hosting's tree
/// declared <c>AI@^1.22.0</c> and called <c>LanePolicy</c>, which only AI 1.22 carries; the reading
/// had no entry for AI, the import wrote Hosting's sources, and twenty Hosting NodeTypes —
/// <c>InstanceAction</c> among them — parked at <c>CS0246 LanePolicy</c>. The seed's version can
/// only UNDERSTATE the image's bytes (an unsettled <c>manifest.lock</c> keeps the number while the
/// sources move, never the reverse — see <see cref="ImageModuleSeed.DeclineReason"/>), so judging
/// against it can hold an import the image would have satisfied, never admit one it cannot. That
/// is the direction a gate errs in.</para>
///
/// <para>Still NOTHING for a substituted load, an ambiguous one, an unloaded module, or an image
/// copy without a stamp: absent means "not judged", never "met".</para>
/// </summary>
/// <param name="landing">The landing service whose activation list names each generation's version.</param>
public sealed class LoadedPackageModuleReader(ModuleLandingService landing) : ILoadedPackageModules
{
/// <inheritdoc />
public IObservable<ImmutableDictionary<string, LoadedPackageModule>> Read() =>
landing.GetActivation().Take(1)
.Select(activation => Resolve(activation, ModuleActivationStatus.LoadedModuleGenerations()));
.Select(activation =>
{
var loaded = ModuleActivationStatus.LoadedModuleGenerations();
return ResolveWithImageCopies(activation, loaded, ImageCopiesOf(loaded));
});

/// <summary>
/// The pure half: each enabled, package-stamped entry whose module loaded from its head or its
/// retained previous generation, keyed by package id (the <c>&lt;source&gt;/&lt;package&gt;</c>
/// path's second segment, case-insensitive).
/// path's second segment, case-insensitive). Image copies are not judged here — see
/// <see cref="ResolveWithImageCopies"/>.
/// </summary>
/// <param name="activation">The deployment's activation list.</param>
/// <param name="loadedGenerations">Module simple name → the generation leaf it loaded from.</param>
public static ImmutableDictionary<string, LoadedPackageModule> Resolve(
ModuleActivationList activation, IReadOnlyDictionary<string, string> loadedGenerations)
ModuleActivationList activation, IReadOnlyDictionary<string, string> loadedGenerations) =>
ResolveWithImageCopies(activation, loadedGenerations, ImmutableDictionary<string, ImageModuleCopy>.Empty);

/// <summary>
/// <see cref="Resolve"/>, plus every module that loaded from the IMAGE's own copy, read at the
/// version that copy's <see cref="ImageModuleSeed"/> states and keyed by the package the seed
/// names. A landed generation that loaded wins over the image reading for the same package (it
/// is what runs); an image copy without a package or an ordered version is left out.
///
/// <para>A distinct name rather than an overload of <see cref="Resolve"/>: dependent
/// repositories cite <c>&lt;see cref="LoadedPackageModuleReader.Resolve"/&gt;</c>, and an added
/// overload would turn each of those into CS0419 under <c>-warnaserror</c>.</para>
/// </summary>
/// <param name="activation">The deployment's activation list.</param>
/// <param name="loadedGenerations">Module simple name → the generation leaf it loaded from.</param>
/// <param name="imageCopies">Module simple name → where the image ships that module and what its
/// seed stamp states (<see cref="ImageCopiesOf"/> in production).</param>
public static ImmutableDictionary<string, LoadedPackageModule> ResolveWithImageCopies(
ModuleActivationList activation,
IReadOnlyDictionary<string, string> loadedGenerations,
IReadOnlyDictionary<string, ImageModuleCopy> imageCopies)
{
ArgumentNullException.ThrowIfNull(activation);
ArgumentNullException.ThrowIfNull(loadedGenerations);
ArgumentNullException.ThrowIfNull(imageCopies);
var builder = ImmutableDictionary.CreateBuilder<string, LoadedPackageModule>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in activation.Entries)
{
Expand All @@ -53,6 +95,50 @@ public static ImmutableDictionary<string, LoadedPackageModule> Resolve(
if (!string.IsNullOrWhiteSpace(version))
builder[segments[1]] = new LoadedPackageModule(entry.Name, version);
}

foreach (var (module, copy) in imageCopies)
{
if (!loadedGenerations.TryGetValue(module, out var leaf)
|| !string.Equals(leaf, copy.Leaf, StringComparison.Ordinal)
|| copy.Seed.Package is not { Length: > 0 } package
|| copy.Seed.Version is not { Length: > 0 } version
|| builder.ContainsKey(package))
continue;
builder[package] = new LoadedPackageModule(module, version);
}
return builder.ToImmutable();
}

/// <summary>
/// The image's own copy of every loaded module that has one with a seed stamp: where it sits
/// (<see cref="MeshBuilder.ResolveModulePath(string, string?)"/> with no landed root — the
/// image's <c>modules/&lt;Name&gt;/</c> or the app closure) and what the stamp says. A module
/// whose image copy carries no stamp is left out, so it stays unjudged.
/// </summary>
/// <param name="loadedGenerations">Module simple name → the generation leaf it loaded from.</param>
public static ImmutableDictionary<string, ImageModuleCopy> ImageCopiesOf(
IReadOnlyDictionary<string, string> loadedGenerations)
{
ArgumentNullException.ThrowIfNull(loadedGenerations);
var builder = ImmutableDictionary.CreateBuilder<string, ImageModuleCopy>(StringComparer.OrdinalIgnoreCase);
foreach (var module in loadedGenerations.Keys)
{
var directory = Path.GetDirectoryName(MeshBuilder.ResolveModulePath(module + ".dll", null));
if (string.IsNullOrEmpty(directory)
|| ImageModuleSeed.Read(directory, module) is not { } seed)
continue;
builder[module] = new ImageModuleCopy(Path.GetFileName(Path.TrimEndingDirectorySeparator(directory)), seed);
}
return builder.ToImmutable();
}
}

/// <summary>
/// Where the image ships one module and what its seed stamp states — the input
/// <see cref="LoadedPackageModuleReader.ResolveWithImageCopies"/> judges an image-copy load by.
/// </summary>
/// <param name="Leaf">The leaf of the directory holding the image's copy — compared ordinally with
/// the leaf <see cref="ModuleActivationStatus.LoadedModuleGenerations()"/> records for the loaded
/// assembly.</param>
/// <param name="Seed">The image copy's <see cref="ImageModuleSeed"/>.</param>
public sealed record ImageModuleCopy(string Leaf, ImageModuleSeed Seed);
121 changes: 121 additions & 0 deletions test/Memex.Portal.Shared.Test/LoadedPackageModuleReaderTest.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using MeshWeaver.GitSync;
using MeshWeaver.PluginCatalog;
using Xunit;

Expand Down Expand Up @@ -53,4 +58,120 @@ public void AnUnloadedOrUninstalledModule_IsNotJudged()
new Dictionary<string, string> { ["MeshWeaver.AI"] = "MeshWeaver.AI@1210" })
.Should().BeEmpty();
}

// ───────────────────────── the IMAGE's copy is judged at its seed version (Plugins#2715)

/// <summary>The image ships AI as <c>modules/MeshWeaver.AI/</c>, stamped as package AI 1.21.1 —
/// the control instance's image (Plugins <c>29bfaefb</c>) on 2026-10-05.</summary>
private static readonly IReadOnlyDictionary<string, ImageModuleCopy> ImageAi1211 =
new Dictionary<string, ImageModuleCopy>
{
["MeshWeaver.AI"] = new("MeshWeaver.AI", new ImageModuleSeed("MeshWeaver.AI", "AI", "1.21.1", "1af92ebc617c7648")),
};

/// <summary>🚨 The image copy loaded and its stamp names the package: the reading states it, at
/// the stamped version — with or without a store entry for the same module.</summary>
[Fact]
public void TheImageCopyLoaded_ReadsTheSeedsVersion()
{
var loaded = new Dictionary<string, string> { ["MeshWeaver.AI"] = "MeshWeaver.AI" };
LoadedPackageModuleReader.ResolveWithImageCopies(List(Ai()), loaded, ImageAi1211)["AI"]
.Should().Be(new LoadedPackageModule("MeshWeaver.AI", "1.21.1"),
"the store head (1.21.0) did not load — the image's copy did, and its seed says what it is");
LoadedPackageModuleReader.ResolveWithImageCopies(List(), loaded, ImageAi1211)["AI"].Version
.Should().Be("1.21.1", "an image-only module, with no landing at all, is judged too");
}

/// <summary>A landed generation that LOADED is what runs, so it wins over the image reading.</summary>
[Fact]
public void ALandedGenerationThatLoaded_WinsOverTheImageReading()
=> LoadedPackageModuleReader.ResolveWithImageCopies(List(Ai()),
new Dictionary<string, string> { ["MeshWeaver.AI"] = "MeshWeaver.AI@1210" }, ImageAi1211)["AI"]
.Version.Should().Be("1.21.0");

/// <summary>No stamp, a stamp naming no package, or a load from somewhere other than the image's
/// copy: still not judged.</summary>
[Fact]
public void AnImageCopyWithoutAStampOrAnotherLeaf_IsNotJudged()
{
var fromImage = new Dictionary<string, string> { ["MeshWeaver.AI"] = "MeshWeaver.AI" };
LoadedPackageModuleReader.ResolveWithImageCopies(List(Ai()), fromImage,
new Dictionary<string, ImageModuleCopy>()).Should().BeEmpty();
LoadedPackageModuleReader.ResolveWithImageCopies(List(Ai()), fromImage,
new Dictionary<string, ImageModuleCopy>
{
["MeshWeaver.AI"] = new("MeshWeaver.AI", new ImageModuleSeed("MeshWeaver.AI", null, "1.21.1", null)),
}).Should().BeEmpty();
LoadedPackageModuleReader.ResolveWithImageCopies(List(Ai()),
new Dictionary<string, string> { ["MeshWeaver.AI"] = "MeshWeaver.AI@9999" }, ImageAi1211)
.Should().BeEmpty("a generation neither a landing nor the image names is a substituted load");
}

/// <summary>
/// 🚨 <b>THE incident, end to end through the real decision.</b> Hosting's tree declares
/// <c>AI@^1.22.0</c>; the process runs the image's AI, stamped 1.21.1. The import must be
/// DECLINED.
/// <para><b>Negative control (in the test):</b> the reading the code had before this change
/// (<see cref="LoadedPackageModuleReader.Resolve"/>, image copies not judged) lets the same tree
/// SYNC — which is what wrote Hosting's sources on the control instance and parked twenty
/// NodeTypes at <c>CS0246 LanePolicy</c>.</para>
/// </summary>
[Fact]
public void ARequirementTheImagesCopyDoesNotMeet_DeclinesTheImport()
{
var loaded = new Dictionary<string, string> { ["MeshWeaver.AI"] = "MeshWeaver.AI" };
var hosting = new ModuleReading("Hosting", "Hosting", "07738e60f774423b", null)
{
Requires = ["Store@^1.0.0", "AI@^1.22.0", "Essentials@^1.11.0"],
};
ImmutableList<ModuleSyncOutcome> Judge(IReadOnlyDictionary<string, LoadedPackageModule> reading) =>
ModuleSyncDecision.DeclineUnmetRequirements(
ModuleSyncDecision.Decide([hosting],
new Dictionary<string, string> { ["Hosting"] = "0d6823302ed089d4" },
"3.0.0-ci.9984", reconcile: false),
[hosting], reading);

var declined = Judge(LoadedPackageModuleReader.ResolveWithImageCopies(List(Ai()), loaded, ImageAi1211)).Single();
declined.Outcome.Should().Be(ModuleSyncOutcomeKind.Declined);
declined.UnmetRequirement.Should().Be("AI@^1.22.0");
declined.Reason.Should().Contain("1.21.1");

Judge(LoadedPackageModuleReader.Resolve(List(Ai()), loaded)).Single().Outcome
.Should().Be(ModuleSyncOutcomeKind.Synced,
"negative control: without the image reading nothing judges the image copy, and the sources move");
}

/// <summary>
/// The production reader of the image's copies, against a real stamp on disk in the image's own
/// <c>modules/&lt;Name&gt;/</c> (this test host's base directory plays the image).
/// </summary>
[Fact]
public void ImageCopiesOf_ReadsTheStampBesideTheImagesCopy()
{
var name = "MeshWeaver.Test.SeededModule" + Guid.NewGuid().ToString("N")[..8];
var directory = Path.Combine(AppContext.BaseDirectory, "modules", name);
Directory.CreateDirectory(directory);
try
{
File.WriteAllBytes(Path.Combine(directory, name + ".dll"), []);
File.WriteAllText(Path.Combine(directory, ImageModuleSeed.FileName),
$$"""{"schema":"{{ImageModuleSeed.Schema}}","module":"{{name}}","package":"Seeded","version":"2.3.4","moduleVersion":"abcdef0123456789"}""");

var copies = LoadedPackageModuleReader.ImageCopiesOf(new Dictionary<string, string>
{
[name] = name,
["MeshWeaver.Test.NoSuchModule"] = "MeshWeaver.Test.NoSuchModule",
});

copies.Keys.Should().Equal([name]);
var copy = copies[name];
copy.Leaf.Should().Be(name);
copy.Seed.Package.Should().Be("Seeded");
copy.Seed.Version.Should().Be("2.3.4");
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
}