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 @@ -400,6 +400,26 @@ comment already gave: a non-convertible type node is their normal input, and rep
input as a fault is how that line became the *fingerprint's own text* — which is why the incident
could never go quiet however often the underlying defect was fixed.

🚨 **That sweep missed one caller, and it was the one that runs longest** (#5264). Both refusals —
the probe's and `NonDeclarationOverlay` — arm `ArmOverlaySelfHeal` with a null gate, so the overlay
recycles itself once a real declaration lands at the path. That watcher subscribes to the
**occupant's** stream, so every emission it sees is the Store plugin root — and it read each one
through `ContentAs<NodeTypeDefinition>` **with** the logger. The result, measured on the control
instance on 2026-09-22: one `EnrichWithNodeType: path 'Feedback' is occupied …` line from the
refusal, then `As<NodeTypeDefinition> for Feedback could not recover value: JsonException` at
`Error`, once per occupant emission, for the watcher's whole life — the same fingerprint again,
arriving 45 s after the collision line. The watcher now applies the same one-sided
`IsProvablyNotADeclaration` before converting: a proven occupant is "not usable" without comment,
and a real declaration (or degraded content that might be one) still reaches `ContentAs` with the
logger, so a genuine conversion fault on a NodeType stays loud. Pinned by
`OverlaySelfHealWatcherTest.CollisionOverlay_OccupantEmissions_AreNotLoggedAsConversionFaults_AndARealDeclarationStillHeals`
(3 Error lines against the unfixed watcher, 0 with the fix; the positive half proves the watcher
still recycles once a declaration arrives).

**What this does NOT do: repair the instance.** An instance that names the bare `Feedback` still gets
the overlay — correctly — until its `nodeType` is rewritten to `Feedback/Feedback` through the repair
path below. The write-side refusal (above) stops new ones only on an image that carries it.

## The repair path both decisions had to leave open

`patch` refuses `nodeType` outright, so a **full-node `update` naming a type that does resolve** is
Expand Down
21 changes: 20 additions & 1 deletion src/MeshWeaver.Graph/Configuration/NodeTypeEnrichmentHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2561,7 +2561,26 @@ internal static IDisposable ArmOverlaySelfHeal(
// degraded-content warning logs.
(NodeTypeDefinition? Def, bool Usable) Evaluate(MeshNode? t)
{
var def = t?.ContentAs<NodeTypeDefinition>(jsonOptions, logger);
// 🚨 A PROVEN NON-DECLARATION is this watcher's NORMAL INPUT on the collision overlay
// (#5264/#2231): the instance names a path something else occupies — the Store plugin
// root at `Feedback` — so the watched stream is that occupant and every emission of it
// is "not a NodeTypeDefinition". Handing it to ContentAs WITH the logger reported that
// at Error as `As<NodeTypeDefinition> for Feedback could not recover value:
// JsonException`, once per emission for the watcher's life: the incident fingerprint
// #5008 removed from the probe and the slow path, re-emitted here, so the incident
// could never go quiet however the instance was repaired. The collision was already
// reported once, with both sides named, by whoever armed this watcher. The split is
// IsProvablyNotADeclaration's own first clause — a node that DECLARES a NodeType other
// than `NodeType` — so a real declaration (or one that leaves NodeType unset) still
// converts WITH the logger and a genuine conversion fault on a NodeType stays loud.
// Deciding on the declared type rather than calling the probe keeps this to ONE
// conversion per emission, the contract stated above: a convertible occupant (untyped
// JSON) still yields its definition exactly as before, and an unconvertible one is the
// probe's conviction, now reached without a second pass.
var declaresAnotherType = t is not null
&& !string.IsNullOrEmpty(t.NodeType)
&& !string.Equals(t.NodeType, MeshNode.NodeTypePath, StringComparison.OrdinalIgnoreCase);
var def = t?.ContentAs<NodeTypeDefinition>(jsonOptions, declaresAnotherType ? null : logger);
return (def, def is not null
&& NodeTypeCompilationHelpers.HasUsableBuild(t!, def, guards));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Linq;
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using System.Reactive.Subjects;
Expand Down Expand Up @@ -547,4 +548,69 @@ IDisposable Arm() => NodeTypeEnrichmentHelpers.ArmOverlaySelfHeal(
return recycles;
}
}

/// <summary>Stand-in for the Store plugin root's <c>PluginContent</c>: content typed as
/// something that is not a <see cref="NodeTypeDefinition"/>.</summary>
private sealed record PluginContentStandIn(string Name);

/// <summary>Records every log line with its level, so the test can say which ones were Error.</summary>
private sealed class RecordingLogger : Microsoft.Extensions.Logging.ILogger
{
private readonly System.Collections.Concurrent.ConcurrentQueue<(Microsoft.Extensions.Logging.LogLevel Level, string Message)> records = new();

public System.Collections.Generic.IReadOnlyCollection<(Microsoft.Extensions.Logging.LogLevel Level, string Message)> Records => records;

public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;

public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;

public void Log<TState>(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId,
TState state, Exception? exception, Func<TState, Exception?, string> formatter)
=> records.Enqueue((logLevel, formatter(state, exception)));
}

/// <summary>
/// 🚨 <b>The collision overlay's watcher must not re-emit the incident fingerprint</b>
/// (Systemorph/MeshWeaver#5264, #2231). An instance naming the bare path <c>Feedback</c> — which
/// the Store plugin root occupies — is refused on activation with ONE Error naming both sides,
/// and the refusal arms this watcher over the OCCUPANT's stream. Every emission of that stream is
/// "not a NodeTypeDefinition" by construction; passing it to <c>ContentAs</c> with the logger
/// reported <c>As&lt;NodeTypeDefinition&gt; for Feedback …</c> at Error once per emission, for the
/// watcher's whole life — the line the incident's log burst carries 45 s after the collision line.
///
/// <para>The positive half keeps the watcher honest: once a REAL declaration lands at the path
/// (the collision resolved), the watcher still recycles exactly once.</para>
/// </summary>
[Fact]
public void CollisionOverlay_OccupantEmissions_AreNotLoggedAsConversionFaults_AndARealDeclarationStillHeals()
{
var recycler = new Recycler();
var logger = new RecordingLogger();
var typeStream = new Subject<MeshNode>();
using var watcher = NodeTypeEnrichmentHelpers.ArmOverlaySelfHeal(
typeStream, InstancePath, new JsonSerializerOptions(), recycler.Recycle, reportStuck: () => { },
NodeTypePath, typeVersionAtOverlay: null, logger);

MeshNode Occupant(long version) => new("SelfHealType", "TestData")
{
NodeType = "Store/Plugin",
Version = version,
Content = new PluginContentStandIn("Feedback"),
};

typeStream.OnNext(Occupant(1));
typeStream.OnNext(Occupant(2));
typeStream.OnNext(Occupant(3));

logger.Records.Where(r => r.Level >= Microsoft.Extensions.Logging.LogLevel.Error)
.Select(r => r.Message).ToArray()
.Should().BeEmpty(
"the occupant is the watcher's normal input on a collision overlay — the collision was "
+ "reported once by the refusal, and a per-emission conversion Error is the fingerprint "
+ "that kept #2231/#5264 reopening");
AssertNoDispose(recycler);

typeStream.OnNext(TypeNode(version: 4, usable: true));
AssertDisposedExactlyOnce(recycler);
}
}
Loading