The Platform Compatibility Ladder

Rule — policy platform-backwards-compatibility: platform builds within one major and one compatibility epoch are backwards compatible. Plugin bytes built against platform N run unchanged on platform N+1. Only a declared epoch bump breaks that; a plugin whose floor (the platform it was produced on) is newer than the running platform is declined loudly. The compatibility is proven on every platform build, never assumed.

The ladder

Each step changes exactly ONE side:

Rung Platform Plugin What happens What must hold
1 P1 p1 the plugin was built on the platform it runs on types, serves
2 P2 p1 the platform rolls; the plugin's OLD bytes are kept — no rebuild, no re-seal every p1 reference resolves on P2; p1 types, serves
3 P2 p2 the plugin is rebuilt against the RUNNING platform p2's floor is P2 ≤ running
4 P2 p3 another plugin revision, same platform as rung 3
5 P3 p3 the platform rolls again as rung 2

Rungs 2 and 5 are the ones a platform change can break, and they are what the checks below measure. Rungs 3 and 4 are the plugin repositories' own builds against the newest sealed set (the running platform) — a plugin is never built against a platform newer than the one it will run on.

Floor, ceiling, epoch — the bounds of a plugin's validity

The key and the fields are introduced by the compatibility-key change (MeshWeaver#5672); this page states what they mean for the ladder.

The break declaration — machine-checked, never implied

A deliberate break bumps "epoch" and appends one entry to "breaks":

{
  "epoch": 2,
  "breaks": [
    {
      "epoch": 2,
      "previousEpochCeiling": "3.0.0-ci.9400",
      "reason": "why the break cannot be a forwarder",
      "declaredIn": "#1234",
      "members": [ { "assembly": "MeshWeaver.Mesh.Contract", "member": "MeshWeaver.Mesh.MeshNode::Frobnicate" } ],
      "affected": [ "MeshWeaver.AI" ]
    }
  ]
}

members[].member is the full type name (Ns.Type) or Ns.Type::Member, exactly as the check names it. The pull-request check holds the declaration to the measurement, both ways:

The check finds The epoch Verdict
no break unchanged ✅ green
a break unchanged 🔴 red — restore the member (an [Obsolete] forwarder keeps the old signature), fix the binder, or declare
a break, every broken member listed under the new epoch bumped ✅ green — the log names each plugin that must rebuild
a break, some member NOT listed bumped 🔴 red — naming the undeclared members
no break bumped 🔴 red — a gratuitous bump forces the whole fleet to rebuild
anything moved backwards 🔴 red
anything the base declares an epoch, the candidate carries no declaration 🔴 red — the declaration may not be removed
a declared member under a different assembly than the break bumped 🔴 red — both halves of an entry must match
an assembly binding conflict (a higher version than the platform carries) any 🔴 red — not declarable; it is the loader refusing the bind

🚨 A red here is answered by fixing compatibility or declaring the break — never by a seal, a pin, an identity gate or a rebuild-everything fallback.

The checks, and what each one proves

dotnet-test.yml job platform-compat, a need of Consolidate test results with an explicit fail step there, so a red ladder blocks the merge (skipped or cancelled is not a pass; the one exemption is the already-green-tree reuse path, as for every gate on that check).

Measured when it was written, against main-cd run 36042267665's bundles: 4 module assemblies, 430 type and 850 member references checked, 0 missing.

2. On every promoted image — Compatibility: newest deployed plugin set runs on this platform (ladder)

main-cd.yml job platform-ladder-compat: the baseline bundles (the set the fleet ran before this promote, verified by satellite-compat-image) linked against the promoted portal image — the full portal closure, including what the portal host adds. No baseline ⇒ red (the baseline IS the subject here). Additive: it gates no later CD job.

3. The break catalogue — ModulePlatformMemberLinkTest

test/Memex.Portal.Shared.Test/ModulePlatformMemberLinkTest.cs builds a platform assembly P1 and a plugin against it with Roslyn, mutates the platform in exactly one way (P2), and asserts TWO columns: the static verdict, and the ground truth — the plugin's P1 bytes loaded next to P2 in a collectible load context and executed. The static column may never be greener than the runtime one.

Platform change (P1 → P2) Caught by Static verdict At run time
removed public type type half Unlinkable, type named TypeLoadException
renamed / moved namespace type half Unlinkable, type named TypeLoadException
removed member member half Unlinkable, Type::Member [signature] (Assembly) MissingMethodException
changed parameter type member half (signature) Unlinkable MissingMethodException
optional parameter appended (source-compatible!) member half (signature) Unlinkable MissingMethodException
changed return type member half (signature) Unlinkable MissingMethodException
static ↔ instance member half (signature header) Unlinkable MissingMethodException
member made non-public member half (accessibility) Unlinkable, "no longer accessible" MethodAccessException
member made protected (still reachable from a DERIVED plugin type, which stays Linkable) member half (accessibility, in the caller's derivation context) Unlinkable MethodAccessException
type made non-public type accessibility Unlinkable, "no longer public" TypeAccessException
field turned into a property member half Unlinkable MissingFieldException
init setter turned into set member half (IsExternalInit modreq) Unlinkable MissingMethodException
constructor re-signed member half Unlinkable MissingMethodException
member of a generic type re-signed member half (via the instantiation) Unlinkable MissingMethodException
enum → string constants, no forwarder member half (the enum-typed signature) Unlinkable MissingMethodException
interface member added without a default, on an interface a plugin implements obligation half Unlinkable, "does not implement" TypeLoadException when the plugin type loads
interface member re-signed (same name and arity) on an interface a plugin implements obligation half (exact signature) Unlinkable TypeLoadException
static abstract interface member added obligation half Unlinkable TypeLoadException
abstract member added to a class a plugin derives from obligation half Unlinkable TypeLoadException
base class a plugin derives from made sealed obligation half Unlinkable TypeLoadException
member / overload / type added — Linkable runs
member moved to a base class member half walks the hierarchy Linkable runs
interface member added with a default implementation — Linkable runs
virtual member added — Linkable runs
renamed with an [Obsolete] forwarder under the old signature — Linkable runs
type moved to another assembly behind [TypeForwardedTo] forwarder followed Linkable runs
minor version bump (3.0.0.0 → 3.1.0.0) version half: roll-forward Linkable + advisory runs
added overload — source side not a binary break Linkable runs; the plugin's rebuild (rung 3) hits CS0419 on a parameterless <see cref> — fix: spell the cref with its parameter list
behaviour change behind an unchanged signature nothing static can Linkable runs — differently

The in-class negative control runs the removal case with the member half OFF and requires Linkable; with the member walk's one reporting line neutralised, six of the member-shaped break cases went red (demonstrated when the suite was written, then restored).

4. The decision itself — PlatformLinkDecisionTest

test/MeshWeaver.PluginTester.Test/PlatformLinkDecisionTest.cs pins every row of the declaration table above, and that the declaration file is read strictly (absent = none yet; malformed = an error, never "no breaks").

What these checks cannot see — stated so they are never read as covering it