The Cross-Repo Pair Gate

Ordering is the whole invariant: when a change spans this repository and a plugin repository, the half that REMOVES public surface must land LAST.

Until this gate existed, nothing enforced that. The half that landed first decided whether somebody else's trunk went red, and the failure was invisible on the pull request that caused it: it surfaced in a different repository, on unrelated pull requests, minutes to hours later. The people who saw the red were never the people who caused it.

What it cost, measured

MeshWeaver#2689 collects five incidents. The first is the shape the gate is built around.

MeshWeaver#2678 — "the node-surface views leave the platform for a module" — merged here and deleted ApiTokenLayoutAreas, GroupLayoutAreas, GroupMembershipLayoutAreas, MeshDataSourceLayoutAreas, NotificationLayoutAreas and ReleaseLayoutAreas from MeshWeaver.Graph. Its plugin half — the MeshWeaver.Graph.Views module — was still open. Consequence in MeshWeaver.Plugins:

MeshWeaver.AI.Test — 8 failures, all CodeCell*:
  No renderer is registered for area `Content` on hub `rbuergi/cell-…`

Those eight failed the MeshWeaver.AI module bundle, which failed All selected bundles built, which failed both required compile gates. Every open pull request in that repository went red, and its main was red from 17:20 (last success 16:48) until the fix.

The other four shapes on the issue widen the class — an added overload that made a <see cref> ambiguous (CS0419, an error under -warnaserror); a JSON envelope whose shape three consumers read by string; a carve-out whose pinned image predated the type it needed; and an explanatory sentence in a // comment that another repository's script parsed as a canonical assembly name. This gate addresses the first class only, and says so below.

Why nothing else could catch it

Gate What it answers Why #2678 passed it
check-type-forwards.py can a module ALREADY PUBLISHED still bind this TypeRef? the nine types were allow-listed as "proven cross-repo moves nothing binds" — which was true, and irrelevant to whether the other repo's source still compiled
its --sibling flag is a departed type a move or a deletion? CI deliberately never passes it: this repo is PUBLIC, the plugin repos are PRIVATE
the plugin repo's own CI does the plugin tree compile? it builds against the published core package, not core's main — green until CD composed the two
a binary-compatibility gate did a name disappear? nothing was removed in shapes 3–5 at all

The general statement, and the reason a pair gate is not merely extra diligence: core's CI does not build the plugin repos, so the coupling can surface nowhere except CD, after the fact. No amount of care in core catches these, because core's gates are not looking at the thing that breaks.

How it works

Two scripts and one job, cross-repo-pair in .github/workflows/dotnet-test.yml.

1 · The trigger — one detector, reused

scripts/check-type-forwards.py --surface-json <path> writes the set of public top-level types declared under src/ at the merge base and no longer declared at HEAD, in three categories:

Category What it is How it breaks another repo
departed gone from src/, while the assembly it left is still built here post-#2276 this is what a move INTO a plugin repo looks like from in here — the #2678 shape
moved landed in a different src/ assembly a type forwarder keeps the type IDENTITY, but the consumer's compile still needs the DESTINATION assembly referenced, or CS0012
assembly-left the whole assembly is no longer built here the carve-out wave — the biggest cross-repo pair there is
member-removed a public member of a type that STAYS is gone (since #3103) CS0117: 'X' does not contain a definition for 'Y' — #3137, the sixth shape

That set is deliberately wider than the forwarder gate's verdict, because it answers a different question. In particular the allow file (scripts/type-forwards.allow) is not consulted: an entry there states that no shipped module holds the TypeRef — a claim about binary compatibility that says nothing at all about whether the consuming repo's source still compiles.

It is also rare enough to be free. Measured on 2026-09-02:

Window Public types removed
main~1 … main~25 → main 0
main~100 → main 116 — all of them the Maps/Indexing carve-out (#2941)

So an ordinary pull request never meets this gate at all, and the one window that does is exactly the wave that produced two of the issue's five incidents.

2 · The declaration

When the set is non-empty, the pull-request body must carry one of:

Pairs-with: Systemorph/MeshWeaver.Plugins#904
Pairs-with: https://github.com/Systemorph/MeshWeaver.Plugins/pull/904
Pairs-with: none — <reason, at least 12 characters>

Bulleted and bolded forms are accepted, because that is how a body is actually written. Fenced code blocks, HTML comments and quoted (>) lines are stripped first, so documenting the syntax — this page included — never declares a pair.

🚨 A line that starts Pairs-with: and parses as neither form is a FAILURE, never an ignored line. A typo'd declaration that read as "no declaration" would put the author and the gate in disagreement about whether a pair was declared, which is the same trapdoor as a gate that skips on a missing input.

3 · The verdict

Each declared counterpart is resolved through the GitHub API and must be merged into its repository's default branch.

Why this is not an inverted dependency

test/MeshWeaver.Documentation.Test/PlatformNeverDependsOnPluginsGuard.cs asserts that the pull-request gate reaches into no plugin repository, and this gate sits on the pull-request path. The distinction that makes both true at once is worth stating precisely, because it is the line the whole guard is drawn along:

A checkout puts another repository's SOURCE into core's build. An API read puts only a FACT about it into a verdict.

This gate resolves a pull-request number the author declared, under a scoped GitHub App installation token with pull-requests: read. No plugin source enters core's build; dotnet build here still needs no sibling on disk; a release (release.yml) checks nothing out at all. That is the same footing shared-rules has stood on since #2732, and both are inventoried in Repository Dependency Direction § C.

🚨 It is still a real edge, and the cost is stated rather than hidden: a core pull request can go red because of a sibling's state. So the guard now carries a second ledger — ApiReadLedger — enumerating every workflow that reads a sibling through the API, asserted in both directions: a new one fails naming itself, and an entry that stops matching fails too, because a detector that silently stops seeing its subject reports a clean tree forever after.

Where its teeth stop, and what that means

The gate cannot enumerate a private repository's callers for you. Core cannot see them, by construction. So Pairs-with: none — <reason> is an escape, and it is deliberately shaped like a scripts/type-forwards.allow entry rather than like a skip: it is a declared, attributable statement in the pull-request body, it is printed into the job log, and an unexplained none is refused. What the gate removes is the case where nobody was ever asked.

It also covers exactly TWO of the ten shapes below — a public type leaving src/, and (since #3103) a public member leaving a type that stays. Two more have gates of their own, and four have nothing. The whole board, so that "uncovered" is written down rather than inferred:

# shape gated by status
1 a public type leaves src/ Cross-repo pair (public surface) gated
2 an added overload makes a dependent's <see cref> ambiguous — UNCOVERED
3 a JSON envelope's field names change Dependent suites (MeshWeaver.Plugins) gated where a reachable Plugins suite exercises the envelope (required, per realm); otherwise uncovered
4 a comment another repo's regex parses as data — UNCOVERED
5 an i18n value change — UNCOVERED (the mirror guard compares against a pinned core commit)
6 a public member leaves a type that stays Cross-repo pair (public surface) gated
7 a public method's BEHAVIOUR changes behind an unchanged signature Dependent suites (MeshWeaver.Plugins) gated for every Plugins suite the diff can reach — REQUIRED on every pull request, a measured break merges only when declared (Breaks-plugins:, below); still undetectable by any SURFACE detector, unmeasured for in-mesh Tests areas, and uncovered for the other satellites
8 a PackageVersion a satellite consumes VERSIONLESS is removed Satellite package pins (removal declared) gated (#3349)
9 a RENDERED-UI change a satellite's e2e asserts on — UNCOVERED; the honest instrument is the release note
10 a member ADDED to a public interface breaks external IMPLEMENTERS Interface additions (implementers declared) gated (#3465)

Shapes 2–5, 7 and 9 are detectable only by knowing what the dependent consumes, and core cannot know that.

The structural answer to those is the one #2689 names as its acceptance criterion — compile-and-run the dependent's suite against the candidate core commit, as a CI-time integration and never as a build-time reference. That keeps the dependency direction intact: core still builds and ships without the plugin repos present, and the integration is an observation about a candidate commit rather than a link into it. For MeshWeaver.Plugins it is now built — see "The dependent's suites run against the candidate" below. For every other satellite the answer is still event-based and lives in the dependent: see "The dependent reacts to core's events".

The seven CODE shapes

Collected on #2689, #3103 and #3276. Each column says which mechanism sees it — the pair gate here, or the dependent's own CI reacting to core's release event. Three more shapes were found later and have sections of their own below: the eighth is a central package pin (#3344), the ninth a rendered-UI string a satellite's e2e asserts on (#3401), and the tenth an added interface member that breaks external implementers (#3465).

# shape incident pair gate dependent's CI on the release event
1 a public type leaves src/ (departure, forwarded move, whole assembly) #2678 — nine Graph view classes; Plugins' trunk red two hours yes yes
2 an added overload makes a dependent's <see cref> ambiguous (CS0419, an error under -warnaserror) #2678 again, a second independent break from one merge no — nothing is removed yes — additions dispatch too
3 a JSON envelope's field names change; the dependent parses them by string #2689 no yes, when the dependent's suite exercises the envelope
4 a comment another repo's regex parses as data #2689 (2026-09-01 carve-out wave, failure #1) no only when the dependent's gate runs on the dispatch — its validate lane does not
5 the i18n mirror: a value change in strings.{en,de}.json (#2650) every Plugins PR red until the mirror PR lands no only when rn-app runs on the dispatch — it does not (see the receiver's scope)
6 a public member leaves a type that stays (field, const, method, property, event, positional record parameter, enum constant, interface member, nested type) #3137 — CacheDuration/NegativeCacheDuration; CS0117; Portal hosts (shard 0) red on every Plugins PR for three hours, "nothing was tested" yes (since #3103) yes
7 a public method's BEHAVIOUR changes behind an unchanged signature — same name, same parameters, different answer #3276 — CatalogLayoutAreas.RenderFromSource began rendering the category landing instead of the flat package list; two MeshWeaver.PluginCatalog.Test render tests in Plugins went red, found 48 commits later by a pin bump no — nothing is added or removed, so no surface detector can see it only if the dependent actually rebuilds against the release — a PINNED dependent does not (below)

Shape 7's worst form: the dependent holds a COPY of the test (#3345)

Shape 7 is invisible to every surface detector by construction. It gets worse when the behaviour that changed is pinned by a test the dependent keeps its own copy of — and that is not hypothetical, it is the arrangement two teardown tests are in today:

file lives in
NackReachesTheWaiterDuringTeardownTest test/MeshWeaver.Graph.Test/ and MeshWeaver.Plugins/src/MeshWeaver.Hosting.Monolith.Test/
LateNackReenqueueTest the same two places

They are duplicated on purpose: core's CI cannot stand up a monolith mesh, so the copy that actually exercises the behaviour lives in the dependent, and the copy that keeps core honest lives here. What made #3345 expensive is what held them together — a comment, on core's copy only:

// Core twin of MeshWeaver.Plugins/… Keep the two in step.

The person who needed to read that was whoever edited the Plugins copy. The person who saw it was whoever edited core's. #3291 rewrote core's twins to the no-forced-teardown contract and left the Plugins originals asserting the contract it had just deleted. Nothing was red — a pinned dependent does not rebuild on core's release event (see the row above) — until the pin bump, one day later, produced a 55-second VERDICT_TIMEOUT in a suite whose name points at the mesh. It was filed here as a core regression and bisected across five commits before the premise fell over.

The control is TeardownTwinParityTest, and it lives in the dependent — the only side that can see both files, because it builds against $(MeshWeaverRoot) at MW_PLATFORM_REF. It compares each body below the namespace line against the core checkout at the pin, never core's main, so it reddens exactly in the change that MOVES the pin, which is the change that owes the update, and it is silent in every change that does not. A missing platform checkout FAILS rather than skips.

Two rules follow, and they generalise past these two files:

Shape 7 in a memory bound: #4017 and NodeTypeRecompileAlcLeakTest (2026-09-11)

Core #4017 (b128b804d) changed WHEN a NodeType load context is superseded — only a publish, never a read — behind an unchanged ICompilationCacheService.GetOrCreateLoadContextForPath signature. The pair gate was green and correct to be green: nothing was added or removed. The behaviour it changed is asserted in the dependent, by NodeTypeRecompileAlcLeakTest.RecompilingANodeType_WithALiveInstance_StillReleasesSupersededContexts (Plugins, Portal hosts (shard 3)), and Plugins resolves the newest SEALED core set per run — so the red arrived when the set carrying #4017 sealed, on every Plugins PR and on Plugins main, with no Plugins change:

Expected 3 to be less than or equal to 2 because a live instance hub legitimately DEFERS an unload, but nothing may make that deferral permanent: …

The control that settled causation held the Plugins diff constant and moved only the core set: the same Plugins commit 401fcadb passed shard 3 at 09:56Z on 3.0.0-ci.8340 and failed it at 10:09Z on 3.0.0-ci.8345. Across the 42 shard-3 runs between 05:10Z and 11:32Z the test failed 6 of 6 on sets 8345/8350/8352 and 0 of 36 on 8323–8340, and #4017 was the only merge between 8340 and 8345 touching compilation code. The deciding question was then not who but which side is wrong: the assertion was right (the read path had begun keeping a second context over the same build), so core was fixed forward and the test kept its bound — see NodeTypeCompilation → One build at two paths is ONE generation.

Two lessons for this shape. The dependent resolves the SEALED set, so a shape-7 red surfaces at seal time, not merge time — attribute by the set each run's Resolve the released platform job names, never by the clock. And a red in the dependent is evidence about core only after the diff is held constant — one commit, two sets.

A shape-7 suspect that was NOT shape 7: the torn bake input (2026-09-11)

Hosting/Issue compiled on core set 3.0.0-ci.8314 and failed with CS0103 'ObservationQueries' on a new pod of 3.0.0-ci.8372; the range between them touched source resolution and compile-cache code, nothing touched the shared= literal, and the obvious reading was shape 7 — behaviour changed behind an unchanged signature. It was not. The failing type's own version history showed its definition MOVING during the pod's bake (v458 at 18:53:04Z with no declared sources, v462 at 18:53:51Z with the shared= entry): the bake had compiled the definition it enumerated before a module update over the files that landed after it. The deterministic repro fails identically on both ends of the range (45306a33e and 74d4c8527), and a fresh pod on the same image baked the type cleanly. Bisecting the range would have found nothing.

The discriminating read before bisecting a shape-7 suspect in a NodeType compile: read the failing type's versions across the failing process's bake window (get_versions, then get_version on each side of the window). If the definition moved while the process was baking, the verdict was measured against a moving input, and the image range is the wrong place to look. The defect and its fix: the batch bake compiles the definition the mesh holds when it compiles.

🚨 Shape 7 in the by-hand sweep: a ! on the WRONG receiver hides the site (#3321)

Shape 7 has no gate, so the only control is a by-hand sweep of the dependents. This is about how to write that sweep, because #3321 found a shape that defeats the obvious way of doing it.

Core's step 3 made ISynchronizationStream.Hub — a public property, declared non-nullable — start answering null once a stream releases it. Nothing was added or removed, so the gate saw nothing, correctly. The sweep found 11 production dereferences in MeshWeaver.Plugins's Blazor view layer, every one of them written like this:

// BlazorView.Stream is `ISynchronizationStream<JsonElement>?`; Hub is declared non-nullable
Stream!.Hub.Post(new ClickedEvent(Area, Stream.StreamId), …);
je.Deserialize<TValue>(Stream!.Hub.JsonSerializerOptions);

🚨 The ! binds to Stream, not to .Hub. It suppresses the warning about the receiver the compiler already knew was nullable, and says nothing whatever about the dereference one level to the right — which is the one that changed. So when Hub becomes absent, the NRE lands past the operator that looks like it was put there to handle exactly this, and no CS8602 is ever emitted. A reviewer skimming for "unguarded dereference" reads ! as due diligence.

Two of the 11 were worse than silent:

The rules for a shape-7 sweep, and they generalise past this property:

The measured blast radius — six satellite checkouts, every file, receiver-resolved — was 22 occurrences in one repo and zero in the other five, with the four content satellites clean structurally (ISynchronizationStream appears in zero files in four of them). The landing order that follows from shape 7 having no gate is in Stream Liveness and the Hub Reference: the dependent's guards are no-ops against the old core, so the dependent merges FIRST.

An eighth shape: a PackageVersion the satellite consumes VERSIONLESS (#3344)

The seven shapes above are all about code. This one is about the central package list, and it is the cheapest of them to trip.

MeshWeaver.Plugins/src/Directory.Packages.props imports this repo's Directory.Packages.props. A satellite project may therefore carry a versionless <PackageReference Include="X" /> whose only version source is an entry here. Delete that entry — even as collateral in an unrelated withdrawal — and the satellite stops restoring, with error NU1010: PackageReference items do not define a corresponding PackageVersion item.

Both repos stay green while it is broken, for two independent reasons:

The pair gate does not see it either: nothing public is removed. The first thing that notices is main-cd, which is the only lane that builds a checked-out plugins tree against this repo's list — and by then the damage is that no set seals.

Measured, #3344 (2026-09-05). A withdrawal of three SQLitePCLRaw pins also removed SQLitePCLRaw.lib.e_sqlite3 3.53.3, which was not part of that set and had been here since 2026-06-29:

#3344 merged 10:01:41Z (every check green)
main-cd #7813 failed 10:05:09Z — NU1010, Plugins: bake + seal skipped
last sealed set #7811, 09:27:02Z

It was also the CVE remedy for GHSA-2m69-gcr7-jv3q, so the same line drop reintroduced a high-severity advisory as NU1903. The comment block above the entry survived the removal intact and still read "the pin below" and "The pin is the source of truth" — pointing at a line that was gone, which is what made the deletion read as deliberate in review.

How big is it? Measured on main, 2026-09-05: MeshWeaver.Plugins carries 49 versionless <PackageReference>s that no project in this repository references at all, and 47 of those resolve their version from an entry here. Any one of them can be deleted with every core check green, and Plugins stops restoring.

Two obvious controls are wrong, and knowing why is what makes the third one right.

A list of the load-bearing entries was written, measured against that number, and discarded: a hand-maintained list of 47 goes red on core PRs whenever Plugins legitimately drops a dependency, taxing every unrelated change in this repo for a fact that lives in another one.

A restore of the satellite tree in core's PR lane — actions/checkout of Systemorph/MeshWeaver.Plugins followed by a dotnet restore, the way main-cd does — derives the answer instead of remembering it, and is the control this page recommended until #3349. It cannot be built here. PlatformNeverDependsOnPluginsGuard.ThePullRequestGate_ReachesIntoNoPluginRepository asserts that dotnet-test.yml contains zero actionable cross-repo hits, and both repository: Systemorph/MeshWeaver.Plugins and any line reading plugins-repo are hits. That ban is the point rather than an obstacle: a gate on core's own pull requests whose verdict depends on a sibling's moving HEAD makes the same diff go red or green with no change of its own, and re-adding a checkout silently restores the external input that workflow was deliberately cleared of.

The gate that ships: declare the removal (#3349)

Satellite package pins (removal declared) fires only when the diff removes a PackageVersion and requires the pull-request body to name each removed id:

Satellite-pins: <PackageId>[, <PackageId>…] — <what you checked and what you found>

It is the same instrument this page already describes for public surface, applied to the same shape one category over: a package pin is a Pairs-with: case that happens not to be C#. Core-only — no checkout, no API read, no credential, no ledger entry, nothing that can go red on somebody else's HEAD — so it also runs on fork pull requests, where the credentialed gates cannot.

What it does not do, stated plainly: it does not know whether a removed pin is load-bearing. It makes a human find out, with the grep below, and say what they found. That is weaker than deriving the answer and is the honest price of staying inside the dependency direction. There is deliberately no blanket form — "none of them matter" is the sentence that produced #3344.

grep -rn 'PackageReference Include="<id>"' ../MeshWeaver.Plugins/src/*/*.csproj

A versionless hit is a blocker: keep the entry, or remove the reference there first.

Proven against the incident, not just against fixtures. Replaying #3344's own commit (c08fee100) with its three intended withdrawals declared, the gate reds naming exactly the fourth:

Undeclared removal(s):
  • SQLitePCLRaw.lib.e_sqlite3

That is the collateral removal that cost ~80 minutes of sealing nothing and reintroduced the CVE. An undecidable read — a shallow fetch missing the merge base, a moved props file, a matcher that stopped matching — exits RED naming why, never "nothing was removed": couldn't tell and clean must never be one colour, which is the failure that let #3344 through with every check green.

A ninth shape: a RENDERED-UI change the satellite's e2e asserts on (#3401 fallout, 2026-09-06)

Shape 5 is about the i18n mirror — a value change in strings.{en,de}.json reddening MeshWeaver.Plugins' catalog guard. This is the other end of the same wire: the value reaches a satellite's browser tests, which assert on rendered text and rendered navigation. Nothing sees it. Not the pair gate (nothing public is removed), not the mirror guard (that compares catalogs, not pages), not the satellite's own CI (it pins the platform, so it keeps passing until someone moves the pin — a different day and a different PR).

One core commit produced TWO independent breakages in MeshWeaver.Education, and the first hid the second. c521f929e (2026-09-03) — "the plugin catalog opens with categories and loads packages per category":

what changed how it surfaced
a package(s) → the localized plurals plural.package.one / plural.package.other TEXT.catalogAvailable, the literal 'package(s) available', matched nothing. Bootstrap waited 20 s for text the page will never show again — while the page read … — 12 packages available.
b the catalog now opens on a category index and loads cards per category with (a) fixed, it failed later and faster (9.8 min → 4.9 min): the Plugin Catalog has no card titled 'Store', on a page whose only children were two links, Uncategorized and All packages

Both look like the satellite is broken. Neither is. And (b) was unreachable until (a) was fixed, so a single round of debugging finds one wall and reports success prematurely.

Why the pin makes it worse, not better. Education's platform pin predated c521f929e by days, so its e2e stayed green the entire time the break existed. The break arrived with the pin bump — authored by someone fixing an unrelated purged-manifest problem — which is the worst possible moment for it, because the bump's own diff is three digests and cannot plausibly be the cause. Expect the report to be "the pin bump broke the e2e".

What to do about it, on both sides:

🚨 Reading the failure is what shortens this. Both walls were diagnosed from the Playwright page snapshot in the run's artifact, which showed the catalog rendering correctly in both cases. Two plausible causes taken from the container log — Orleans no active nodes … grain podhub, and ConfigMasterKeyProvider refusing without a master key — were both excluded by evidence: the podhub errors appear in the last PASSING run too, and that refusal landed ten days before it. Guess from a log and this costs a day; read the snapshot and it costs an hour.

A tenth shape: an ADDED interface member breaks external IMPLEMENTERS (#3465, 2026-09-06)

Everything above triggers on something LEAVING. This one is the mirror image, and it is the half nothing covered:

🚨 A forwarder rescues a CALLER. It cannot rescue an IMPLEMENTER.

Core #3446 (the #3433 EA credential seam) added three reactive members to IEaGraphAuth — GetConnection, GetAccessToken, ExchangeAndStore — and kept the retiring …Async surface as default-implemented forwarders. Every CALLER therefore kept compiling. The pair gate was correctly silent, because nothing was removed. Core main went green. Then MeshWeaver.Plugins moved its platform pin onto the sealed set carrying it (Plugins#1415):

src/MeshWeaver.Mail.MicrosoftGraph.Test/ExecutiveAssistantDraftLifecycleTests.cs(438,44):
  error CS0535: 'FakeEaGraphAuth' does not implement interface member 'IEaGraphAuth.ExchangeAndStore(string, string, string)'
  error CS0535: ... 'IEaGraphAuth.GetAccessToken(string)'
  error CS0535: ... 'IEaGraphAuth.GetConnection(string)'

FakeEaGraphAuth is a sanctioned test double that implements the interface, so adding a member obliges it to supply one, and no amount of source-compatible forwarding on the core side changes that. Callers and implementers have different compatibility rules, and only the caller half was ever covered.

🚨 #3446's forwarder strategy was CORRECT and is not the mistake. It is what kept every caller working and what made the core change safe to land on its own. It is also what the platform's own rules ask for — AGENTS.md says retire a published symbol as a forwarder, never a delete — and it is deliberately not [Obsolete], because MeshWeaver.Plugins builds -warnaserror against a core checkout at a pinned ref, so the attribute would red that repo's main the moment core merged. The gap is the shape, not the strategy.

Why it bit harder than usual: a CIRCULAR block on the release critical path.

Each blocked on the other, Plugins main was dark for hours, and three sessions were needed to unwind it. The resolution is that the two must land together — but nothing warned that they would have to, and the coupling was discovered by a red rather than predicted.

Why the existing partial cover cannot fire either. Same reason this page already records for shape 7: for a dependent that PINS core, the pin bump IS the integration test. Plugins' platform-ref job resolves the same pin on the release event as on a pull request, so the event moves what is BAKED, not what src/ compiles against. The break is invisible until somebody moves the pin — which is when it is dearest.

The gate that ships: declare the addition

Interface additions (implementers declared) fires only when the diff adds a member an outside implementer would have to write, and requires the pull-request body to name each one:

Implementers: <Type.Member>[, <Type.Member>…] — <what you checked and what you found>

A member counts as implementer-obliging when:

🚨 Giving the member a DEFAULT IMPLEMENTATION silences the gate, and that is the point. A default interface member keeps every implementer compiling, so it is the actual fix; a gate that taxed it would push authors away from the one change that works. #3446's …Async forwarders are exactly that shape, and the detector is silent on them — including the case where the => sits two lines below the parameter list, which is how a long signature is naturally written.

🚨 The ordering is INVERTED, and that is why this is not a Pairs-with:

For a REMOVAL the deleting half lands last, so Pairs-with: demanding a merged counterpart is exactly right. For an ADDITION the core half lands first: the dependent's adaptation cannot compile until core's change is pinned. A gate demanding a merged counterpart here would have demanded the very deadlock it exists to prevent — Plugins#1416 could not have merged, by construction.

So this gate asks for a statement, not a precondition: name each obliging member and say what you found. It therefore needs no credential, no API read and no ledger entry, and — like Satellite-pins: — it runs on fork pull requests, where the credentialed gates cannot. There is deliberately no blanket form: "nothing implements it" is a claim about a repository this one cannot see, so it is made per member or not at all. And a reason resting on a live-mesh sweep must quote the envelope's searched: true, for the same #2741 reason Pairs-with: none does — in-mesh C# can implement a core interface and no compiler here can see it.

What it costs, measured

window additions of which implementer-obliging
main~25 → main (2026-09-06) 17 types + 20 members 3 — #3446's, all of them
main~100 → main 49 types + 42 members 3 — the same three
per merge, last 40 first-parent 15 change the public declaration set 1 merge meets the gate: #3446

So the tax on ordinary work is zero, and the one pull request in a hundred that meets it is the one that caused the incident.

Falsified both ways, against the incident itself

Replaying #3446's own commit (--base 1bcb764ec^1 --head 1bcb764ec):

ARM 1 — the pull request as it was actually written (no declaration)      exit 1
  [implementer-obliging-added] MeshWeaver.Mesh.Contract :: MeshWeaver.Mesh.IEaGraphAuth.ExchangeAndStore
  [implementer-obliging-added] MeshWeaver.Mesh.Contract :: MeshWeaver.Mesh.IEaGraphAuth.GetAccessToken
  [implementer-obliging-added] MeshWeaver.Mesh.Contract :: MeshWeaver.Mesh.IEaGraphAuth.GetConnection

ARM 2 — the same diff with the three members declared                     exit 0
  Every obliging addition is declared.

Exactly the three members CS0535 named, and nothing else — the three …Async forwarders added in the same diff are default-implemented and are correctly reported as ordinary member-added.

🚨 Arm 2 was RED on its first run, and the reason is worth keeping. The declaration in a real pull-request body WRAPS — three qualified member names plus a sentence does not fit on one line — and the first matcher was single-line, so it read a correct declaration as no declaration at all. A gate that rejects correct work is how people learn to route around it. The matcher now joins continuation lines up to a blank line or the next label, and a line that starts Implementers: and does not parse is a failure, never an ignored line — the same rule Pairs-with: carries, and for the same reason: an author who believes they declared something and a gate that believes they did not is indistinguishable from a skip.

Four ways to oblige an implementer — two of them are now SEEN (#3489)

Written down because a gate whose limits are inferred rather than stated is how the next incident gets filed as a surprise. Each row was run against the detector, not assumed — the control in the same run (public abstract void B(); added to a public abstract class) reports implementer-obliging-added correctly, so a blank was a real blind spot and not a broken probe.

the change why an implementer breaks status
an interface gains a base interface — interface IFoo : IBar every implementer of IFoo must now supply IBar's members ✅ closed (#3489a) — implementer-obliging-base-added
a protected abstract member is added to a public abstract class CS0534 in an external subclass, exactly as for a public one ✅ closed (#3489d) — implementer-obliging-protected-added
an interface gains an overload of a member name it already declares void M(string) beside void M(int) is a member every implementer must write ⛔ open, deliberately
an existing default member is made abstract the body implementers were relying on is gone ⛔ out of reach by construction

All three closed shapes are one verdict in three grammars — an outside implementer must now write code it did not have to write, and no forwarder on this side can help, because a forwarder rescues a CALLER. So they share one declaration mechanism (Implementers:) and one gate. Splitting them would ask an author to learn three spellings of one obligation.

Why the first two were invisible, and why neither is a member diff. (a) changes no member of the interface at all — surface_additions diffs member SETS per type and there is nothing in that set to differ. (d) changes only members the public index does not hold by contract — it is public-only, so a protected abstract member is not an addition, it is not anything.

🚨 The (d) scan measured ZERO on its first run over a tree that has five, and the zero was blindness, not absence. MEMBER_MODIFIERS deliberately contains no access keyword but public, so _leading_modifiers stops dead at protected and answers set(). Every downstream test then reads false and the shape stays invisible — the same "a sweep's zero has two causes" shape the denominator exists to catch, one level further in. The fix strips the access keywords inside the new scan rather than widening a frozenset the public path also reads: moving publicMembersAtBase to close a blind spot in a separate index would be the cure breaking the patient. Verified: on origin/main the report's publicTypesAtBase, publicMembersAtBase, publicInterfacesAtBase and implementerObligationsAtBase are byte-identical before and after this change (1 974 / 12 345 / 132 / 452); only the two new counters appeared.

🚨 And the second zero-discrimination went the other way, which is why both are worth recording. grep -rE '^\s*protected\s+(internal\s+)?abstract' under src/ finds six declarations; the scan reports five. The missing one is RoutingServiceBase.RouteImpl, and the scan is RIGHT: RoutingServiceBase is internal abstract class, so nothing outside the assembly can subclass it and it is correctly out of scope. The crude instrument was the grep. A discrepancy is a question, not a verdict, and it resolves in whichever direction the evidence points.

What stays open, and why not closing it is the decision rather than the omission:

Both remain what Implementers: cannot ask about, and a reviewer of an interface change should read them as the two things still to check by hand — down from four.

Their own control arms, and one floor that is deliberately 1

implementerObligationsAtBase does not constrain either new shape: a parser that found every interface and every abstract member but stopped reading base lists would report a healthy 132 / 452 and zero base edges forever. So each gets its own denominator, published and floored:

arm floor measured on main, 2026-09-07
interfaceBaseEdgesAtBase 5 26
protectedObligationsAtBase 1 5

🚨 The protected floor is 1, not "far below 5", and that is a decision rather than laziness: with a true value that small there is no floor that is both meaningful and survivable, so it is set to catch exactly what a floor can catch here — the scan returning nothing at all, which is precisely how it read before this change. A partial regression is caught by a different instrument: scripts/check-parser-delta.py (#3492), which compares two parser versions over one tree rather than one parser against a guess about the codebase.

Falsification, three arms, each watched failing:

what was disabled cases that go RED
_base_list returns set() 3
_obliges_external_subclass returns False 1
the where-clause guard (constraints read as bases) 1
the consumer's trigger set narrowed to the #3465 category alone 2

The denominator, and what printing it found

publicTypesAtBase does not constrain this shape at all: a parser that located every public type but stopped recognising the word interface would publish a healthy 1 900 types and zero obligations forever, and "no obliging additions" would be spelled exactly like "the scan never looked". So the report carries two more control arms — publicInterfacesAtBase and implementerObligationsAtBase — and the gate refuses a report where either has collapsed, or one that predates the shape and carries neither. Measured on main, 2026-09-06: 131 public interfaces, 450 implementer obligations.

🚨 Cross-checking that denominator against grep found a live blind spot in the EXISTING gate. The parser said 130 public interfaces and grep said 131. The one it could not see was MeshWeaver.Domain.INamed — and the cause generalises: a UTF-8 BOM is not a line start, and NAMESPACE_RE anchors on one. 310 files under src/ carry a BOM; in 91 of them it sits immediately before namespace, so ^namespace never matched and the file was indexed with no namespace in force. Two silent consequences, both now fixed and both fixtured:

the file's namespace form what happened measured
block-scoped (namespace N {) types are declared at column 4, which without a namespace reads as NESTED — so they were absent from the index entirely 16 public types, including ButtonControl, HtmlControl, SplitterControl, NamedAreaControl, INamed. Deleting one triggered no pair gate at all
file-scoped (namespace N;) types were indexed under the wrong key — MeshWeaver.Data:IDataStorage for a type that is MeshWeaver.Data.IDataStorage 128 public types. A type-forwards.allow entry written with the real full name can never match such a key, and a namespace rename is invisible because both sides carry the same wrong one

publicTypesAtBase went from 1 935 to 1 951 on main with the one-line fix. A count nobody compares against anything is not a control arm — which is the whole argument for printing the denominator rather than merely computing it.

The same hole had a SECOND victim, and finding it is the reason to sweep siblings

There are three public-surface gates under scripts/. Measured against main:

gate BOM-aware before this change exposure
check-type-forwards.py no LIVE — 16 public types invisible, 128 miskeyed
check-cross-repo-pair.py yes (trivially — it parses JSON and a pull-request body, never C#) none
check-record-signatures.py no LATENT — 0 files today

check-record-signatures.py enforces record-signature stability, and its RECORD_RE anchors on ^ and opens with \s*. Python's \s does not match U+FEFF (measured: re.match(r"\s", "") is None, "".isspace() is False), so in a file whose BOM sits immediately before a public record — a record on line 1 — the declaration matches nothing and its primary constructor can change unchallenged. Measured today: 1 281 scanned files, 310 carrying a BOM, 522 public records, and ZERO where the BOM currently hides one. The hole is real and currently unexercised. It is fixed anyway, because one gate knowing what its neighbour does not is exactly how this survived unnoticed in both — and if the two are fixed in different changes they diverge again for however long that takes.

🚨 Reading a file without error is not reading it correctly. Both gates read with errors="replace", which guarantees no exception — a different property from correctness, and an easy one to mistake for robustness. It neither throws nor corrupts; it silently hands back U+FEFF as the first character. encoding="utf-8-sig" would strip it. In the record gate the strip deliberately lives in records_in() rather than at the read site, because the BEFORE side of every comparison comes from git show, not from read_text — a fix at the read site would have covered only half of each diff.

The durable protection is the denominator, not the lstrip

The lstrip fixes today's instance; a gate that silently indexes fewer types than last time is indistinguishable from a codebase that shrank. So each gate now states how much it is seeing and refuses to certify itself when that collapses:

gate denominator floor measured on main
check-type-forwards.py publicTypesAtBase 500 1 951
…the implementer shape publicInterfacesAtBase / implementerObligationsAtBase 50 / 150 131 / 450
check-record-signatures.py public records across the whole src/ tree 200 522

The record gate's floor is asserted against the whole tree in its --self-test, not per run, and that distinction is forced by its design: it scans only the files a diff CHANGED, so its per-run count is legitimately zero on most pull requests and could never be a floor. Its self-test runs first in the record-signatures job, so the denominator is checked on every pull request.

Proven in both directions by sabotage, the same way the tenth shape's detector was: removing the strip reddens the two BOM cases; forcing the floor above the real count reddens the denominator case; and a RECORD_RE that stops matching entirely now reddens a canary placed first — previously it raised KeyError from a later case and buried the verdict in a traceback, which is harder to read than the defect it found.

Member-level detection (the sixth shape)

check-type-forwards.py indexes, under each public top-level type, the names of its public members: body members one indent level inside the type that say public (or, in an interface, that do not say otherwise; every enum constant), plus a record's positional parameters — those ARE public properties, and renaming one breaks every with { X = … } in a consumer. Nested public types count as members of their outer type; a constructor is .ctor; an indexer is this[]; an operator is operator <token>.

A member removed from a type that is still declared at HEAD is reported as member-removed with fullName Namespace.Type.Member. A removed type is reported once — its members do not pile on. Two deliberate limits: the granularity is the NAME, so removing one overload while another still binds is not reported; and a rename is a removal plus an addition, which is what it is to a consumer.

Measured 2026-09-03:

what result
src/ at main 1 850 public top-level types, 11 574 public members across 35 assemblies
#3137 replayed (--base e4ab72222^1 --head e4ab72222) exactly the two fields, no other entry
main~25 → main 2 members removed (both #3137); 13 types and 17 members added
per merge, last 25 21 touch a src/*.cs; 11 change the public declaration set; 1 removes from it

The control arm grows with it: the report now carries publicMembersAtBase beside publicTypesAtBase, and the dispatcher below refuses a report that saw fewer than 3 000 members.

A waiver must rest on a sweep that ran

AGENTS.md asks for a search_chunks sweep of the live mesh before deleting public surface, because in-mesh source is invisible to every compiler. #3137's pull request made that sweep, the deployment answered "searched": false — no embedding provider, nothing searched (#2741) — and the answer was read as "no callers". So a Pairs-with: none — <reason> whose reason contains searched: false is now refused, and a reason that mentions a sweep (sweep, swept, search_chunks) without quoting searched: true is refused too. A reason that rests on something else — "only read by the test this PR rewrites" — is judged on its length alone, as before.

The dependent's suites run against the candidate (Dependent suites (MeshWeaver.Plugins) — REQUIRED)

Policy dependent-suites-affected-gate (Policy Not Prose), which superseded core-merge-never-blocked: a core pull request PROVES it does not break MeshWeaver.Plugins before it merges. Every non-fork, non-Dependabot pull request asks Plugins to build and run, against the CANDIDATE (the pull request's fresh merge with main), exactly the Plugins suites and in-mesh Tests areas the diff can reach — and Consolidate test results fails unless that verdict is green, or the break it measured is DECLARED (below).

That makes "compatible" — the standing assumption of policy platform-semver-versioning that the platform stays backwards compatible within a major — a MEASUREMENT with an explicit escape, instead of an assumption. It is also what finally covers shape 7 (a behaviour change behind an unchanged signature) for every Plugins suite the diff can reach: no surface detector can see shape 7, but a suite that exercises the behaviour can.

Why it is required now — the 2026-10-08 incident

Core #6297 (merged 08:23Z) made a one-way-synced partition refuse every non-system write, behind unchanged signatures. Every core check was green, and the advisory run this section used to describe did not run at all: it ran only on a pull request labelled dependent-suites, and nobody labelled it. Plugins main resolves the newest sealed same-major platform set, picked the change up within hours, and went red on its GitSync suite ("Access denied … synced one-way from its repository"). Nothing had declared the break, because nothing had measured it.

Earlier, three core merges in two days — #5635 (bf5ac85526), #5647 and #5655 (777e84819a) — each turned Plugins' main red the same way (StaleLiveBoundAreaTest, the LayoutAreaIdentityTest dispose wedge, NodeTypeBatchBakeDiscoveryInvariantTest, a MeshWeaver.AI host dying on exit 134).

Only what is affected — never everything

The policy it supersedes existed because a required wait on another repository once held a green core pull request four hours (#5807, a merge-queue entry waiting on an unbounded run). The answer here is SIZE, not silence:

The declared break — the one escape

A measured break merges only when the pull-request body says so, on one line:

Breaks-plugins: <realm>[, <realm>…] — <what breaks> — counterpart Systemorph/MeshWeaver.Plugins#<n>; semver: <major|minor|ceiling>

.github/scripts/check-plugins-break-declaration.py accepts it only when ALL of these hold:

condition why
the verdict is red on a MEASURED break (drift in named realms), with NO missing evidence anywhere (per realm or top level) and not a refused selection; a green, too, must carry its summary, its Plugins run link and its integer counts a declaration excuses a break, never an absent measurement
the declared realms cover every realm the verdict names as broken a break in an undeclared realm is an undeclared break
the counterpart is OPEN in Systemorph/MeshWeaver.Plugins (never a fork), and it is the one this run's request job measured once it merges, the default branch carries it and the main verdict itself must be green; a declaration added after the request was never measured
the counterpart's OWN run — the same candidate and base, Plugins at the counterpart's head — is green the adaptation is proven against the change it adapts to
the semver consequence is IN the diff: major → PlatformVersion's major increases; minor → its minor increases; ceiling → the compatibility epoch in src/MeshWeaver.Compiler/platform-compatibility.json increases policy platform-semver-versioning: the major moves only on a declared break; a ceiling is how the compatibility ladder declares one

The request job dispatches the counterpart's run beside the main one (key <run>-<attempt>-c, its own concurrency group) whenever the body names a counterpart — Breaks-plugins: first, otherwise a Pairs-with: pair (Paired Change Sets), where it stays informative. The body is read again at decision time and held to what the request MEASURED: add the line, then push (or "Re-run all jobs") so the request job dispatches the counterpart's run — re-running only the decision job refuses, by name, a counterpart nobody measured. A malformed Breaks-plugins: line is refused by the request job before anything is dispatched. There is no label and no none form.

How it works

step where what
preflight dotnet-test.yml → Required CI inputs asserts the App credential RED by name
request dotnet-test.yml → Dependent suites (request) only once core's own build and tests are green on the candidate (or the tree is a reused green) — otherwise it is NOT sent and goes red as "waiting for core's own suites"; then it proves the receiver exists on Plugins' default branch and sends repository_dispatch core-candidate-suites with the candidate (github.sha), its first parent as the base and a key unique to the run attempt — once for Plugins' default branch, once more at a declared counterpart's head
scope Plugins core-candidate.yml → scripts/core-candidate-scope.py the per-realm, affected-only selection above, printed as an audit table (selected realms with edges, then not-selected realms); refused when it cannot compute one; legs cut by the portal-host lane's measured weights
candidate arm Plugins core-candidate-arm.yml (with MeshWeaver.Plugins#3187; before it, each leg builds its own suites from source) the union of the legs' suites built once, from source against the candidate (-p:MeshWeaverRoot, no image — the candidate is unpublished), shipped to the legs through scripts/dedupe-tree.py, and run there with --no-build; a one-leg arm builds in its leg; exit codes and dead hosts recorded exactly as the platform canary records them
control arm the same arm, at the base only what the candidate did not pass — so a test already red in Plugins against core main is reported and never blocks core
verdict Plugins scripts/core-candidate-verdict.py failure on drift (passes at the base, fails at the candidate; a host that dies only at the candidate; a leg that builds only at the base), on ANY missing evidence, or on a refused selection; a conclusion per realm; written as a root commit at refs/core-candidate/<key> in Plugins
wait dotnet-test.yml → Dependent suites (MeshWeaver.Plugins) → .github/scripts/await-dependent-verdict.py polls that ref read-only over REST once a minute and prints the queue census every 5 minutes; silence by the deadline is red on its OWN step (exit 3 → "No verdict in time … (infrastructure)"), whose title says WHY — "no runner within 42 min (queued behind N candidate runs: …)", "still running at the deadline", or "no run carries this key"
decide the same job → .github/scripts/check-plugins-break-declaration.py green, or a declared break that holds (table above), or red naming the realms and the line to write
require Consolidate test results NEEDS preflight, dependent-suites-dispatch and dependent-suites, and fails unless all three succeeded; a fork or Dependabot pull request prints NOT MEASURED by name

What core prints. This repository is public and MeshWeaver.Plugins is private, so the verdict carries counts, one sentence, the per-realm conclusions and the link to the Plugins run — never a test name, a suite name or an assertion. That is also why the suites cannot run in core's own workflow: its logs and artifacts are public.

What it costs, measured

The new selector, replayed over core's last 120 first-parent merges (origin/main at 3031ff5ae2, each diff against its own first parent; the core and Plugins trees at their current tips, so this is an estimate of the graph, not of each historical tree):

suites selected (of 84) merges share suite time per candidate (measured weights)
0 9 8 % none — the scope job alone (~30 s)
1–10 43 36 % ~22 min of suite time in ~2 legs
11–40 11 9 % ~1.7 runner-hours in ~6 legs
41–70 3 3 % ~2.2 runner-hours in ~8 legs
71+ 54 45 % ~3.4 runner-hours in 12 legs

No merge was REFUSED once the import graph and the build-file census were in (the first pass refused one — MeshWeaverModulePackageVersion.targets, which no build file names — and that is how the census rule came to exist). Plugins' test bases reference core's foundations (MeshWeaver.Mesh.Contract, test/MeshWeaver.Fixture, Memex.Portal.Shared), so almost half of all core changes genuinely reach almost every suite: that is the true affected set, not an over-selection, and Tests areas (144 in 41 realms) follow the same shape. Wall clock: the legs run in parallel, ~5–6 min from-source build plus ≤18 min of measured suite time each, so a full-size candidate answers in ~26–30 min after dispatch when the aks-silos pool has runners — inside the 45-minute cap. The request is sent only after core's own build and tests are green, and the waiter that follows it holds a 42-minute deadline: that window is the dependent's whole budget from the moment it was requested.

Runner capacity — requested after core's own suites, built once per arm, named when it waits

The gate became required on 2026-10-08 and within hours it was blocking core merges on runner capacity, not on verdicts. Measured that afternoon over REST (job timings, Plugins and core):

reading value
live candidate runs at 17:30Z 10 (one per open core PR), 4–12 legs each
candidate legs QUEUED for the shared aks-silos-dind pool at 17:30Z 48, beside ~30 queued Plugins-CI dind jobs; 31 dind jobs running
run 37809289143 (core #6320, 8 legs): Build this leg's suites … per leg 7.0–8.5 min on seven legs (1.8 min on the eighth) — the same core closure, eight times, ~58 runner-minutes
the same run: test steps per leg 0.9–11.2 min
core #6320 candidate legs done by 17:05Z; its one control leg queued from 17:08Z; the waiter gave up at 17:23:48Z ("No verdict in time")
candidate runs whose requesting core run had already COMPLETED 2 of 10 at 17:30Z (5 of 12 at 17:43Z), each still holding queued legs
MW_BUILD_QUEUE off — the CI queue's gate tier was bypassed, so candidate legs and Plugins-CI legs met GitHub's plain FIFO

Four changes answer it, none of them a longer wait (the 42 minutes and every cap stand):

  1. Requested after core's own suites. A candidate that fails its own build or tests cannot merge whatever Plugins says, so measuring it only took runners from the candidates that can. The request job needs build and test; when they are not green it is not sent and goes red BY NAME (Dependent suites NOT requested — waiting for core's own suites), and Consolidate test results repeats that sentence. Never a skip: a skipped required ancestor reads as satisfied.
  2. Built once per arm (MeshWeaver.Plugins core-candidate-arm.yml, landing with MeshWeaver.Plugins#3187 — until it merges, each leg still builds from source). A multi-leg arm builds the union of its legs' suites in ONE job — core from source once, copy-local as hard links — and ships each built suite's bin/ + obj/ through scripts/dedupe-tree.py, the portal-host lane's mechanism (one copy per unique file). The legs restore packages, prove every dll is present and evaluates IsTestProject=true (the two measured --no-build traps), and run. The per-leg build verdict is read off the union's per-suite outputs, so the verdict script and the control plan read exactly what they read before. A one-leg arm — the usual control arm — still builds in its leg, because a separate build job would only add a second runner wait.
  3. Named when it waits. The waiter's queue census reads only Actions metadata of core-candidate.yml runs (actions: read on the same App token): this candidate's jobs (running, waiting for a runner, done), its position among the live candidate runs and the keys ahead of it, and Plugins' total queued runs. It prints every 5 minutes and once more at the deadline, so a timeout says "no runner within 42 min (queued behind 4 candidate run(s): …) — waiting for a runner: Control / control (leg 1/1)" instead of only "No verdict in time". It never decides anything: an unreadable census is reported and the exit code is unchanged.
  4. A superseded candidate gives its place back. Each superseded core head already cancels its older candidate run through the concurrency group pr-<N> (cancel-in-progress), and a leg stands down when it starts and finds its core run finished. Between the two, a leg still QUEUED held its FIFO place — and with the request now waiting for core's own suites, that gap is longer. So every new candidate run's admission cancels the candidate runs whose requesting core run has COMPLETED (scripts/core-candidate-reap.py, also MeshWeaver.Plugins#3187; an unreadable status is never a reap). Its first live pass, at 17:43Z, cancelled 5 of 12 live candidate runs. 🚨 "Completed" turned out to be the wrong test — it is what made the gate livelock under load; the rule is now "superseded or answered" (next section).

Build-once, measured (the same candidate as #6320, cd3eb48d9e against 3fc485cadf, 8 legs and 46 suites; before = run 37809289143, after = run 37818559621 on MeshWeaver.Plugins#3187's branch):

before after
verdict success, 46/84, drift 0, 2 pre-existing identical
builds 8 in-leg builds, 54.4 min in total one build job: 10.1 min build + 1.3 min pack and ship
shipped build — 16,137 MiB raw → 1,423 MiB stored (one copy per unique file)
leg job times 8.6–16.6 min 2.6–12.8 min (setup: a 13–19 s restore instead of a build)
dind runner-minutes, candidate arm 101.4 66.2 (−35 %)
critical path with free runners 16.6 min (longest leg) 24.9 min (build job + longest leg)

So build-once trades about 8 minutes of latency on an idle pool for a third fewer runner-minutes, and those runner-minutes are what a congested pool is short of.

A late verdict still decides — no human step (the 2026-10-08 livelock)

The defect. The waiter holds a runner and a 42-minute window. Under load a candidate's dind legs wait longer than that for a runner, so the waiter gives up first ("No verdict in time") and the core run COMPLETES. Every Plugins-side "is anyone still waiting?" check read completed as nobody: the legs stood down when they finally got a runner, the reaper cancelled the run, and the Verdict job wrote "nobody reads this verdict". So under load no verdict was ever accepted — every core PR stayed red until someone re-ran jobs by hand, and each re-run met the same queue. Measured 2026-10-08: core run 37841893441 (#6337) — the waiter failed at 20:21Z; Plugins runs 37844107216 and 37843796980 got their control leg's runner later, found the core run completed failure and abandoned; 11 core PRs red, no core merge after 21:12Z. Raising the 42 minutes only moves the cliff.

The fix: a verdict that arrives after the waiter gave up re-runs the waiter. One rule decides who still reads a candidate's verdict — MeshWeaver.Plugins scripts/core-candidate-requester.py (classify), shared by the legs, the reaper and the Verdict job:

state when legs reaper when the verdict lands
waiting the waiter JOB has not finished — it still polls (the run's own status is not read: it stays in_progress after the waiter stopped polling) measure keep the waiter reads it itself; the Verdict job follows the waiter to its end
resumable the waiter finished WITHOUT reading a verdict — it failed on No verdict in time, or on an earlier step before reading anything — and, once the run completed, the PR is open, its head is still this run's head, and the request job's attempt is still the one that minted this key measure keep the Verdict job re-runs that waiter job
answered the waiter's DECISION step (Compatible, or a declared break?) ran — it read a verdict and decided, green or red stand down reap nothing
superseded run cancelled; PR closed, merged or moved on; request re-issued under a new key stand down reap nothing
unknown anything unreadable, including a finished waiter whose steps do not read measure keep nothing

After publishing refs/core-candidate/<key>, Plugins' Verdict for core job mints the meshweaver App token with actions: write on this repository and nothing else, and — when the request is RESUMABLE — calls POST /actions/jobs/<waiter>/rerun. That re-runs the waiter and its dependent, Consolidate test results. A POST that returned is not a re-run, and the run's status proves nothing either way (it can still read completed while GitHub applies the request, and a fast re-run may already be done), so the job reads back the waiter itself: a NEWER execution of it — a new job id or a higher run attempt, in any status — confirms the re-run; none within 5 minutes is RED. On the re-run the waiter's first poll finds the verdict, because the request job's outputs (key, candidate, base) are kept from the attempt that minted them. It validates the verdict as always and decides. The Verdict job follows the waiter to a terminal state, bounded at 20 minutes: a waiter still in its last poll when the verdict lands either reads it itself (answered) or ends on No verdict in time (re-run once the run completes — a re-run needs a completed run). Past that bound it goes RED and names the job to re-run.

What this keeps:

The reaper also runs only for a core-requested run (repository_dispatch, which always runs Plugins' default branch). A hand workflow_dispatch of an unmerged branch once ran a reaper with that branch's rules and cancelled live candidates (flagged on MeshWeaver.Plugins#3187).

The rule's self-test (core-candidate-requester.py --self-test, run in the scope job and before every resume) pins the four cases: a verdict before the deadline (nothing re-run), a verdict after the waiter gave up (exactly the waiter re-run, also when the run is still finishing), no verdict ever (refused, red), and a superseded head (ignored, legs stand down). A negative control flips RESUMABLE to SUPERSEDED by changing one input. It also pins the window's edge (a verdict landing while the waiter is in its last step is followed to the end, then re-run once), a waiter that failed before reading anything (resumable, never answered — the decision step having run is what flips it), and the read-back (a newer waiter execution in any status confirms; none is red). On core's side, await-dependent-verdict.py --self-test pins that a re-run's first look returns an already-published verdict without waiting: time.sleep is replaced by a recorder, so a regression fails at once instead of sleeping into the job cap, and a negative control shows the recorder does see a wait when the first look finds nothing.

The remedy for a timeout is therefore none. The no-verdict step says the gate turns itself green or red when the verdict lands. A person acts only when no candidate run is live for the key — for example one cancelled by hand. Then re-run Dependent suites (request) to request a fresh candidate under a new key.

Proven on the incident (2026-10-08)

Replayed through Plugins' core-candidate.yml (workflow_dispatch, the per-realm selector of MeshWeaver.Plugins#3168). The decision was then run with .github/scripts/check-plugins-break-declaration.py over the verdicts those runs wrote:

measurement Plugins at result
core #6297: candidate da827571e3 (its merge) against base c26b9401d9 #3168's head (without the GitSync fix) — run 37787136202 failure — 8 drifts, realm hosts: all 8 in MeshWeaver.GitSync.Test, which exits 2 at the candidate and passes at the base in the control arm. 81 of 84 suites and 144 Tests areas in 66 of 77 realms were selected; 11 realms were named as not selected; 3 more failures already exist at the base and do not block. GitSync.Test was pulled in by memex/Memex.Portal.Shared → src/MeshWeaver.GitSync. Dispatch to verdict: 47 min, with the control arm queued behind the full-size legs
the same candidate, with the counterpart #3168 + MeshWeaver.Plugins#3164 (the GitSync adaptation) — run 37787175604 success — drift 0; realm hosts 46 of 46 suites green; the same 3 base failures
a docs-only change: core #6234 (0e6cdd2a17, AGENTS.md only) #3168's head — run 37789249026 success — 0 of 84 suites, 0 of 77 realms selected, 77 named as not selected; 6.5 min from dispatch to verdict

The decision, over those real verdicts:

PR body PlatformVersion in the diff decision
no Breaks-plugins: line (what #6297 had) 3.0.0 → 3.0.0 RED: "this change BREAKS MeshWeaver.Plugins realm(s) hosts, and the PR body declares no break", with the line to write
Breaks-plugins: hosts — one-way-synced partitions refuse every non-system write … — counterpart Systemorph/MeshWeaver.Plugins#3164; semver: minor 3.0.0 → 3.1.0 GREEN: a declared break, with the counterpart green against this candidate and the minor bump in the diff
the same declaration 3.0.0 → 3.0.0 RED: "the declared MINOR bump is not in the diff"
the same declaration, after #3164 merged — RED: asks for a re-measure ("Re-run all jobs"); the default branch now carries the fix, so the main measurement must be green on its own

So the gate would have stopped #6297 with a red check naming the realm. Two ways through were left open: fix the behaviour, or land it as a declared, minor-versioned break together with #3164.

Proven live, both directions (the first build, 2026-09)

Run against branch ci/dependent-suites-controls through Plugins' workflow_dispatch before this gate was required:

control candidate selected verdict dispatch → verdict
positive — a comment-only change in src/MeshWeaver.Testcontainers 9801798b 1 of 83 success, drift 0 (run 36118343132) ~3.5 min
negative — the same file's ArgumentException message reworded, signature unchanged 62840cc7 1 of 83 failure, drift 1; the control arm re-ran the suite at the base and it passed (run 36117392480) ~9 min
full-size — core merge 202199cc2b 202199cc 80 of 83, 12 legs every suite exit 0; ONE .trx was empty, so the verdict was failure — missing evidence (run 36118353941) 26 min

The full-size run is the evidence the missing-evidence rule is worth having: dotnet test printed Passed! 14 for MeshWeaver.Serialization.Test over a 0-byte .trx — the xunit trx reporter silently writes an empty file when a test's output carries ANSI ESC — which every lane that only checks that a .trx EXISTS had been reading as a pass (fixed in MeshWeaver.Plugins#2377). Each from-source leg build took 5–6 minutes.

Why this is not the withdrawn dispatcher

The same shape (dependent-suites.yml, #3103) was withdrawn on 2026-09-03 because the context went red on every core pull request while Plugins had no receiver, and because it coupled the two trunks. The receiver now lands in Plugins FIRST and the request job refuses to send to a default branch without one; the control arm means a red that exists in Plugins' own main never blocks core; and it runs where landing happens rather than on every push. The coupling is real and stated: a core change that DELIBERATELY retires behaviour a Plugins test encodes needs a Plugins adaptation that passes against both behaviours, landed first — the expand-then-contract order.

Where its teeth stop

The dependent reacts to core's events — core never waits

Rule (maintainer, 2026-09-03): none of the top-level repositories depends on another, and the integration between them is event-based. Core emits; a dependent reacts in its own CI, and the red lands in the repository that owns the fix.

The event source is the MESH. The target shape (maintainer, 2026-09-03): memex issues an event that something has a new version — a platform build landed in Hosting/PlatformBuilds, a module bundle was published to the registry — and the GitHub repositories subscribe to it and trigger their builds. The emitter IS memex (since 2026-09-03): core's CD POSTs the signed build fact into Hosting/PlatformBuilds and finishes; the Hosting module's PlatformBuildInboxWatcher (MeshWeaver.Plugins) fans meshweaver-framework-released out to every repository the Hosting/Deployment records name as a registry source — data in the mesh, not a list in any workflow. Core's own notify-dependents dispatcher, the last CI-to-CI link between repositories, was withdrawn the same day, and PlatformReleaseNotifyGuard.CoreDispatchesToNoRepository refuses a /dispatches POST in any workflow core runs on its own behalf. Each dependent's ci.yml receives it, resolves its platform-ref to that release, builds its src/ and content against it, runs its suites and — only if everything passes — seals and publishes its bundles for that platform identity. A shape-1…7 break therefore surfaces as a red release-follow run in the dependent's repository, minutes after the platform published, with the dependent's own test names in the log, and it is fixed there by a pull request in that repository. Nothing in core polls, reads back or blocks on it.

🚨 …as long as the dependent's source lane actually resolves to the released commit. A PINNED dependent does not, and #3276 is the measurement. MeshWeaver.Plugins pins core deliberately (MW_PLATFORM_REF, its own #1255: a floating main reddened that repo three times in one day with no diff in any branch there), and its platform-ref job resolves the SAME pin on a repository_dispatch release event as on a pull request — the event changes what is baked, not what src/ compiles against. So the release-follow run rebuilds the dependent against the commit it already used, and a break introduced after the pin is invisible to it. It waits, silently, until a human bumps the pin — and then arrives as "my pin bump is red", which reads like the bumper's problem and is not.

That is not an argument for unpinning: the pin was bought with a real incident and it moves the break to a diff the dependent owns. It is an argument for reading the two halves as what they are — the event catches a break for a dependent that TRACKS; for a dependent that PINS, the pin bump IS the integration test, and the cost of a break is proportional to how long the pin sat. #3276 sat for 48 commits, and the whole cost was paid by the bisect that found it.

The contract (maintainer, 2026-09-03: "end of github pipeline must call memex, which must register release and publish event") is three sentences:

  1. Every publishing pipeline ENDS with one call to memex. Core's CD, after the image set is promoted, POSTs the signed platform build (event: platform-build) into the control instance's Hosting/PlatformBuilds inbox (notify-platform-update). Every node repository's node-repo-publish-bake.yml run, after its bundles are sealed for an identity, POSTs the signed publication record (event: bundle-publication — source, identity, commit, tester + portal image) into the same inbox (register-publication, its last job). Nothing runs after that call, and no pipeline sends a repository_dispatch to another repository.
  2. memex REGISTERS the release as a durable node — Hosting/PlatformBuilds/<version> for a platform build, Hosting/Publications/<identity>/<source> for a bundle publication — the source of truth for "what is published for which identity" (what the self-update availability check reads).
  3. memex PUBLISHES the event from that registration: FrameworkReleaseBroadcaster sends meshweaver-framework-released (platform) or meshweaver-upstream-published (bundle publication, client_payload.version = the identity) to the subscribed repositories — the repositories the control instance's Hosting/Deployment records name as registry sources. The subscribers' CI receives it, resolves both images from the version, builds and publishes for that identity — and ends by calling memex (1).
 pipeline (core CD | a node repo's publish-bake)        memex (control instance)              subscriber CI
 ───────────────────────────────────────────────        ────────────────────────              ─────────────
 promote / seal ✅                                       WebhookInbox Hosting/PlatformBuilds
   └─ ONE signed POST ──(platform-build |──────────────▶│ verify HMAC
      bundle-publication)… and FINISH                    ├─ REGISTER  Hosting/PlatformBuilds/<version>
                                                         │            Hosting/Publications/<identity>/<source>
                                                         ├─ subscribers = Hosting/Deployment records'
                                                         │              pluginRepos[].isRegistrySource
                                                         └─ PUBLISH   repository_dispatch ─────────────▶ on: repository_dispatch:
                                                            meshweaver-framework-released |               types: [meshweaver-framework-released,
                                                            meshweaver-upstream-published                        meshweaver-upstream-published]
                                                                                                          → bake for the version → seal → POST memex

Where the pieces are: the POST steps in main-cd.yml and node-repo-publish-bake.yml (this repo); the inbox watcher, registration and broadcast in the Hosting module's PlatformBuildInboxWatcher (MeshWeaver.Plugins, Hosting/Deployment/Source); the broadcaster in src/MeshWeaver.GitSync. PlatformReleaseNotifyGuard.CoreDispatchesToNoRepository refuses a dispatch SENDER in any workflow under .github/workflows — there is no ledger — and UpstreamBuildGateGuard.TheLaneEndsByRegisteringWithMemex_AndDispatchesToNobody pins the lane's call.

What this section's rule used to forbid outright — a context on the core pull request that only a plugin repository can turn green — now exists for exactly one question and one repository; the next section is why, and how it avoids the reasons the 2026-09-03 attempt was withdrawn.

The consequence the pair gate covers stays: a pull request that REMOVES public surface must name its merged counterpart. Since #3465 one ADDITIVE shape is covered here too — a member added to a public interface, which breaks IMPLEMENTERS rather than callers and which a pinned dependent therefore cannot see either (shape 10). Everything else additive or behavioural (shapes 2–5, 7 and 9) is the dependent's to catch when it next builds against the platform — on the event if it tracks, on the pin bump if it pins.

Proving it

Every script runs --self-test first in its job, and every job fails it:

Both directions, mechanically. Each new mechanism was sabotaged in turn and the self-tests were re-run, because a case that cannot fail proves nothing:

what was disabled surface cases that go RED
the BOM strip 2
the obligation classifier, forced to "never obliges" 4
…forced to "always obliges" 8
body detection (every member reads as abstract) 4
the declaration statement truncated to one line 3

The control arms are publicTypesAtBase (the pair gate refuses a base tree declaring fewer than 500 public top-level types; src/ declares 1 951 across 35 assemblies today) and, for the tenth shape, publicInterfacesAtBase and implementerObligationsAtBase (floors 50 and 150; 131 and 450 today). Every other field of the surface report is legitimately empty on an ordinary pull request, so without them "this diff changed nothing" and "the scan read nothing" would produce the same JSON.

🚨 A floor catches "read nothing". It cannot catch "reads LESS than it did" (#3492)

Those three floors are the right instrument for the question they ask, and they were never able to ask this one. Measured on origin/main:

control arm floor measured slack
publicTypesAtBase 500 1 971 ~1 450
publicInterfacesAtBase 50 132 ~82
implementerObligationsAtBase 150 452 ~302

The slack is deliberate — a floor must survive a carve-out wave without going red. It is also exactly what a UTF-8 BOM walked through. Three bytes before the first character defeat both ^-anchored matchers:

namespace MeshWeaver.Layout;      NAMESPACE_RE is `^namespace` — no match, so the file's
                                   types are keyed with an EMPTY namespace…
public class ButtonControl        …and TYPE_RE's indent group is `[ \t]*`, which U+FEFF is not

re.match(r"\s", "\ufeff") is None and "\ufeff".isspace() is False, so neither pattern forgives it. 310 of 1 271 .cs files under src/ carry a BOM. The damage splits in two, and the halves fail differently:

That is 0.9 % of the types and 0.8 % of the interfaces: far inside every floor's slack.

How long, and what it cost — both measured rather than assumed. The blind set was 140 types (16 + 124), byte-identically the same set, at the gate's first commit (51adbeef3, #2689) and 754 commits later. The detector was born blind. Whether anything escaped was then answered two ways, because one was not enough:

measurement result
truth(51adbeef3) − truth(HEAD) — public types that left src/ across the window 0
every --diff-filter=DR event on src/**/*.cs in the window, re-parsed at its parent 6 file events, 0 carrying a BOM, 0 blind
every in-place modification (460 file-commits): a BOM'd file losing or renaming a line-1 declaration, or a namespace rename the gate saw identically on both sides 0
src/**/*.cs files that gained or lost a BOM (the one edit that can move a key) 1, and it moved nothing — RegistryUpdateReconciler.cs line 1 is using System.Reactive;, so the BOM had hidden neither a namespace nor a declaration

🚨 The endpoint comparison alone would have been wrong to trust, and that is the part worth keeping. It cannot see a type born and removed inside the window — and that happened: 780beff47 (#3361) renamed the whole MeshWeaver.ContainerRegistry assembly to MeshWeaver.ContainerImages, and the assembly exists at neither endpoint, so seven public types changed namespace invisibly to an endpoint diff. Same shape as the defect being investigated: absence of a match read as absence of a subject. The per-event pass is what closes it.

Verdict: zero escapes, and not by luck — no public type left src/ at all in the 754 commits the gate has existed. The gate was blind in 140 places and had not yet been asked the question it would have answered wrongly. A reprieve, not a defence.

The control that generalises: a DIFFERENTIAL over a fixed corpus

scripts/check-parser-delta.py runs the merge base's own copy of the detector and this diff's copy over the same tree (the merge base), both in --surface-json report mode, and compares the denominators.

detector at the merge base  ─┐
                             ├─→  ONE tree  ─→  two sets of denominators  ─→  delta
detector in this diff       ─┘

The corpus is byte-identical for both runs, so every difference is attributable to the parser and to nothing else. The codebase can grow, shrink or be refactored without moving the number: the control is invariant under code change by construction, and moves only when the detector's behaviour moves — which is its subject. That is what a floor cannot be, because a floor is a statement about the codebase.

The verdict is deliberately asymmetric, because the directions mean opposite things:

delta meaning verdict
negative the detector sees less of the same tree; every gate downstream now guards a smaller set RED, unless declared
positive the detector sees more of the same tree — a fix printed loudly, passes
zero nothing changed printed with both numbers, so a pass says something

An intended tightening declares itself in the pull-request body, in the shape Pairs-with: already established:

Parser-delta: publicTypesAtBase — nested records were counted as top-level; they are never
independently bindable by simple name, so the old number was wrong upward.

The declaration names the counter and a reason, never the number — the corpus is the merge base, which moves while a pull request is open, so a written figure would go stale and train people to edit it without reading it. It is also per counter: declaring publicTypesAtBase does not silence implementerObligationsAtBase.

🚨 No skip-trapdoor. The step runs unconditionally and asks nothing about its own inputs. A detector that exits non-zero, or exits 0 and writes no report, is a failure naming which side — never a zero delta. The one path that returns 0 without running anything is a proof: when every subject file is byte-identical between the merge base and the diff, the delta is zero by construction, and the two SHA-256 digests are printed to show it.

Both arms, measured on the real repository rather than argued:

arm result
--self-test 13 cases, 0 failed — including a stub reason, a declaration naming the wrong counter, a withdrawn counter, a counter that stops being an integer, a detector that exits 3, and one that exits 0 writing nothing
base cac18fd9c (pre-#3487) vs merged main publicTypesAtBase +16, publicMembersAtBase +111 — #3487's lstrip, reproduced as a positive delta over an identical tree, and passed
base merged main vs a working tree with that lstrip reverted RED, exit 1, on all four counters: -16 types, -111 members, -1 interface, -1 obligation

The third row is the one that matters: the control was watched failing on the reintroduced defect, not merely reasoned about.

What it deliberately does NOT cover. check-record-signatures.py shares this family's ^-anchored parsing and had the same BOM hole (fixed in #3487; exposure was zero files at the time). It is not gated here, for a structural reason rather than an oversight: it scans the files this diff changed, not a whole-tree index, so it publishes no denominator that could shrink. Its blindness shows up as a missed finding on one file, which a differential over a fixed corpus cannot see. Giving it a whole-tree index would be a real change to that gate, not a wrapper around it.

🚨 An empty inline body made a MARKER interface adopt the next type's members (#4449)

The detector located a type's body by testing the declaration line for a trailing {. A body written as { } on the declaration line matches neither that nor the ; terminator, so the scan walked past it and adopted the next type's brace as its own:

public interface IOwnerEnforcedNodeValidator { }   // ← body opens and closes here; not seen

public record NodeValidationContext                 // ← its body was indexed as the INTERFACE's
{
    public required NodeOperation Operation { get; init; }
    …
}

Every member of NodeValidationContext was therefore indexed under IOwnerEnforcedNodeValidator — where members are public-by-default and oblige an implementer — so adding one ordinary property to the record reported IOwnerEnforcedNodeValidator.PartitionOwnership as an implementer-obliging addition and failed Interface additions (implementers declared) on a member that is not on an interface at all. The same mistake pointed the other way too: the record scanned as carrying six members it did not own, and a marker interface as carrying six obligations that do not exist.

Why it is worth writing down rather than just fixing. This is the failure direction a gate cannot survive twice. A false NEGATIVE lets one break through; a false POSITIVE that no author can fix on the code side teaches everybody that the gate is noise, and the next real finding is read as noise too. The documented escape — Implementers: IFoo.Bar — … — would have been a true sentence about a member that does not exist, and the phantom attribution would have stayed.

The body is now located by the line's brace BALANCE: > 0 opens a body that stays open, == 0 with a brace present is a body that already closed. Two guards keep the fix honest:

The differential control is what measured it. check-parser-delta.py scored the fix at publicMembersAtBase −6 and implementerObligationsAtBase −6 over an identical tree, and required both to be declared in the pull-request body. Those six are exactly NodeValidationContext's members at the merge base — the phantoms, counted. A control designed to catch a detector losing sight of something proved equally good at pricing one that had been double-counting, and the declaration it demands is what keeps the two apart in the record.

How it landed: through the queue, on a ZERO delta — by removing the construct first. A declared decrease is honoured on pull_request and refused on merge_group, where the rule is strict because a queue entry has no single pull request to read a declaration from. So the fix, green on its own run, could not pass the queue while the merge base still held the construct the OLD detector misreads. The −6 was irreducible over that tree, not over every tree: #4572 wrote IOwnerEnforcedNodeValidator's empty body on its own lines — formatting only, and invisible to this control because the detector did not change — after which both detector versions agree on main's tree and the fix measured +0 on all four counters. The phantoms left the index with the construct, the detector fix then landed through the queue with no change to the rule and no one-time exception, and its self-test still pins the inline shape, so the fix stays guarded.

That is the general recipe for a parser tightening the queue refuses: when the shrink is phantoms a SOURCE construct causes, land the source change first (the surface gates see the phantoms leave and ask for a Pairs-with: none reason, which is honest), then land the detector change over a corpus on which old and new agree. When no source construct can be moved — a detector genuinely seeing less of a tree that cannot change — the strict rule is doing its job.

🚨 A gate added to main reaches NEITHER build of a pull request already green (#3508)

The control above landed correct and non-vacuous, and then did not run on the one pull request that night which actually changed the parser (#3503). Not because it was wrong — because of where it was wired. A pull request has two builds, and a gate added afterwards misses both:

  1. Its own pull_request run executes the workflow file as it stood on the merge ref at the time it ran. A job or step added to main later is simply not in that file. Re-running the run does not help: it re-runs the same file.
  2. Its merge_group run — the one build that sees the change at the moment it lands — skipped the entire cross-repo-pair job, whose if: is pull_request only, for the perfectly good reason that its other input is the pull-request BODY.

Both exits close on the same pull request, so a gate lands and every pull request already green at that moment is permanently exempt from it, silently, and invisibly from the pull request. On #3503 the comparison happened to have been run by hand and written into the body — authorship luck, not a property of the system.

Measured on the queue run for #3546 (gh-readonly-queue/main/pr-3546-…, run 34093021863):

job on merge_group
Public surface (binary compatibility) (record-signatures) completed/success
Cross-repo pair (public surface) completed/**skipped**

The fix is placement, and only exit (2) can be closed. Exit (1) is structural: a build executes the file it was cut from, and nothing changes that. So the parser-delta control moved into record-signatures, the job that already runs on pull_request and merge_group — and which carries no fork or dependabot clause either, so the move closes those two exits as well.

The strict/declaring split is written on the EVENT, the only exemption AGENTS.md sanctions, and it is the same shape the two gates beside it already use for --pr:

event form why
pull_request --pr-body-file supplied the body is where an intended decrease is declared
merge_group no body — STRICT: any decrease is refused a queue entry has no single pull request (its ref can carry several), and nothing should shrink the detector at the moment it lands

Every build gets exactly one of the two; there is no path on which neither runs, which is the whole property #3508 is about. The one thing to know before declaring a decrease: it is honoured on the pull request and refused in the queue, so a genuine parser tightening lands only if it does not shrink a published counter. That is a deliberate trade — the queue is where the change becomes main, and a declaration read there would have to be attributed across a multi-entry group that carries several pull requests' diffs at once.

How the move was falsified (a gate that cannot fail is not a gate — the acceptance test the issue names is the point of the issue):

arm result
clean tree, merge_group exit 0 on the byte-identical proof, both SHA-256 digests printed
a planted blindness in the detector's git-tree reader (MeshWeaver.Layout/ skipped), merge_group exit 1 — publicTypesAtBase 1975 → 1710 (−265), and −1817 / −11 / −46 on the other three
the same plant, pull_request, empty body exit 1, identical verdict
the same plant, pull_request, body declaring all four counters exit 0 — ↓ DECLARED in the pull-request body, so the declaring arm is not lost

Each row executed the step's own run: block extracted from dotnet-test.yml, not a restatement of it.

The sibling shape is #3504: a repository that hand-rolled a lane opts out of every guard that lane grows later. Same defect one level up, and invisible from the side that is uncovered.

See also

Repository Dependency Direction · Reading CI Signals · Shared Rule Blocks · Carving Projects Out Of Core · Module Versioning