Arming the in-mesh warning ratchets in this repo

The maintainer's report, 2026-09-16:

baking, as executed in the plugins pipeline, is very verbose and does NOT surface warnings as errors ⇒ we have lots of missing xml comments

Core built the mechanism in MeshWeaver#4473 — read The In-Mesh Warning Standard for what the two ratchets ARE, why the runtime compile must stay lenient, and which diagnostic codes are the platform's to fix rather than a repo's to record. This page is the other half: what it took to arm them here, and what they measured.

Why nothing was enforced until this change

The tester's default is observe-only and exits 0. A bake with no --warning-baseline measures every warning, prints the folded inventory, and says by name that it enforced nothing — deliberately, so that landing the mechanism could not red the whole fleet in one commit. A satellite stays in that state until it names a baseline file on the shared lanes — this repo's adoption is that step. (Measured 2026-09-16, org-wide code search for warning-baseline-file: three hits, all in core — the two lanes and core's own page. No satellite had adopted.)

That is the shape the report describes exactly: verbose (an inventory), and not an error (exit 0).

This repo bakes its content THREE times. Two of them can be armed.

Measured on .github/workflows/ci.yml and on the lanes themselves, not assumed — warning-baseline-file: is declared by exactly two platform lanes, and this repo's seven lane calls include both:

Bake Where Verb Armed
the PR gate's compile node-repo-gate.yml (ci.yml → test-repos) mw-plugin-test compile yeswarning-baseline-file: plugin-gate-warnings.allow
the merge's publication bake node-repo-publish-bake.yml (ci.yml → publish-bake) mw-plugin-test compile yes — the SAME file
the in-mesh lane scripts/mesh-test.py --no-tests (ci.yml → compile-check-lanes) mw-plugin-test build no — see below

Both shared lanes take the same file, and that is not tidiness. A baseline on one lane only holds a pull request and its own publication to different standards: the PR would be judged and the publication would not, or the reverse — and the reverse is how a green PR produces a red main.

The third bake is the one this repo owns. mw-plugin-test build accepts --warning-baseline too, but mesh-test.py does not pass it, and its own no-warn policy cannot see the new report. Measured against that script's own WARN_RE/ERROR_RE: every shape the report emits — the inventory, the per-code table, both verdict lines, a site line and a NEW line — matches NEITHER, while the classic Foo.cs(3,5): warning CS1591: … shape they were written for matches. So its long-standing caveat —

⚠ zero warnings is NOT evidence of a clean build

— is still true of that lane, and is now false of the other two.

🚨 And it could not share this file even if it were plumbed. Measured on the same run, same 98 of 98 types, the two verbs disagree — not over the modules (both composed 4) but over the shape of the reference set itself. compile builds it from the PORTAL HOST's /app (218 assemblies) plus its shared frameworks (313); build runs in-process and uses its OWN application directory's TRUSTED_PLATFORM_ASSEMBLIES. Different references, different diagnostics:

compile (the two armed lanes) build (the in-mesh lane)
raw occurrences 3,248 4,686
distinct sites 1,523 1,572
warnings pairs to baseline 236 250
CS0436 (type conflicts with an imported type) 1,446× over 45 sites / 14 types

CS0436 appears only in the build lane, and it is an artefact of THAT lane's composition, not of the content. A baseline harvested from one verb is therefore STALE and NEW at once against the other, which is exactly why this change arms the two lanes that bake identically and leaves the third alone. Nothing is unguarded by that: the in-mesh lane compiles the same content the two armed bakes do, so it is redundant coverage rather than a hole. Giving it its own baseline is a separate piece of work with its own measurement.

🚨 The baseline was MEASURED, and an empty baseline is the instrument

Core's adoption path is "run one bake with MW_LOG_LEVEL=Information and paste the warnings: baseline … lines". That path is not available from inside the shared lanes: the lane starts the tester itself — in container mode with a fixed env (-e HOME=/tmp), so MW_LOG_LEVEL never reaches it — and a caller cannot set env: on a reusable workflow's job in either mode. Hand-writing the file, or copying core's, would have recorded debt nobody measured, under this repo's NodeType names.

So the baseline was harvested from an armed bake with an EMPTY baseline file, in two pushes on the same pull request:

  1. Push 1 — both lanes name plugin-gate-warnings.allow; the file carries its header and no entries. An empty baseline tolerates nothing, so every observed pair is NEW and the bake goes RED, printing for each one the exact line to record: warnings: <ratchet> NEW <type> <code> — not in the baseline. Fix it, or record the debt by adding the line '<type> <code>'.
  2. Push 2 — those lines, sorted and de-duplicated, ARE the file. The same bake then reports ENFORCED … 0 NEW, 0 stale and the gate is green.

That red is not a mishap on the way to the change; it is the positive control. It proves the ratchets are armed and CAN fail before anything is recorded — which is the one thing a green run on a green tree can never prove, and the reason this repo does not accept a gate whose evidence is "the command returned".

…and it cannot be silently switched off again

The input's default is EMPTY, empty is observe-only, and observe-only exits 0. So a future edit that deletes warning-baseline-file: from one lane — or from both — fails nothing and changes nothing a reader would notice: the wall stays green and the repo is back where the report found it. Core guards its own two trees with a postcondition inside bake-then-gate.sh (it asserts both ENFORCED lines on a green bake); a satellite that calls the shared lanes has no equivalent, so this repo grew its own.

scripts/check-shared-lanes.py — the checker that already reads every platform lane call, with the calling job's block travelling with the call so a call answers for ITSELF rather than for a neighbour — gained a fifth arm:

It runs in Repo policy gates beside its own --self-test, which drives the arm from both directions — including the case that matters, a lane call with the input deleted.

What the two ratchets measured

One bake of this repo's whole content. First measured observe-only on platform set 3.0.0-ci.8721 (core ea35bbe1a) and then reproduced byte for byte by the armed run that produced the baseline, on 3.0.0-ci.8736 (core f0ea2ceb3) — same 3,248 occurrences, same 1,523 sites, same 15 codes. The denominator first, because a count without one is not a measurement:

NodeTypes discovered, and compiled 98 of 98 — none failed, so no baseline entry is unverifiable
NodeTypes producing at least one warning 98 of 98 — there is no warning-free type in this repo
bundles 37, from 1,237 node files over 71 packages
reference set the portal host's /app (218 assemblies) + its shared frameworks (313) + the gate's 4 composed module bundles
raw warning occurrences 3,248
folded to distinct sites 1,523 (a site is one (id, message); a shared Source/*.cs reports one defect once, not once per consumer)
distinct diagnostic codes 15

Independently confirmed: the repo carries exactly 98 files declaring "nodeType": "NodeType", so the bake compiled every one of them — the sweep's denominator is the tree's.

Per code, and who owns it

code occurrences sites types ratchet whose
CS1591 missing XML doc comment 2,378 1,386 85 doc-comments this repo's content — the maintainer's report, counted
CS1573 a <param> with no matching parameter 247 61 30 warnings this repo's content — a doc comment that IS wrong
CS0419 ambiguous cref 194 26 29 warnings this repo's content
CS1574 cref resolves to nothing 188 25 31 warnings this repo's content
CS1701 assembly-reference version skew 114 2 95 warnings the reference set — no author can fix it
CS1734 paramref naming no parameter 59 7 23 warnings this repo's content
CS1572 a <param> tag for no such parameter 18 4 8 warnings this repo's content
CS8602 possible null dereference 15 1 4 warnings a latent bug — but 12 of the 15 are the generator's; see below
CS8604 possible null argument 14 4 6 warnings a latent bug
CS8601 possible null assignment 12 1 1 warnings a latent bugthe scope-proxy generator's; see below
CS1570 badly formed XML 3 2 3 warnings this repo's content
CS1571 duplicate <param> 3 1 3 warnings this repo's content
CS0105 using directive appeared previously 1 1 1 warnings this repo's contentwrong; it was a tool's. See "The CS0105 row was misattributed" below
CS1587 XML comment not on a valid element 1 1 1 warnings this repo's content
CS8625 null literal to a non-nullable 1 1 1 warnings a latent bug

The nullable four — CS8602/CS8604/CS8601/CS8625, 42 occurrences over 7 distinct sites — are the debt worth paying first: real possible-null defects in source that ASKED for the analysis (571 of this repo's in-mesh Source/ and Test/ .cs files carry #nullable enable), and few enough to clear. CS1701 is the one class nobody here can pay — System.Reactive compiled against .NET 8's System.Linq.Expressions and running on .NET 10 — so the bake RECORDS it.

The two armed lanes were checked against each other FIRST

The gate composes 4 module bundles and publish-bake composes 8, so "both lanes take the same file" is a claim that had to be measured rather than assumed — a code that only the 8-module composition produces would be NEW on main the moment the PR that introduced the baseline merged.

On the same commit, observe-only: publish-bake's (narrowed) bake compiled 38 of 38 types with its 8 modules and produced 11 diagnostic codes, every one of them inside the gate's 15CS1591, CS1573, CS1574, CS0419, CS1701, CS1734, CS1572, CS8604, CS1570, CS1587, CS8602. No code appears that the 4-module composition does not see, and in particular no CS0436. That is what makes one file honest for both lanes.

It is a strong check rather than a proof: a narrowed bake cannot enumerate the pairs of types it did not compile. The residue is bounded and loud — should the merge lane ever see a pair the gate did not, it fails on main naming the exact line to add, which is the ratchet behaving correctly rather than a silent drift.

The baseline, and what it enforces

plugin-gate-warnings.allow records 321 (type, code) pairs over 98 NodeTypes, every one of them a line the armed bake printed for itself:

ratchet entries codes what it refuses from now on
warnings 236 14 a NEW real warning on ANY of the 98 types — including a new CODE on a type already carrying another — and a recorded one that has since been fixed (STALE)
doc-comments 85 1 (CS1591) a type acquiring its FIRST undocumented public member, and a type that has become fully documented (STALE)

The key is (type, code), not (type): the 85 types already carrying CS1591 cannot pick up a broken cref or a possible-null dereference in silence, because that pair is still NEW.

The harvest is internally consistent, which is the check worth stating: the 321 lines split 236/85 exactly as the two ENFORCED verdicts counted them, and the per-code line counts reproduce the inventory's own type(s) column for all 15 codes (CS1701 95, CS1591 85, CS1574 31, CS1573 30, CS0419 29, CS1734 23, CS1572 8, CS8604 6, CS8602 4, CS1570 3, CS1571 3, CS0105 / CS1587 / CS8601 / CS8625 one each).

The CS0105 row was misattributed — and the check that would have caught it

The harvest originally closed with "Not one site names a GENERATED member — core fixed those three codes at the root before any satellite could record them under the content's name." That sentence was false, and the CS0105 row is the counter-example. Core did fix the skeleton generator's un-deduped usings; this repo's own scripts/gen-scope-proxies.py then produced a duplicate by a different route, and the baseline recorded it as BusinessRules/Scope's debt.

The mechanism, measured at the byte:

Fixed at the generator (utf-8-sig on every read of .NET-written text) and the proxies regenerated; the baseline line is deleted, and the STALE check is what proves the warning is gone rather than merely tolerated.

What made it survive review. Nothing renders a mid-file BOM: not an editor, not git diff, not the code review. The generator's own --check compared the committed bytes against the same wrong bytes and printed ✓ 7 proxy file(s) match. The ratchet was the only instrument that saw anything at all, and what it saw it attributed to the wrong owner. The check that separates the two cases is one command — if a code the platform is supposed to have fixed reappears, look for the site before believing the attribution:

grep -c $'\xef\xbb\xbf' <the module's committed .cs files>   # 0 everywhere, or the BOM is yours

This is the fleet's utf-8 / utf-8-sig trap in its quiet form. The known shape crashes (UnicodeDecodeError / a stray  in a parsed key); this one emits a duplicate and keeps going. Any Python in this repo that reads text a .NET program wrote uses utf-8-sig.

…and the same generator owned the CS8601 row, and 12 of the 14 CS8602 occurrences

Having found one row misattributed, the arithmetic was worth redoing for the neighbours. CS8601 is 12 occurrences over one type, BusinessRules/Scope. The seven committed proxies contain exactly 12 GetProperty calls, and each one is this line:

private static readonly System.Reflection.MethodInfo __G___X = typeof(X).GetProperty(nameof(X.Y)).GetMethod;

Type.GetProperty returns PropertyInfo? and PropertyInfo.GetMethod is MethodInfo?, so that single line is two warnings: a dereference of a possibly-null reference (CS8602) and a possibly-null assignment to a non-nullable field (CS8601). Measured on a standalone project configured exactly as the mesh compiles — <Nullable>annotations</Nullable>, one compilation unit, an authored #nullable enable ahead of the generated text — two such lines produced 2 × CS8602

The counts close with nothing left over, on both trees:

tree CS8602 total types BusinessRules/Scope
main 15 Scope, Collaboration/Review, Edu/CourseCatalog, Store/Plugin 12 (the other three ≥ 1 each)
the warn-as-error branch, authored sites already fixed 14 Scope, Edu/CourseCatalog, Store/Plugin 12 (the other two ≥ 1 each)

So BusinessRules/Scope carried no authored CS8601 or CS8602 at all — 24 of the repo's recorded warning occurrences were one emit in one generator, filed under the content's name.

Why nobody could see it where the proxy lives. The mesh compiles a NodeType's sources as ONE concatenated compilation unit under NullableContextOptions.Annotations, so nullable analysis runs only in text that opted in — and an authored #nullable enable earlier in the concatenation (BusinessRules/Scope's Test files all carry one) is still in force when the generated text is reached. The pre-push gate, scripts/compile-check.py, feeds each file as its own <Compile> item, where the directive cannot reach across, and reports nothing. Both halves measured: the gate green, the bake naming 24 occurrences.

The fix is at the generator, and it is a fix rather than a silencing — ?.GetMethod ?? throw new InvalidOperationException(…). The getter is guaranteed by the symbol the line was emitted from, so a null there is a metadata mismatch; naming it at type initialisation beats a NullReferenceException raised later inside Evaluate. No !, no #pragma.

The instrument: scripts/concat-probe.py

Both defects were invisible to the pre-push gate and named only in aggregate by the bake, which is an unpleasant place to debug from: CS8602 14× / 1 site(s) / 3 type(s) says a code and a count, not a file and a line. scripts/concat-probe.py closes that gap — it builds the MESH's shape for ONE NodeType (usings hoisted the way ExtractUsingStatements hoists them, everything concatenated into one unit, <Nullable>annotations</Nullable>, documentation file on, analyzers off) and prints every diagnostic mapped back to the contributing file and line:

python3 scripts/concat-probe.py BusinessRules/Scope
#   before the generator fix: 24 diagnostic(s) — 12 CS8601 + 12 CS8602, each on a
#   `GetProperty(...).GetMethod` line in a Test/Generated/*Proxy.cs
#   after:                     0 diagnostic(s)

🚨 It is a diagnostic, not a gate, and it exits 0 whatever it finds. Its reference set is a source build, so it can differ from the bake over anything the platform image's implementation assemblies decide (CS1701 above all), and it is not sharded, baselined or ratcheted. Use it to FIND a site the bake has already named — the bake stays the verdict.

Reading a red

The line says What happened What to do
NEW <type> <code> this (type, code) pair is not in the baseline fix the warning. Record it only if it is genuinely not yours to fix — and then say why in a comment beside the line
STALE baseline entry the type compiled CLEAN of that code delete the line. The debt was paid; the ratchet refuses to keep pretending otherwise
unverifiable baseline entry the type did not compile in this run, or this run never baked it nothing. It is a warning, never a failure — a type that failed emitted no warnings, and reading that silence as "the debt is paid" would delete a record on a measurement nobody took

MW_LOG_LEVEL=Information on a local bake restores every distinct site behind a line, so a baseline entry can always be expanded back into the members it covers.

🚨 A #pragma warning disable in a NodeType's source is not a fix, it is the same evasion wearing content's clothes. And a code the PLATFORM emits into content it does not own is fixed in core — three whole codes were, rather than being recorded in every repo of the fleet under the content's name (see the core page).

🚨 The ratchet lives in the IMAGE, not in the lane

--warning-baseline is parsed by mw-plugin-test, which comes from the resolved platform set — not from @main of the workflow. Measured on the two images themselves:

tester image compile --help --warning-baseline /tmp/nope
3.0.0-ci.8721 (@sha256:17a621ff…) lists [--warning-baseline <file>] and explains both ratchets accepted
3.0.0-ci.8716 (@sha256:eb3ac423…) no such option Unknown argument '--warning-baseline'. Try --help.exit 2

So a repo that arms the input while its runs still resolve an older sealed set does not get a lenient bake; it gets a dead one, with an error that names the tester's argv rather than the pin.

This is why arming waited for main to pass on a set carrying it: pull requests here follow the newest sealed set this repo's main has passed (scripts/resolve-platform.py), so a repo cannot adopt a core capability before its own main has vouched for the set that carries it. A satellite that copies this change without checking its resolved set will red every PR at once, and the error will name the tester's argv rather than the pin.

🚨 And "main has passed on it" is read from an ANNOTATION that a re-run erases. Measured while adopting this, 2026-09-16: main's run resolved 3.0.0-ci.8721 and died on an artifact-service 403 (FinalizeArtifact; the tests themselves reported failed: 0). rerun-failed-jobs re-ran the two legs and the run concluded SUCCESS — but GitHub re-creates job records for the whole new attempt, and the carried-over Resolve the released platform job came back with zero annotations (attempt 1 had it; attempt 2 did not). main_passed_ceiling() reads the latest attempt, found nothing, skipped the run, and every pull request in this repo went on resolving 3.0.0-ci.8716 — under a sentence that is false: "main has not passed on it yet". It had, six minutes earlier. This change was blocked on exactly that for three hours, and the way out was a FULL re-run of a later main run: it re-runs the resolver, so the annotation is re-published (measured — attempt 2's own job record carries it), where a partial re-run would have erased it again.

It is not a one-off: enumerating this repo's newest successful main push runs and reading each one's annotation directly, every run at attempt 1 carries it and both runs that were re-run have none (35073843357 today, 35027485210 the previous evening). The loss correlates exactly with run_attempt > 1 — which is the ordinary outcome of retry-known-transients.yml doing its job.

The only honest ways out are a NEW push to main or a FULL re-run; re-running the resolver job alone would re-resolve a NEWER set and publish an annotation claiming main passed on a set it never tested. Filed as MeshWeaver#4491 — the resolver should read the annotation across attempts, and the vendored copy (check-resolver-copy.py) means the fix lands in core first.

A consequence worth knowing: the reference set is part of the measurement. A diagnostic like CS1701 (assembly-reference version skew) is a property of the set, so a platform bump can add or remove a pair with no content change at all. That is loud by design — the entry is added or deleted with the bump, and the ratchet never silently absorbs it.

Reconnecting…
The connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.