The image lanes track core main — DECLARED, not defaulted

⚠️ Amended 2026-09-09 (MeshWeaver#3842). ci.yml no longer holds a frozen pin — it resolves the newest SEALED set per run (PlatformResolution) — and the bounded-gap gate (check-platform-pins.py --check-image-gap, the third guarantee below) is deleted with it: a bound that reddens when time passes is the failure that policy forbids. The declaration, its fail-closed resolver and the recorded commit stay, and check-image-platform-ref.py still enforces them.

MeshWeaver#3259. This repo reaches for the platform under two identities that are chosen from different refs at different moments, and until this change nothing in the tree said so.

lane how it resolves the core half value, measured 2026-09-04
ci.yml — every gate ${{ vars.MW_PLATFORM_REF \|\| env.MW_PLATFORM_REF }}, the env default a literal sha a frozen pin, with a fail-closed resolver job (#724)
portal-ai-image.yml ${{ vars.MW_PLATFORM_REF \|\| 'main' }} core main, resolved at the instant the step ran
portal-next-image.yml same same
log-watcher-image.yml same same

And vars.MW_PLATFORM_REF is genuinely unset — the repo carries only BAKE_PUBLISH_TARGETS, MW_TEST_IMAGE and PLATFORM_WEBHOOK_URL. So the || 'main' half of an expression, written at the point of use, decided what every published image carried, and was the only place that said so. Every gate in ci.yml judged a pair that is not the pair the images ship.

What that cost

ci.7658 shipped an image pairing a core half predating MeshWeaver#3206 with a Plugins half already carrying #1263's refusal — a pair that was never built or tested anywhere. It existed for the first time in production. Attributing it afterwards took a cross-repo comparison, because no lane had written down the core commit it built against. Core records the incident in Doc/Architecture/ImagePairSkew (MeshWeaver#3247).

The divergence is INTENDED and it stays

The obvious-looking fix — point the image lanes at ci.yml's pin — is not what landed, and it is not an oversight that it did not.

There are THREE core-ish pins, and all three may legitimately disagree

This is the part a later reader's instinct will want to "fix". Do not.

# pin what it chooses
1 uses: Systemorph/MeshWeaver/.github/workflows/<lane>.yml@<sha> which definition of a reusable workflow runs
2 MW_PLATFORM_REF which core commit the run builds and tests against (Portal hosts checks core out at it)
3 MW_IMAGE_DIGEST / MW_PLATFORM_SET the platform image the repo compiles against

Plus, since this change, a fourth declared fact: MW_IMAGE_PLATFORM_REF, the core the images are built from.

Demonstrated, not theorised. #1268 moved MW_PLATFORM_REF and both uses: refs together from b88be8a05 to c41a34fd, and it went red. core#3268 was a change to the workflow definition, so only the uses: refs needed it; advancing MW_PLATFORM_REF dragged the core source forward with it (b88be8a05..c41a34fd = 48 commits — name the endpoints when citing a range; b88be8a05..9acbbb39 is 2, and both are right for their own range) and broke two tests. The landed fix, 700a83ac, deliberately splits them: uses: on c41a34fd, MW_PLATFORM_REF back on b88be8a05, check-platform-pins.py green on the split.

Pin 3 has its own reason to move independently: a superseded-image-assemblies: declaration is only meaningful while the pinned image still defines the moved types, and moving the image pin is precisely what retires it.

So check-image-platform-ref.py is indifferent to every pair of them. It never reads a job-level uses:, never reads MW_IMAGE_DIGEST/MW_PLATFORM_SET, and never compares MW_IMAGE_PLATFORM_REF to MW_PLATFORM_REF. Its self-test carries #1268's split as a control that could have falsified that claim: a fixture with all three pins on different values and the image lane on main, which must pass. It also identifies the pinned lane by the fact that it declares MW_PLATFORM_REF, not by how it wires the resolver — otherwise rewiring ci.yml would reclassify it as an image lane and demand a declaration it should not have.

The value being added is that each fact is stated and fails closed when unset. Never that any two of them match.

What changed

|| 'main' is the silent default antipattern: a fallback that changes what ships with nobody declaring it. Three things replace it, in each of the three image lanes.

  1. A declaration. A workflow-level env: MW_IMAGE_PLATFORM_REF: main, with the reasoning beside it. A workflow env is visible and reviewable in the repo; a repo variable is not — which is the whole reason the unset vars.MW_PLATFORM_REF could stand in a comment as the thing that decides the answer while deciding nothing.
  2. A fail-closed resolver. A first step, id: platform-ref, reading ${{ vars.MW_PLATFORM_REF || env.MW_IMAGE_PLATFORM_REF }}, which prints the ref and where it came from and exits 1 when it resolves empty. The checkout then reads that step's output, so the resolver cannot be routed around. It carries no if: — a gate must never be conditional on its own input, because GitHub paints a skipped step with the same tick as a passed one. The repo VARIABLE still wins, so one variable freezes the whole repo — gates and images together — with no PR.
  3. A record. Each lane resolves its checkout to a concrete 40-character commit, asserts the shape, and writes it into the job summary alongside the declared ref and its source. Nothing anywhere recorded this before; it is what any later comparison of the shipped pair has to read.

The enforcement

Prose that asserts an invariant nothing checks is not an invariant. scripts/check-image-platform-ref.py runs in validate (self-test first, so a refactor cannot make it vacuous) and fails RED on: a re-grown quoted-literal fallback on any platform checkout, a missing or empty declaration, two lanes declaring different values, a checkout that bypasses the resolver, a resolver that gains an if: or loses its emptiness test, a resolver where the variable no longer overrides the declaration, a resolver or a gate that gains a continue-on-error:, a lane that stops recording the commit it built, and — since option (c) below — a lane that stops bounding the gap between that commit and ci.yml's pin. It also fails when it can find no platform checkout or no image lane at all — a guard whose subject has moved passes having checked nothing.

The gap between the two pairs is BOUNDED — MeshWeaver#3259, option (c)

Recording the commit says which core an image ships. It does not say whether anything ever judged that core. ci.yml pins a different one and every gate in this repo judges that pair, so the further the two drift, the more of the shipped pair has never been built or tested anywhere. ci.7658 is what the far end of that looks like.

So each image lane now runs, before it can push anything:

- name: The core this image ships is not far from the core ci.yml tests
  env:
    GH_TOKEN: ${{ github.token }}
    PIN_OVERRIDE: ${{ vars.MW_PLATFORM_REF }}
    BUILT: ${{ steps.platform-sha.outputs.sha }}
  run: |
    python3 scripts/check-platform-pins.py --check-image-gap \
      --built "$BUILT" --pin-override "$PIN_OVERRIDE"

It bounds the gap. It does not abolish it

This is the distinction the whole design turns on, and the one a later reader's instinct will want to erase.

One bound, not a second one

The thresholds are MAX_LAG_HOURS = 24 and MAX_COMMITS_BEHIND = 120 in scripts/check-platform-pins.py — the same two constants --check-staleness already applies to this repo's two frozen platform inputs (#1264), reached through the same _distance_problems judgement. The workflow passes neither, so the script is the policy. Two different bounds for one question would be its own defect, and widening one to make a run pass is the band-aid this gate exists to make unnecessary: the pin can always move instead.

The judgement gained one field to do this — StalenessSubject.tip, the far end. The two staleness arms measure a frozen input against core main; this arm measures the tested pin against a commit one image really built, which is core's tip only at the instant that lane ran. It defaults to "core main", so the older arms' text is byte-identical.

Why --pin-override exists

vars.MW_PLATFORM_REF overrides ci.yml's pin and all three image lanes' declaration at once. When it is set, both halves follow one ref and the gap is nil by construction — so the gate is handed the run's own view of the variable rather than reading the file. Measuring the file's literal while a variable drove the run would be a gate reporting on a platform nobody built, which is the same defect it exists to catch.

The trapdoors it does not have

Its only external input is GITHUB_TOKEN reading a public repository, so there is nothing to provision and therefore nothing to ask whether exists; its other input is produced two steps earlier by a step that already fails RED when the platform checkout did not resolve. It carries no if: and no continue-on-error:, and it runs before ACR login, so a refused pair costs no image. Every path that could otherwise only be seen inside an image lane — no token, an empty --built, a --built that is a branch name, a core that refuses, a pin core does not know, a built commit core does not know — is RED, and each is a self-test case.

The guard on the guard

scripts/check-image-platform-ref.py gained rule (7): every image lane must carry the gate, reading the recorded commit through steps.<recorder>.outputs.* (a literal, or another step's output, is refused — a grep hit is not a binder), passing ${{ vars.MW_PLATFORM_REF }} to --pin-override, carrying no if: and no continue-on-error:, and appearing strictly before the first step that logs in to, pushes to, or publishes into a registry. A lane that no longer publishes at all fails too: the ordering rule would otherwise bind to nothing. Rule (5) gained the same continue-on-error: refusal on the resolver — the twin of its if: trapdoor.

🚨 A SUBSTRING IS NOT AN INVOCATION

The first draft of rule (7) matched --check-image-gap as text, and the Copilot review on #1346 found two ways through it. Both were reproduced before being closed:

So the invocation is now parsed: the mode must appear bare, exactly once, with no = form, no threshold override, and no second mode — over a body with whole-line # comments stripped, since a commented-out flag neither runs nor counts. Each shape is a mutation case in the self-test.

The ordering rule had the mirror of the same defect: index > first_publishing_step passes on equality, so a single step that pushes and then calls the checker satisfied it while the bytes had already left. It is now strictly <, with a same-step mutation case.

It was watched failing

What this does NOT do — stated plainly

It DETECTS a large gap; it PREVENTS nothing. The class of fault that took production down — a core/Plugins pair that was never built or tested anywhere — is bounded now, not eliminated:

Operating it

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.