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
34 changes: 28 additions & 6 deletions deploy/helm/templates/memex-portal/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -349,8 +349,14 @@ data:
FrameworkBroadcast__Subscribers__2: "{{ .Values.config.memex_portal.FrameworkBroadcast__Subscribers__2 | default "" }}"
FrameworkBroadcast__Subscribers__3: "{{ .Values.config.memex_portal.FrameworkBroadcast__Subscribers__3 | default "" }}"
# The MODULES a deployment refuses to run without (MeshBuilderModuleActivation.RequiredKey).
# The image ships a baseline list in appsettings; a deployment OVERRIDES an entry by name here,
# and blanks one it cannot satisfy.
# The image ships a baseline list in appsettings; a deployment OVERRIDES an entry here and
# blanks one it cannot satisfy.
#
# 🚨 It overrides BY INDEX, and it never APPENDS. Configuration merges an array element per
# key, so Modules__Required__5 REPLACES whatever the image baseline holds at index 5 — it does
# not add a sixth requirement. The baseline is 7 entries today (0..6: Radzen, Analysis,
# EntityViews, GoogleMaps, Speech, Social, AI), so an overlay ADDING a module must start at the
# first index PAST it, and an overlay that restates the baseline must restate all of it.
#
# 🚨 Un-templated, an override is silently dropped and the health check wins anyway — the #1925
# defect, third occurrence. Not cosmetic here: `required_modules` reports Unhealthy, /health
Expand All @@ -364,6 +370,20 @@ data:
# by looking for the key. A range renders correctly and is invisible to the guard — which is how
# the first draft of this block passed helm and failed the test, correctly.
#
# 🚨 THE CEILING IS 9 — the block renders 0..9 and NOTHING ABOVE. A ceiling is not an oversight
# here, it is the price of the literal-key contract above: a bounded list is what the guard can
# check, and an unbounded `range` is what it cannot. So the ceiling has to be stated where it is
# raised, because twice now an overlay declared the first index past it and the key reached no
# container at all — Memex#128/#131 (MeshWeaver.Mcp, /mcp answering 404 with Mcp__BaseUrl
# configured beside it) and Memex#129. Why 9 and not 7: the baseline consumes 0..6, so a ceiling
# of 7 leaves exactly ONE appendable slot; ten slots leave three. RAISING IT is one hasKey block
# per index and nothing else — do that rather than reusing a baseline index.
#
# Two guards in test/MeshWeaver.Documentation.Test/PlatformBakeLaneGuard.cs hold this shape:
# ModulesRequired_IsRenderedAsAContiguousRangeFromZero (a gap silently truncates nothing and
# drops exactly the missing index) and NoValuesFile_DeclaresAModulesRequiredIndexAboveTheCeiling
# (whose failure message names the ceiling, so the next person is told the number).
#
# Each index is emitted only when a deployment sets it, so an install that overrides nothing
# keeps the image's own list rather than blanking entry 0.
{{- if hasKey .Values.config.memex_portal "Modules__Required__0" }}
Expand All @@ -381,10 +401,6 @@ data:
{{- if hasKey .Values.config.memex_portal "Modules__Required__4" }}
Modules__Required__4: "{{ .Values.config.memex_portal.Modules__Required__4 }}"
{{- end }}
{{- /* Indices 5-7: headroom so the NEXT required module does not silently vanish. The list
stopped at 4 while the overlays grew to 5 (MeshWeaver.AI, the engine guard after the
2026-08-27 outage) — the config-key coverage gate caught the overlay key reaching NO
container, the 5th recurrence of the named-key omission (#1925/#1778/#1780/#2203). */}}
{{- if hasKey .Values.config.memex_portal "Modules__Required__5" }}
Modules__Required__5: "{{ .Values.config.memex_portal.Modules__Required__5 }}"
{{- end }}
Expand All @@ -394,6 +410,12 @@ data:
{{- if hasKey .Values.config.memex_portal "Modules__Required__7" }}
Modules__Required__7: "{{ .Values.config.memex_portal.Modules__Required__7 }}"
{{- end }}
{{- if hasKey .Values.config.memex_portal "Modules__Required__8" }}
Modules__Required__8: "{{ .Values.config.memex_portal.Modules__Required__8 }}"
{{- end }}
{{- if hasKey .Values.config.memex_portal "Modules__Required__9" }}
Modules__Required__9: "{{ .Values.config.memex_portal.Modules__Required__9 }}"
{{- end }}
# Convergence policy (MeshWeaver#2192): when a NodeType publishes a usable build, instances
# self-recycle onto it instead of waiting behind the "Recycle" banner. Off = the image default
# (banner). A self-updating portal that leaves this off chooses unbounded in-memory assembly
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
Name: Deployments can require more modules
Category: Fix
Description: A deployment can now declare up to ten required modules; an index past the ceiling fails the build instead of silently reaching no container.
Icon: Sparkle
Order: -20260827
---

# Deployments can require more modules

A deployment states the modules it refuses to run without, and the chart used to carry only five
of those slots. A sixth entry was accepted everywhere it was written and then reached no container
at all — which is how one portal's MCP endpoint kept answering 404 while its configuration said it
was switched on. There is room for ten now, and an entry past that limit fails the build with the
limit named, rather than disappearing quietly.

The same list had a quieter way to go wrong: because entries are numbered, a new one written over
an existing number replaced that module's requirement instead of adding to it, and nothing said so.
A portal now reports at startup exactly which requirement was replaced and what to do about it.
79 changes: 79 additions & 0 deletions src/MeshWeaver.Mesh.Contract/MeshBuilderModuleActivation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ public static MeshBuilder InstallConfiguredModules(
+ "registry has not landed yet is reported degraded and named (it is not something "
+ "a held rollout could deliver).");

// The SILENT half of the same key: a requirement that did not go missing but was
// OVERWRITTEN. Nothing above can see it — the entry is not absent, it was never asked for.
foreach (var shadowed in ShadowedRequired(configuration))
report($"REQUIRED module '{shadowed}' was silently REPLACED at its index and is no "
+ $"longer required by this deployment. {RequiredKey} is an ARRAY and an override "
+ "binds BY INDEX: setting one entry does not append to the list, it replaces "
+ $"whatever the image declared at that position. Nothing else reports this — the "
+ "module is simply not required any more, so it is never missing, the deploy "
+ "succeeds and the health check stays green with the guard gone. Move the new "
+ "entry to the first index PAST the image's own list, or restate the entry you "
+ "replaced at a free index.");

// Hand the configuration to the builder BEFORE anything is installed: an attribute's
// BuilderConfigurations runs inside InstallAssemblies, so a module asking "what did this
// deployment configure?" must find the answer already there. We hold it either way — not
Expand All @@ -103,6 +115,73 @@ public static string[] MissingRequired(
.Select(entry => entry!)];
}

/// <summary>
/// The required modules an override SILENTLY REPLACED — declared by one configuration source
/// and overwritten at the same index by a later one, with the replaced module named nowhere
/// else in the effective list.
///
/// <para>🚨 <b>Why this is not the same question as <see cref="MissingRequired"/>, and why
/// nothing else can ask it.</b> <c>Modules:Required</c> is an ARRAY, so an override binds BY
/// INDEX: <c>Modules__Required__5</c> replaces whatever the image's own list holds at index 5 —
/// it does not append a sixth requirement. The failure that follows is invisible from every
/// direction. The replaced module is not MISSING (nobody asked for it any more), so
/// <see cref="MissingRequired"/> and the readiness contract both have nothing to say; the
/// override is not un-rendered, so the chart's coverage gate passes; the deploy succeeds and
/// <c>/health</c> stays green having quietly stopped guarding a module. Measured on Memex#131:
/// an overlay added <c>MeshWeaver.Mcp.dll</c> at index 5 to require the MCP endpoint, and index
/// 5 of the image's list is <c>MeshWeaver.Social.dll</c>.</para>
///
/// <para>The two halves are only ever visible TOGETHER in the running process — the image's
/// baseline ships in one repository and the overlay lives in another — which is why this is a
/// boot-time check and not a chart guard. Provider ORDER is the whole mechanism: a value a
/// provider supplies that is not the effective one has been shadowed by a later provider, so no
/// provider-type sniffing is needed and any layering (files, env, command line) is covered.</para>
///
/// <para>Two shapes are deliberately NOT reported, because both are how the key is meant to be
/// used: <b>blanking</b> an entry the deployment cannot satisfy (the effective value is empty —
/// an explicit "not required here", and the whole remedy the 2026-08-23 rollouts had), and
/// <b>reordering</b>, where the replaced module still appears at some other index and no
/// requirement was lost.</para>
/// </summary>
/// <param name="configuration">The host configuration. A non-root <see cref="IConfiguration"/>
/// carries no provider list and yields an empty result — the check simply does not apply.</param>
public static string[] ShadowedRequired(IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
if (configuration is not IConfigurationRoot root)
return [];

var effective = root.GetSection(RequiredKey).GetChildren()
.ToDictionary(child => child.Key, child => child.Value, StringComparer.Ordinal);
var stillRequired = effective.Values
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value!)
.ToHashSet(StringComparer.OrdinalIgnoreCase);

var shadowed = new List<string>();
foreach (var (index, winner) in effective)
{
// A BLANK winner is an explicit "this deployment cannot satisfy it" — the sanctioned
// way to drop a requirement, and never a mistake to report.
if (string.IsNullOrWhiteSpace(winner))
continue;

foreach (var provider in root.Providers)
{
if (!provider.TryGet($"{RequiredKey}:{index}", out var supplied)
|| string.IsNullOrWhiteSpace(supplied)
|| string.Equals(supplied, winner, StringComparison.OrdinalIgnoreCase)
// Reordered, not lost: the module is still required, at another index.
|| stillRequired.Contains(supplied))
continue;

shadowed.Add(supplied);
}
}

return [.. shadowed.Distinct(StringComparer.OrdinalIgnoreCase)];
}

/// <summary>
/// The pure half: entries in, installable paths out, one <paramref name="onSkip"/> line per
/// entry that is not there. Separated from the builder so the skip-don't-crash rule is testable
Expand Down
Loading
Loading