Skip to content
73 changes: 70 additions & 3 deletions .github/scripts/check-untyped-content.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,44 @@
# Prod content types are covered by ContentTypeRegistrationSweep (static definitions, swept at boot)
# and by WithContentType running on instance-hub activation (dynamic types). This gate covers what
# neither does: catching a NEW gap the moment a test run first exhibits it.
#
# 🚨 WHY THIS GATE COULD NOT FIRE FOR ITS FIRST FIVE DAYS (MeshWeaver#3625), AND WHAT KEYS IT NOW.
#
# The scan was correct and its input could not arrive. Both producers logged the degradation as a
# Warning with NO exception object, and the only sink that feeds `collected-logs/` takes a record
# IF AND ONLY IF `exception is not null && logLevel >= Warning`
# (XUnitFileLogger.Log / LoggingBuilderExtensions.Log -> TestTraceLog.AppendFault). So the phrase
# below could reach the scanned directory by construction NEVER. Measured on a run that really did
# degrade content: 817 trace records naming the test class, ZERO occurrences of the phrase it
# emitted. A gate whose input cannot arrive is indistinguishable from a gate that passes — the exact
# thing AGENTS.md forbids, committed inside a gate written to prevent it.
#
# Two things changed, and the second is the one that stops it recurring:
#
# 1. The degradation warnings now carry `MeshNodeContentDegradedException` — an exception object
# that is constructed and never thrown, whose whole job is to satisfy the sink's predicate.
# Reaching a sink is a property of the CALL, not of the level or the wording.
#
# 2. 🚨 The gate keys on that TYPE NAME first, and on the prose phrase only as a second net.
# A message is a DESCRIPTION of the event; the type is the event's IDENTITY, bound by the
# compiler at every construction site. That gives the coupling two independent bindings
# instead of one — a rename is a compile-wide change, AND UntypedContentDegradationGate pins
# this script's MARKER to nameof(MeshNodeContentDegradedException). The phrase is kept because
# it costs nothing and catches a third seam whose wording ("stays", not "stayed") the phrase
# grep never matched anyway.
#
# The reachability half — that a degradation record actually satisfies the sink predicate — is
# asserted by UntypedContentDegradationReachesTheTraceSinkTest, which drives the PRODUCTION emitter
# and evaluates the sink's own condition against the captured record. Modelled on
# EmitDeadAttributionReachesTheTraceSinkTest, which exists for the same trap one diagnostic over.
set -euo pipefail

# The event's IDENTITY. Primary key: compiler-bound, and pinned to this string by
# UntypedContentDegradationGate via nameof(...).
MARKER='MeshNodeContentDegradedException'
# The event's DESCRIPTION. Secondary net, kept deliberately: a per-test file log
# (MESHWEAVER_TEST_FILE_LOGS, on in node-repo-module-pack.yml) carries the formatted message with
# no exception attached at all.
PHRASE='stayed an untyped JsonElement'
DIR="${1:?usage: check-untyped-content.sh <collected-logs-dir>}"
scan_err="$(mktemp)"
Expand All @@ -52,8 +88,24 @@ fi
# ERRORED reported "no degradation" and passed the gate. That is precisely the silent pass this file
# exists to prevent, committed inside the file that prevents it. Caught by the repo's own
# `CI's own shell` gate, which flags a captured command substitution that swallows stderr and status.
# 🚨 EXCLUDE THE BINARY ARTEFACTS BY NAME — and deliberately NOT with grep's own `-I`.
#
# `collected-logs/` is not all text: the workflow copies native mini dumps (`/tmp/coredumps/*.dmp`)
# and, for a host that died on a SIGNAL, a `symbols-<project>/` directory of `.dll` + `.pdb`. A core
# dump is a snapshot of the process's memory, so it can contain the marker string simply because the
# process once formatted it — a match there is not a log record and would be a FALSE RED (and, in
# the Occurrences list, an unparseable `Binary file … matches` line that the sed path-extraction
# below cannot reduce).
#
# `-I` would also fix that, and it is the wrong instrument: it decides by CONTENT, so a text log
# that happens to contain one NUL byte becomes invisible to the scan — silently, with the gate
# reporting "no degradation". That is a pass-by-scanning-nothing, which is the one outcome this file
# exists to make impossible; a false red is loud and investigable, a false green is not. Excluding
# by NAME can only ever skip a file whose extension is listed right here, in the open.
EXCLUDES=(--exclude='*.dmp' --exclude='*.dll' --exclude='*.pdb')

set +e
matches=$(grep -rl "$PHRASE" "$DIR" 2>"$scan_err")
matches=$(grep -rl "${EXCLUDES[@]}" -e "$MARKER" -e "$PHRASE" "$DIR" 2>"$scan_err")
scan_rc=$?
Comment on lines 107 to 109

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted, with a different instrument — and applied to both greps in one shared EXCLUDES array so they cannot drift.

The concern is real: dotnet-test.yml copies /tmp/coredumps/*.dmp into collected-logs/, plus a symbols-<project>/ directory of .dll + .pdb whenever a host dies on a signal. A core dump is a snapshot of process memory, so it can contain the marker simply because the process once formatted it — a match there is not a log record.

I did not use -I, and the reason is the same rule this whole script exists to enforce. -I decides by CONTENT: a text log carrying one stray NUL byte becomes invisible to the scan, silently, and the gate then reports "No content-type degradation" — a pass-by-scanning-nothing, which is the one outcome this file must make impossible. A false red is loud and investigable; a false green is not. Excluding by NAME (--exclude='*.dmp' --exclude='*.dll' --exclude='*.pdb') can only ever skip an extension listed in the open, right there in the script, and the reasoning is written beside it.

set -e

Expand All @@ -76,8 +128,11 @@ echo "empty, a reactive wait never completes. It is caught here or not at all."
echo ""
echo "Occurrences:"
# Trim to the node path — the whole line carries a serialised payload and drowns the signal.
grep -rh "$PHRASE" "$DIR" 2>/dev/null \
| sed -E 's/.*Content for ([^ ]+) stayed.*/ \1/' \
# Both record shapes name the node: the log message says "Content for <path> stayed …", the
# exception says "content for '<path>' (nodeType …)". Reduce either to the path.
grep -rh "${EXCLUDES[@]}" -e "$MARKER" -e "$PHRASE" "$DIR" 2>/dev/null \
| sed -E -e "s/.*Content for ([^ ]+) stayed.*/ \1/" \
-e "s/.*content for '([^']*)'.*/ \1/" \
| sort | uniq -c | sort -rn | head -20
echo ""
echo "Files: $(printf '%s' "$matches" | tr '\n' ' ')"
Expand All @@ -87,4 +142,16 @@ echo "source, or WithType(typeof(T), nameof(T)). Do NOT paper over it with .Cont
echo "call site: AGENTS.md is explicit that deserialising close to where the type IS registered"
echo "comes first, and .As<T>() on a payload read where the type was never registered hides a"
echo "routing mistake rather than fixing it."
echo ""
echo "🚨 IF THIS IS YOUR TEST'S OWN FIXTURE — a node deliberately seeded with content the build"
echo "cannot read, to prove a write REFUSES it — then the fixture is what needs changing, not this"
echo "gate, and there is no allow-list on purpose: an exemption here would be a permanently green"
echo "check wearing a reason. Model 'present but unreadable as T' the way a running mesh actually"
echo "produces it: seed a value of a DIFFERENT, REGISTERED type. The stream cache then types it"
echo "happily (nothing degraded — so no record, and no hit here), while ContentAs<T>/As<T> still"
echo "answers null, because it recovers a foreign runtime type ONLY when the short name matches."
echo "That is the production case — a same-named record from another collectible assembly, or a"
echo "foreign type — so the fixture gets closer to the defect, not further from it. Malformed JSON"
echo "with no resolvable \$type models a DIFFERENT defect: content nothing can read, which is what"
echo "this gate exists to report."
exit 1
74 changes: 74 additions & 0 deletions src/MeshWeaver.Data.Contract/MeshNodeContentDegradedException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
namespace MeshWeaver.Mesh;

/// <summary>
/// 🚨 <b>The IDENTITY of a content degradation — an exception object attached to the diagnostic,
/// deliberately never thrown.</b>
///
/// <para><b>What it marks.</b> A <c>MeshNode</c>'s <c>Content</c> arrived as JSON, the
/// polymorphic reader could not resolve its <c>$type</c> against the reading hub's TypeRegistry,
/// and the mesh-wide <c>IMeshContentTypeRegistry</c> could not recover it either. The value crosses
/// the read boundary STILL untyped, so every downstream <c>Content is X</c> / <c>as X</c> silently
/// answers absent: a view renders empty, a reactive wait never completes. Nothing throws — a READ
/// must stay bad-data tolerant, and that is exactly why this class of defect is invisible.</para>
///
/// <para>🚨 <b>Why an exception for something that is not thrown.</b> Because carrying one is what
/// makes the diagnostic DURABLE, and durability here is a property of the CALL, not of the wording
/// or the level. A CI shard's authoritative sink — <c>_meshweaver-test-trace.log</c>, written by
/// <c>XUnitFileLogger.Log</c> → <c>TestTraceLog.AppendFault</c> — takes a record <b>if and only if</b>
/// <c>exception is not null &amp;&amp; logLevel &gt;= Warning</c>. The degradation warnings passed no
/// exception, so they could reach that file by construction never; and the job log carries only the
/// output of tests that FAILED, while a degradation is overwhelmingly logged under a test that
/// PASSES. The consequence was measured in MeshWeaver#3625: the shard gate
/// <c>check-untyped-content.sh</c>, which greps that very directory, had <b>no way to fire</b> — a
/// permanently green check, indistinguishable from a passing one.</para>
///
/// <para>This is the third recorded instance of the same trap (#890's <c>PROCESS CANNOT EMIT</c>,
/// #3413's <c>never rendered</c>, and this one) — see
/// <c>Doc/Architecture/ReadingCiSignals</c> → "for a fault the trace log is the sink".</para>
///
/// <para>🚨 <b>The gate keys on this TYPE NAME, not on the message.</b> A prose phrase is a
/// description of the event; the type is the event's identity, bound by the compiler at every
/// construction site. That gives the coupling two independent bindings instead of one: renaming the
/// type is a compile-wide change, and <c>UntypedContentDegradationGate</c> pins the script's key to
/// <c>nameof(MeshNodeContentDegradedException)</c> so a rename that forgets the script reds.</para>
///
/// <para>Constructing an exception without throwing it costs one allocation and captures no stack,
/// so <see cref="System.Exception.ToString"/> yields exactly <c>type: message</c> — which is the
/// whole record a reader needs here, since the fault is in the DATA and not in a call path.</para>
/// </summary>
public sealed class MeshNodeContentDegradedException : System.Exception
{
/// <summary>The read seam that observed the degradation (e.g. <c>MeshNodeStreamCache.GetStream</c>).</summary>
public string Seam { get; }

/// <summary>The node whose content stayed untyped.</summary>
public string? NodePath { get; }

/// <summary>The node's NodeType, which is the key the content-type registry recovery is attempted under.</summary>
public string? NodeType { get; }

/// <summary>Truncated raw JSON of the content that could not be materialised.</summary>
public string? RawJson { get; }

/// <summary>Creates the degradation marker.</summary>
/// <param name="seam">The read seam that observed it.</param>
/// <param name="nodePath">The node path.</param>
/// <param name="nodeType">The node's NodeType.</param>
/// <param name="rawJson">Truncated raw JSON of the unreadable content.</param>
/// <param name="inner">The deserialization exception, when there was one.</param>
public MeshNodeContentDegradedException(
string seam, string? nodePath, string? nodeType, string? rawJson, System.Exception? inner = null)
: base(
$"{seam}: content for '{nodePath ?? "<unknown>"}' (nodeType '{nodeType ?? "<none>"}') "
+ "stayed an untyped JsonElement — the $type discriminator resolved to no registered "
+ "CLR type on the reading hub and the mesh-wide content-type registry did not recover "
+ "it. Downstream 'Content is X' / 'as X' consumers read it as ABSENT: views render "
+ $"empty and reactive waits never complete. Raw: {rawJson ?? "<none>"}",
inner)
{
Seam = seam;
NodePath = nodePath;
NodeType = nodeType;
RawJson = rawJson;
}
}
Loading
Loading