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:

Two consequences follow, and both are live on main today.

1. Every consumer still needs the core checkout. src/Directory.Packages.props gates on _MeshWeaverRefsListImportedMeshWeaverRefs 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 warningsClosedXML 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

  1. Rewrite the prepare step as above, in node-repo-module-pack.yml, and move the script to core's .github/scripts/.
  2. Bump MW_PORTAL_IMAGE_DIGEST to 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 the CopyToPublishDirectory ItemGroup in src/Memex.Portal.Distributed.csproj.
  3. Verify at every consumption point, not only at production — the cache is what can be stale.
  4. Then drop MeshWeaverRoot from the lanes that no longer need it, one at a time, watching for the CS0246 class above; keep it in portal-hosts, which builds the platform's own host and is by definition the producer rather than a consumer.
  5. container-refs.py retires when the last docker-extraction path does; the drift guard turns itself off when the file is gone.
Reconnecting…
The server was updated. Reloading the page to pick up the latest version.