The platform reference set is an ARTIFACT, not a reconstruction
A module never runs against a source tree. It is loaded into the platform image and bound by
the assemblies in there, so the honest thing to compile it against is what that image ships —
Module Build Architecture
settles that, and the unified lane already carries it: the platform image and its /app extraction
stage once per run into the actions cache, and a warm digest touches no registry at all.
This page is about the half that is not settled. The set of bytes arrives efficiently; the reference set is still reconstructed on the runner, and a reconstruction can be silently partial.
The defect: partial is indistinguishable from complete
node-repo-module-pack.yml's prepare stage does one docker cp "$cid:/app/." into
$RUNNER_TEMP/platform-refs and hands that directory to every build as $(MeshWeaverRefs). Its
only completeness check is that more than 50 assemblies came out. That directory therefore carries:
- the assemblies — yes;
platform-refs.props, the 243<PackageVersion>items a restore needs — no;platform-refs.targets, the removal list — no;- any statement of what it contains or which platform commit produced it — no.
Two consequences follow, and both are live on main today.
1. Every consumer still needs the core checkout. src/Directory.Packages.props gates on
_MeshWeaverRefsListImported — MeshWeaverRefs set and platform-refs.props present. A raw
/app copy fails the second half, so the import falls back to $(MeshWeaverRoot), which is why
this repo's ci.yml still checks out Systemorph/MeshWeaver five times and passes
MeshWeaverRoot eight times. The fallback is correct — it is the fix from plugins#1054 — but it
is a fallback, and while it is the only path that works the checkout cannot go.
2. When the list is generated, it can still be short, and nothing says so. .deps.json is
authoritative for what SHIPPED but is TRIMMED of everything the ASP.NET Core shared framework
provides, while a consumer csproj still names those packages and still needs a version. The other
82 of 243 versions come from the two central lists the platform publishes into its own /app. An
image predating that publish yields 161 of 243 and exits 0; the consumer meets the gap as
NU1010 on ClosedXML, CsvHelper, DocumentFormat.OpenXml — four layers away, naming nothing.
That is plugins#1054, and it cost a day.
🚨 The failure class is RECONSTRUCTION, not transport. A set each consumer assembles from an image whose contents may predate the code reading them can be partial, and nothing downstream can tell. Moving those bytes faster does not touch it.
The fix: generate once, state what it is, refuse anything else
scripts/refs-artifact.py turns a platform publish output — a container's /app, or better, the
dotnet publish that BECOMES that /app — into a named artifact:
<set>/*.dll the platform, as delivered
<set>/platform-refs.props 243 <PackageVersion> items + the binding identity
<set>/platform-refs.targets <PackageReference Remove/> for what the set supplies
<set>/refs-manifest.json what this is, and which platform commit produced it
It never runs docker. Four verbs — build, verify, publish, fetch — and every read fails
closed:
| refusal | why it is not a warning |
|---|---|
| neither central list | "this publish PREDATES the central-list publish" — 161 of 243, named |
| one central list of two | both are copied by ONE guarded ItemGroup; half means a partial platform checkout |
| a central list declaring nothing | an empty list is indistinguishable from a missing one at restore |
not exactly one *.deps.json |
neither the versions nor the binding identity can be read |
MeshWeaver.* assemblies disagreeing on AssemblyVersion |
MeshWeaver#143 — drift is a runtime FileNotFoundException, never a build error |
| a file whose size or sha256 differs from the manifest | a torn or edited set |
| a file present but not listed | an unlisted file is an unverified one |
| a manifest that does not STATE an invariant | an unstated invariant is not a satisfied one |
The manifest is the point
{
"schema": "meshweaver.platform-refs/1",
"kind": "generated", // ← NOT a raw `docker cp /app/.` copy
"digest": "sha256:…", // over the sorted (path, size, sha256) listing
"platform": { "commit": "…", "version": "…", "assemblyVersion": "3.0.0.0",
"surfaceIdentity": "s…" },
"contents": { "packageVersions": { "total": 243, "shipped": 161, "declaredOnly": 82 }, … },
"completeness": { "centralListsPresent": true, "shippedPackagesPresent": true,
"bindingIdentityUnique": true },
"fileList": [ { "path": "…", "size": …, "sha256": "…" }, … ]
}
kind alone ends the plugins#1054 ambiguity: MeshWeaverRefs has meant a generated set to
Directory.Packages.props and a raw /app copy to the module-pack lane, and nothing could tell
them apart. Now a consumer can. completeness is recorded rather than assumed, so a set produced by
an older generator that did not check is refused by a newer consumer that does — the invariant
travels with the artifact instead of living only in the producer that happened to run.
What this deliberately does NOT build
The brief that started this work called for a publication to our own blob storage — a producer step writing a per-release reference set to a storage account under OIDC, and a consumer fetching it by URL. That half should not be built, and the reason is written down:
"Own-account blob storage would add auth and distance for nothing; the actions cache is the same Azure blob, colocated, already carrying the images." — Module Build Architecture, 2026-09-01
The transport problem the brief set out to solve is already solved, by the unified lane, one layer
below where the brief was aiming: platform-refs is cached per platform-image digest in the actions
cache, and a warm digest reaches no registry. A second publication path with its own storage
account, its own RBAC and its own credential would be a parallel mechanism serving the half that is
finished, while the half that is broken — completeness — is not a transport question at all.
So refs-artifact.py's build and verify are the verbs the pipeline needs now; publish and
fetch exist because they cost almost nothing and because the artifact is deliberately shaped to be
served later without change:
<base>/blobs/sha256/<digest> the set, one deterministic tar.gz → an OCI layer
<base>/manifests/<digest> refs-manifest.json → an OCI config
<base>/tags/<alias> a file holding the manifest digest → an OCI tag
That is an OCI Distribution registry's object-storage layout. When MeshWeaver's own OCI surface
lands — pull-only, manifests and access control served by the portal, blob GETs answered 307 to
short-TTL pre-signed URLs — this artifact is addressable through it with no change to producer or
consumer: fetch speaks plain HTTP(S) and carries any query string on its base URL across to every
request, which is exactly the shape of a pre-signed URL. It needs no cloud SDK, no Azure credential
and no docker — a URL, and optionally a bearer token.
Where the fix has to land
One step, in the prepare stage that already exists:
- docker cp "$cid:/app/." "$RUNNER_TEMP/platform-refs/"
- dlls=("$RUNNER_TEMP"/platform-refs/*.dll)
- [ "${#dlls[@]}" -gt 50 ] || { echo "::error::… not a platform image"; exit 1; }
+ docker cp "$cid:/app/." "$RUNNER_TEMP/platform-app/"
+ python3 refs-artifact.py build --from "$RUNNER_TEMP/platform-app" \
+ --out "$RUNNER_TEMP/platform-refs" --platform-version "$V" --platform-commit "$SHA"
and, in every consumer of the cached directory, refs-artifact.py verify --set … before it is used
— because a cache entry is exactly the thing that can be stale, torn, or from a producer that did
not check.
🚨 That prepare stage is node-repo-module-pack.yml, which lives in the platform repo, and the
adoption contract is explicit that mechanics live there while a consuming repo carries only policy:
"Scripts are centralized … a repo keeps ONLY its scripts/compile-check.allow. Per-repo script
copies are retired; three had already drifted apart when this landed." By that rule
refs-artifact.py's final home is core's .github/scripts/, fetched by the lane at the pin. It
sits in this repo's scripts/ for now because that is where it was asked for; the move is one file
with no contract change, and it should happen in the same PR that rewrites the prepare step.
Until then the duplication that already exists — scripts/container-refs.py still emits the same
two files from a docker-extracted /app — is not left to a comment: refs-artifact.py --self-test
runs both generators over one fixture and fails if the emitted platform-refs.props or
platform-refs.targets differ. Two generators that disagree would both be green while producing
different reference sets, which is the one thing a drifting build input must not be able to do.
Measured
All local, 2026-09-01, against a real 210-assembly platform /app.
| result | |
|---|---|
| generated set | 243 package versions (161 shipped, 82 declared-only), 137 removals, AssemblyVersion 3.0.0.0, 45 platform assemblies |
| publish → fetch round trip (staged store) | 85 MB blob, full re-verification in < 1 s |
MeshWeaver.Import, no core checkout, no docker |
0 errors, 0 warnings — ClosedXML 0.105.1, CsvHelper 33.1.0 resolved from the declared-only half |
| also clean, same way | MeshWeaver.Northwind.Application, MeshWeaver.Hosting.Sqlite, MeshWeaver.Mcp |
| control — the same set with the 82 declared-only versions removed | NU1010: ClosedXML, CsvHelper, Build FAILED — plugins#1054, reproduced exactly |
producer against an /app with no central list |
refuses, naming the cause and the pin to bump |
producer against an /app with one list of two |
refuses differently, naming the partial publish |
consumer against a raw docker cp copy |
refuses — "this directory is not a published reference set" |
MeshWeaver.Hosting.Instance does not build against that set, with CS0246 on ConsentTexts
and InstanceConsentService — types newer than the pinned image. That is the mechanism working:
a project reaching past the platform surface it is pinned to fails visibly, naming the type, instead
of resolving something that binds at compile and throws at load. It is a stale-pin symptom, and the
cure is a pin bump, not a fallback.
What still has to happen before a repo drops its core checkout
- Rewrite the prepare step as above, in
node-repo-module-pack.yml, and move the script to core's.github/scripts/. - Bump
MW_PORTAL_IMAGE_DIGESTto an image that carries both central lists. The digest pinned today does not, so the generator refuses it — correctly, and loudly. The producing side is theCopyToPublishDirectoryItemGroup insrc/Memex.Portal.Distributed.csproj. - Verify at every consumption point, not only at production — the cache is what can be stale.
- Then drop
MeshWeaverRootfrom the lanes that no longer need it, one at a time, watching for the CS0246 class above; keep it inportal-hosts, which builds the platform's own host and is by definition the producer rather than a consumer. container-refs.pyretires when the last docker-extraction path does; the drift guard turns itself off when the file is gone.