The platform is resolved at run time — no pins
We must not pin anything. It must be tolerant. — platform owner, 2026-09-09 (MeshWeaver#3842)
This page supersedes the pin mechanics in PlatformBuilds (the six image pins), PlatformSourcePin (the seventh, the core source ref) and the bounded-gap arm of ImagePlatformRef. Those pages stay as the record of what a pin cost; this one is what runs.
What went wrong, measured
Until 2026-09-09 .github/workflows/ci.yml in MeshWeaver.Plugins named the platform in seven
literals and six lane refs: MW_PLATFORM_REF (a core sha), MW_PLATFORM_SET (a promoted-set
name), MW_IMAGE_DIGEST and MW_PORTAL_IMAGE_DIGEST each copied onto both module-pack calls, and
uses: …@<sha> on every reusable lane. scripts/check-platform-pins.py held the copies together and,
since 2026-09-03, reddened main when the set fell more than 24 h / 120 commits behind core.
| when (2026-09-09) | what |
|---|---|
| 12:41:44Z | Plugins main push run for 4c4019b46: FAILURE — "Repo policy gates: 2 staleness problem(s)". Core had released; the hand-maintained pin was now stale by the gate's own bound. |
| 12:41Z → 15:30Z | Every run on main failed the same way. The registry kept serving the red HEAD to live portals. |
| 15:30Z | A human re-pinned to 3.0.0-ci.8195 (Plugins#1560, 2a5f1e37). Green again. |
A gate that turns the trunk red because time passed is not a gate on this repo's code, and while it is red the fleet is served from an unvouched HEAD. That is the exact failure the rule above forbids, and it had already happened on 09-06, 09-07 and 09-08 (134, 147 and 133 commits behind).
What runs now
Nothing in the workflow names a platform build. One job — platform-ref, Resolve the released
platform — runs scripts/resolve-platform.py on every run and every other job takes its answer
from needs.platform-ref.outputs.*:
| output | consumers |
|---|---|
sha — the core commit the sealed set was cut from |
every Systemorph/MeshWeaver checkout in ci.yml (portal hosts, type-forward gate, drift check, RN protos, template, the inline compile gate's compile-check.py), and the platform-ref: of both module-pack calls (they BUILD against core source) |
image-digest — the tester image (mw-plugin-test) |
compile-check (framework assemblies from /app), the Tests-area gate, both module-pack tester-image-digest: inputs, the bake |
portal-image-digest — the portal image (memex-portal-ai) |
both module-pack platform-image-digest: inputs, the gate and the bake (the reference set, MeshWeaver#3071) |
set — e.g. 3.0.0-ci.8208 |
the run summary, so a red says which platform it saw |
How it resolves. Core's main-cd.yml runs on main, newest first, through the public GitHub
API with the run's own GITHUB_TOKEN. A run is sealed when Promote: tag the full set,
Verify every image shipped and Bake platform content in the shipped image + publish all
succeeded — the three jobs that make a set a release rather than a build. The newest sealed run
wins only when its final platform-bake publication receipts also identify one source commit and
release. The workflow run's head_sha describes workflow execution and can differ from the
commit selected by its workflow_run trigger. The receipt's actual release names the version
tag; its source commit names the seven-character identity tag used if version tags are absent.
The two images are resolved in ACR with the pull-scoped credential CI already holds.
🚨 …except on a pull request, which takes the newest sealed set THIS repo's main has already
passed (Roland, 2026-09-12; --passed-on-main, wired in ci.yml for pull_request and
merge_group only). main, the release dispatch and the daily poll keep taking the newest sealed
set — that is what makes main the place a regression shows up. The failure mode is the decision:
a set that regresses this repo reds main alone, instead of reddening every open pull request at
once for a reason no author's diff can reach. The ceiling is read back from the
Platform for this run notice of the newest successful push runs on main, the resulting lag is
printed with both set ids, and a ceiling that cannot be established is RED rather than a silent
fallback to the newest set. The measurement that produced the rule, and its cost, are in
PullRequestDrain.md → Which platform set a pull request resolves.
Every lane floats on @main. GitHub forbids an expression in uses:, so the workflow
implementation follows main. The input roles are separate: scripts-only validate, supersede,
Tests-area gate and publish-bake calls pass scripts-ref: main; the two module-pack calls pass
platform-ref: ${{ needs.platform-ref.outputs.sha }} because they build against core source.
The gate and bake receive the resolved image digests independently of their script revision.
This keeps newly added guard scripts available without changing the platform being tested.
scripts/check-shared-lanes.py checks every applicable call's own inputs. Literal platform refs
and pinned workflow refs are violations; auto-arm has neither platform nor scripts inputs and is
the explicit exception.
Tolerance — what each case does
| case | behaviour | where it is said |
|---|---|---|
| the newest run is green but published nothing (Promote/Verify/bake skipped — core CD 8197 was such a run) | passed over; the next-newest sealed run is taken | the job log (skip main-cd #N: NOT sealed — …) and the summary's chosen because row |
the newest run is still sealing (the release dispatch fires the moment promote finishes, minutes before the platform bake seals) |
on repository_dispatch / schedule the resolver waits up to 15 minutes for it; on a push or PR it is passed over and the next run follows it |
the log, every 30 s while waiting |
| the newest set's images are gone (retention purge, MeshWeaver#3438) | passed over — neither the version tag nor the identity tag resolves — and the next sealed set whose images exist is taken | the log names the image and both tags |
| the newest run was cancelled or failed before any seal job ran (superseded by a newer commit, a build leg red) | passed over, with the run's own conclusion as the reason | the log |
| nothing resolves — no sealed run in the newest 300, the API unreachable, the registry credential refused, core renamed a seal job | the platform-ref job is RED with a message naming what was looked for and every skip; every gate behind it fails on needs.platform-ref.result rather than skipping |
the job's ::error, the summary |
the repo VARIABLE MW_PLATFORM_REF is set (a core sha or X.Y.Z-ci.N) — the freeze, for a bisect or an upstream outage |
that one run is taken; if it is not sealed or its images are gone the job is RED — a freeze is an instruction, never a preference | the log's first line, the summary's chosen because row |
| a successful platform bake has no readable, complete final publication receipt, or architecture receipts disagree | passed over and named as source/release unverified; a set-name freeze fails without substitution | the job log and final error if no verified set remains |
| core's own Plugins seal for the set failed or was skipped | reported, never decisive: this repo is the plugins publication and its next main bake supplies the seal. Making the choice depend on core's copy of our seal would make 2026-09-09 circular (Plugins red ⇒ core's Plugins seal fails ⇒ Plugins resolves an older set ⇒ Plugins bakes for an identity no portal runs) |
the summary's plugins seal by core row |
Nothing in the workflow tests whether the pin is "stale": there is no pin. The staleness gate, the
pin-agreement gate, the --check-tags round trip, the "pin age" reporter, the image lanes'
bounded-gap step and the platform-ref-bump.yml lane are all deleted with the literals they policed.
Compatibility is a VERSION rule, not an exact match
A module built against platform X runs on platform Y unless one of them declares the break — a MAJOR bump. Nothing here demands that the set a run resolves equal the set a portal runs: a portal adopts the newest sealed bundle of its own identity and keeps serving the previous one meanwhile (MeshWeaver#3583, stale, vNEXT pending); a major bump reads incompatible, awaiting bundle on the page. Never a dead page, never an error caused by delivery alone. Drift between what this repo tested and what a portal runs is therefore the normal state, bounded by the cadence of sealed sets (several a day), not by a number in a file.
What is still true from the pin era
- One core per run (#724). The resolver answers once and every consumer reads
needs.*; two jobs of one run can never compile against two platforms.ref: mainon a checkout remains forbidden — it is what omittingref:already does, and it looks resolved. - The image lanes track core
main, declared, not defaulted (MeshWeaver#3259, ImagePlatformRef). Their core half is now hours from the gates' rather than days, and the gap gate that bounded the distance is gone with the pin it measured against. platform-canary.ymlstill compares against core's tip daily — now against the released set resolved the same wayci.ymlresolves it — because a behaviour change behind an unchanged signature is invisible to every surface gate, and a day's early warning is worth having even when the next sealed set arrives within hours. What it now refuses to compare is below.PlatformPinUpdater(the fleet's automated bump,Hosting/Deployment) reads⏭️ carries no MW_IMAGE_DIGEST … — skippedon this repo and moves on; satellites that still pin are still bumped until they adopt this shape (tracked on MeshWeaver.Plugins).
What the canary refuses to compare — a dead host is not a passing suite (#1620)
The canary's verdict is computed from trx content, as sets of test names:
drift = sorted(pin_passed & cand_failed)
pre_existing = sorted(pin_failed & cand_failed)
fixed = sorted(pin_failed & cand_passed)
A test host killed by a signal writes a truncated trx — only what it had already streamed —
so every test after the crash is in neither passed nor failed. pin_passed & cand_failed
therefore cannot contain them, and until 2026-09-11 the job printed
✅ No drift — every test that passes at the pin also passes at core
main
over an arm that had measured a fraction of the suites it named. The suites ran with || true, so
nothing anywhere looked at the exit code, at 139, or at the log. "The tail of the arm never ran"
and "the tail of the arm passed" were the same observation — the shape AGENTS.md forbids under
"A gate NEVER tests its own inputs", and a verification step that cannot fail.
Not hypothetical: MeshWeaver.FutuRe.Test takes that SIGSEGV on Portal hosts (shard 1) at a
low-single-digit-percent rate (#1605;
core's Doc/Architecture/DebuggingNativeCrashes → sighting #15 carries the dump read), and the
canary runs the same suites against the same workload. The Portal hosts shards were never
affected — they already run classify-test-run.py --record-crash-into. The canary was the
outlier, so the fix reuses that machinery rather than inventing a third mechanism.
The distinction the whole fix rests on
dotnet test returns the same non-zero code for two events that mean opposite things here:
| evidence | what the canary does | |
|---|---|---|
| a suite FAILED | the trx holds a failing test | an INPUT to the comparison — observing failures at one ref is what a canary is for, and treating every non-zero exit as fatal would break the lane's purpose |
| a host DIED | there is no verdict for anything after the moment it stopped | a RED verdict naming the suite — never an input to a set comparison that cannot see the gap |
The taxonomy owner is the platform's classify-test-run.py, including the one named post-verdict
runner crash it deliberately downgrades (#1199).
Each arm now keeps every suite's exit code as <suite>.exit, hands a non-zero one to the
classifier, and records the classifier's own verdict as <suite>.classified. A death is written
into the suite's own trx as <suite>.HOST_CRASHED by the platform's record-host-crash.py —
taken from that arm's core checkout, never a copy in this repo — which is core #2495's
single-channel rule: put the crash in the very file every reporter already parses, so no summary
can report a pass over a dead process. The recorder being absent is a hard red before any suite
runs, not a skip.
Two independent checks, and neither is a threshold
scripts/platform-canary-delta.py refuses the comparison on either:
Evidence. Per suite: no trx, no recorded exit code, a recorded
HOST_CRASHED, a non-zero exit that was never classified, a non-zero exit that nothing in the trx explains, or an exit of 0 over a trx holding failures. Each reds with the arm and the suite named, and the signal spelled out (exit=139 (SIGSEGV)).Denominator, in two forms. Absolute: every suite the runner was handed — the arm now carries
suites.txt, copied from.github/platform-canary-suites.txt— must have produced evidence, and an arm that did not carry the list at all is refused. Relative: every test method observed on one arm must be observed on the other, in both directions, and the two arms must have reported the same suite set.🚨 The absolute form is not redundant. Comparing the arms to each other cannot see them both being cut off at the same suite — a job that hit the 45-minute cap, a runner that died — because the two are then symmetric and every cross-arm check is satisfied. Only the list the runner was handed catches that, which is why an absent
suites.txtis missing evidence rather than an empty denominator.
🚨 The granularity is the METHOD, and that is measured, not arbitrary. A test-case count
legitimately differs between the arms: MeshWeaver.Content.Test's theories take their data from the
core checkout — ContentIntegrityTest.GetContentCatalogNodes and
GetFilesWithLayoutAreaReferences enumerate $(MeshWeaverRoot)/samples/Graph and
$(MeshWeaverRoot)/src/MeshWeaver.Documentation/Data, which that suite's .csproj copies into its
own output — so every core commit that adds, renames or deletes a doc page moves that suite's case
count, in either direction, with nothing wrong. A count ratio would fire on that, and a guard that
fires on normal variation gets muted, which is worse than no guard at all. Theory data moves cases
within a method; a truncation removes the method outright. So the method is the unit that
discriminates, and the per-suite case counts are printed beside the verdict as information rather
than used as one. A skipped test is observed — the host reached it and the framework recorded a
decision — so a Skip is not a hole.
python3 scripts/platform-canary-delta.py --self-test carries the controls in both directions: a
recorded crash reds with the suite named, on the pin arm as well as the candidate; a listed suite
that never ran reds, including when both arms are short at the same suite; a complete pair of
arms still reaches drift / pre_existing / fixed unchanged; a failing suite still flows into
the comparison; the #1199 downgrade is not refused; and a legitimately smaller — and a
legitimately larger — but complete arm stays green. The delta job runs it before it compares
anything, and Repo policy gates runs it on every pull request, because a control that only ever
runs on a schedule is one nobody notices going vacuous.
Reading a run
The Platform — resolved at run time, not pinned table at the top of every run summary names the
set, the core commit, both digests, core's Plugins-seal verdict and why this set was chosen. A
red that smells like "the platform changed under me" is read there first: the set it names is the
one to reproduce against (docker pull mw-plugin-test@<digest>; scripts/fetch-refs.py resolves
the same set locally).
Satellites that also run a migration image
resolve-platform.py --migration-image meshweaver.azurecr.io/memex-migration includes that
image in the same candidate selection as the tester and portal. It emits
migration-image-digest and records the third image in the run summary. This is optional:
existing two-image consumers keep their selection and output contract.
A newer sealed set with a missing migration image is passed over for an older complete set. Version tags and the release's commit tag are checked by the existing image resolver. An explicit freeze with a missing migration image fails, as does a registry authentication or transport error; neither permits silently choosing another release. All three images are therefore selected together before any Education disposable mesh starts.
The offline regression suite covers complete sets, missing newer migration images, no complete set, frozen incomplete sets, commit-tag fallback, registry refusal, and optional output behavior.
Local reference resolution
fetch-refs.py uses the same sealed-candidate scan as CI and checks both images through the
existing Docker registry login. It checks the version tags before the commit tag, and skips a
purged newer set when an older sealed set remains available. Without a resolvable retained set,
the default invocation fails with an actionable error. --latest is an explicit exploratory
choice for the moving image, which may not yet be sealed.
An authentication or transport failure stops the scan immediately and names the failed image inspection. Only an explicitly missing manifest means a release can be passed over; unavailable registry metadata is not evidence that retention purged a set.
A freeze validates the complete requested release version as well as its run number. A request
for 2.0.0-ci.8203 cannot select a run that actually published 3.0.0-ci.8203.
The source of a released image is the producer's target
On 2026-09-11, CD run 34559585291, ci8322
executed workflow code at 6231c4da4c1baa5dbcc9e44b43020006583fcb2c, but its gate selected
6e024887d6dac6a28378d989c95d71e763fb9cc7, the successfully tested commit that triggered it.
Both image jobs checked out that older target. Their portable PDB evaluator-source checksum
matched that target, while the consumer resolver returned the newer workflow head_sha.
The images were correctly built from their target; this was a consumer provenance error,
not evidence of stale compiler output or a falsely compiled release.
The resolver now reads the existing final bake published: record written by the platform's
canonical .github/scripts/publish-bake-bundles.sh after publication or equivalent-content
convergence and release-marker writes. It does not parse the echoed shell program, an earlier
checkout line, a Plugins content receipt, or a workflow metadata SHA as the publication.
Each successful platform-bake job must supply exactly one record containing:
source=meshweaver-contentand a full 40-character lowercase source SHA;- a complete
X.Y.Z-ci.Nrelease (the historicalX.Y.Z.ci.Nspelling also parses), withNequal to this publishing run's number; - an architecture, identity, nonzero bundle and release-marker counts, and at least one published or converged target.
All successful architecture legs must agree on source and release. Their framework identities
may differ by architecture. The resolver reads each job log at most once per execution and
retains only the parsed receipt, with a 16 MiB bound on each download. A missing, expired,
malformed or conflicting receipt never falls back to head_sha. Authentication and transport
errors remain failures. Log redirects carry the signed storage URL without forwarding the
GitHub token.
A source-SHA freeze searches for a verified sealed publication from that source; a workflow HEAD
alone is not a match. A set-name freeze still selects that exact release and refuses substitution.
--no-registry verifies the same publication source while omitting image resolution. The
recorded release replaces reconstruction from Directory.Build.props; source metadata being
unreadable cannot turn an authenticated complete release receipt into an invented version.
Existing version-tag, source-tag, optional migration-image and retained-set selection remain
in the shared choose path used by CI and fetch-refs.py.
Executed validation
The regression calls the actual resolver with the observed ci8322 SHA mismatch and synthetic
REST/registry responses. Before the correction, two executed cases produced one wrong-SHA
assertion failure and one error rejecting a freeze on the actual source. After the correction,
scripts/test-platform-resolution.py passes 31 tests and resolve-platform.py --self-test
passes 19 cases. The matrix includes absent/truncated/expired logs, invalid source/release
fields, conflicting architecture receipts, bounded reads, credential-safe redirects, freezes,
commit-tag fallback, both required images and the optional migration image. Existing CI runs
both commands in the Resolve the released platform job before making the live choice.
The related shared-lane guard passed 15 self-test cases and all six current calls; fetch-refs.py --self-test passed. Shape validation passed 261 nodes across 60 packages, every manifest was
current, and the canonical NodeType compiler completed 89 clean types with no debt, new breaks,
fingerprint drift or unverifiable inputs (70 seconds). This used the existing implementation
reference directory /Users/roland/.cache/meshweaver/roadmap-validation-refs and core6231's
.github/scripts/compile-check.py, with MW_REPO_ROOT explicitly set to the candidate tree.
Two earlier compile processes ended with SIGTERM; neither was counted as a passing gate.
The same parser was also executed offline on the retained successful producer logs:
| release | final platform-bake receipt | source |
|---|---|---|
| ci8322 | job 103142895248, final record at 04:07:19Z | 6e024887d6dac6a28378d989c95d71e763fb9cc7 |
| ci8323 | job 103151834271, final record at 04:55:41Z | 6231c4da4c1baa5dbcc9e44b43020006583fcb2c |
| ci8329 | job 103172487035, final record at 06:42:53Z | 818c10649389c158594dc20c3e7792367f2cb51f |
This validates receipt interpretation, not a new image build, binary inspection or live platform
adoption. The original external receipts are /tmp/roadmap-platform-provenance-before.log,
/tmp/roadmap-platform-provenance-suite.log, /tmp/roadmap-platform-provenance-self.log, and
/tmp/roadmap-8323-release-evidence.md; the commands and source contract above reproduce the
logic without requiring those local paths.
Main integration and the remaining CI boundary, 11 September 2026
PR #1646 integrates main 05fde51007d97e5859682551b94027d190d3491b while preserving
the resolver and its 31-test suite byte-for-byte from
1834f2c7db28c742aa55f14cfeda9d8978ba78c0. Main's parameterized GitSync workflow-path
fixtures and the three additional refusal/recovery controls from d07ee196 are retained
together. Both resolved test files match the previously validated
6d31e2380266b2f0674ba2547df99c2d904c5b77 exactly; that combined fixture passed a strict
build and all 199 GitSync tests. This merge does not duplicate or weaken the product guard.
The prior run 34579424587 passed resolver, module, NodeType and Tests-area gates, but Portal hosts shard 3 failed. The shared managed-compiler failure is tracked under core #890, separately from these source conflicts. The maintenance neither changes that compiler nor establishes that failure as resolved; a fresh normal CI run must still pass its required checks. No retry-to-green, timeout extension or assertion suppression is part of this change.