CI Content Bake

βœ… Rule change, 2026-09-07 (maintainer) β€” Module Adoption Policy, implemented by #3648, #3649, #3650 and #3651. This page describes the mechanism as it runs after those changes: a declared floor is advisory, a refused generation falls back to the previous one, a new build is adopted eagerly, and a platform roll is held only by a module that provably cannot load on the target.

Every .cs stored in a mesh node compiles at runtime in the portal (see NodeType Compilation), and until issue #1660 that was also the deploy path: every image roll changed the framework identity, invalidated every cached NodeType assembly, and the portal re-compiled the world in-process while users lived inside the warm-up window. The accepted direction is load-only runtime: CI compiles the shipped content once, and boot loads the result.

This page describes workstream 1, step 1 β€” the pieces that exist and how they fit.


The producer: the content gates persist what they compile

CI already compiles the image's shipped content as a verdict: the doc-gate job runs mw-plugin-test over src/MeshWeaver.Documentation/Data (staged as the Doc package) and over the samples/Graph/Data trees. That is the platform's OWN content, and it is the only content the platform bakes.

🚨 The platform never syncs or bakes plugin source. Until 2026-08-27 core CD checked out MeshWeaver.Plugins, Roslyn-compiled every module, and published the result as the plugins source β€” the same shelf Plugins' own publish-bake writes. Two producers, one shelf, and a full compile of source the platform does not own on every platform push. Removed: Plugins bakes Plugins and publishes it; the platform's portals install it. See The Plugin Build Contract β†’ "Three rules that follow".

Each of those lanes runs the bake and the gate as two steps (.github/scripts/bake-then-gate.sh β€” the same split main-cd makes, described below): compile <stage> --output <dir> produces the bytes, then the gate runs over the same stage with --seed <dir> and consumes them. The bake persists:

Only types that reached CompilationStatus.Ok contribute. A type the gate's known-debt allowlist tolerates simply has no entry β€” the consumer compiles it as it would have anyway. A type that claims Ok while the run's assembly store has no bytes for it faults the run: an artifact stage that ships less than the verdict claims would be the skip-trapdoor shape CI forbids.

Both gates fail RED when a green run produced no bake identity, produced no bundles, or adopted none of them β€” each is a postcondition of the verdict, never an optional extra. That last one matters more than it looks: adoption is invisible in a gate verdict by construction (a type the gate compiled itself renders and tests exactly like one it adopted), so without an explicit assertion the entire consuming half could stop working with every run still green. assert-bake-consumption.sh reads the gate's own seed: adopted N of M line and requires M > 0 as well as N β‰₯ M β€” because BakeSeed.Shortfall() returns a PASS over an empty bake ("adopted everything declared" is vacuously true of nothing), which is exactly the vacuous green the split exists to make impossible. N may legitimately EXCEED M: N counts adoption events and the gate installs every package twice (the idempotence pin re-installs the unchanged snapshot), so adopted 32 of 28 is healthy.

🚨 What the gates' bake is FOR: proving the bake stage still works, on the PR that breaks it. It is not the delivery lane. A gate job's bundles are keyed to that job's own binaries, and no shipped image ever contains those (see "The identity rule" below), so doc-gate uploads nothing. What the portals adopt is baked inside the shipped image for the platform's content, and published by each plugin repo's own publish-bake for plugins β€” see "The delivery" below.

🚨 The gate's verdict is not assemblies alone. A package's committed content/** binaries β€” course videos, posters, og cards, fonts β€” are installed and read back on the same run; see Gate Content Assets for the host shape that made them invisible to the gate for months, and the content check that now covers them.

BAKE is a build step; GATE is a mesh run that CONSUMES one

This is how the bake worked until issue #1763: mw-plugin-test stood up an in-process mesh (new MeshBuilder(...).AddGraph()), imported the repo's content, let the mesh compile every NodeType, and --bake-output collected what the mesh had produced. That is "compile through mesh nodes" β€” the thing #1707 forbids β€” and it is where the minutes went: mesh startup, the hub scheduler, and one per-type activation for every type in the tree.

#1763 split CD. #2064 split the PR lane too β€” dotnet-test.yml's doc-gate and plugin-gate were still fused, so the platform's own PR gate was doing the very thing CD had stopped doing, and --bake-output no longer appears in that workflow at all. The remaining fused caller is node-repo-publish-bake.yml (the satellite lane).

The two concerns are now split, and they are different kinds of thing:

what it is how it runs
BAKE β€” produce assemblies a build step mw-compiler compile <root> --output <dir>: resolve NodeType sources from the git tree, compile with MeshWeaver.Compiler, emit DLL + PDB, write the bundle. No MeshBuilder, no AddGraph(), no import, no hub.
GATE β€” prove it works a runtime check mw-plugin-test <root> --seed <dir>: stand up a mesh, adopt the bake's assemblies, render each type's default area, execute its Tests area. Rendering and running tests are genuine runtime behaviours; producing an assembly is not.

The emergency path is untouched. A live instance with no usable artifact still compiles its own β€” #1707 requires it, because there will always be code that never went through CI. That is recovery, not a build lane.

The gate CONSUMES the bake β€” --seed <dir>

--seed points the gate at a bake directory. The wiring is one registration and no new pipeline: the gate registers an IPrebuiltAssemblyConsumer over that directory, and PackageInstaller's existing adopt-before-compile step (#1707 slice 3) asks it for every NodeType it installs. It delegates to ShippedPrebuiltBundles.SeedForTypes β€” the same consumption implementation a portal runs β€” so the framework gate, the per-type dependency-record gate and the already-current skip that decide the verdict are the ones that ship. A gate with no --seed resolves nothing and compiles exactly as before.

What that buys is not speed, it is what the gate is judging: with a seed, the assembly that renders and runs the Tests areas is the assembly that will ship. Without one, the gate proves that a private recompile of the same sources worked and publishes different bytes.

Two refusals guard it, because both failures are otherwise invisible:

🚨 The seal carries the modules it composed β€” a gate composes from the seal, never the registry

A bake composes external modules with --module (this run's module-pack artifacts, or an upstream's) and seals its NodeType assemblies against those bytes: every dependency record names the module's MVID. The registry's package endpoint (/api/plugins/bundles/<pkg>/<version>) serves something else β€” the module's own lane's last build, under a content version that does not move when a rebuild changes the bytes. A gate that seeded publication X but composed its modules from the registry therefore ran assemblies built against one MeshWeaver.AI while holding another, and the boot seeder rightly declined every one: dependency record mismatch β€” built against mvid:…, live is mvid:…. On 2026-08-29 that was every satellite, red at once, with no diff in any of them (#2698).

The publication is the unit of consistency. Since #2707:

The registry's package endpoint remains the runtime surface β€” a portal installing AI@1.2 gets whatever the module lane published β€” which is why the platform's own wave seals its module bundles without publishing them as packages.

🚨 An adoption used to lose a race with the first-build kickoff

Adopting a prebuilt assembly writes the NodeType's node, and that write goes through the type's OWN hub β€” so PrebuiltAssemblySeeder.Seed activates the hub it is about to stamp. Activation is exactly what arms the first-build kickoff (CompilationStatus is null + no usable build β‡’ flip Pending), so the seeder's own probe started the Roslyn compile the adoption exists to avoid:

54.709  MeshNodeStreamCache: opening shared stream for Widget/Thing   <- the seeder
54.728  First-build kickoff: no usable build - flipping CompilationStatus=Pending
54.7xx  Prebuilt assembly ADOPTED for Widget/Thing ... no compile needed
54.7xx  [ReleaseRequestWatcher] ... satisfied by the existing current build - no compile dispatched
54.8xx  Compiling assembly for Widget_Thing (disk, 0 NuGet refs)      <- overwrites the adoption

Every signal said the adoption had worked, because it had. The release request was correctly satisfied; the kickoff simply never asked. So install-time consumption saved nothing anywhere β€” on a portal as much as in a gate β€” and the type was re-stamped over the adopted build milliseconds later.

NodeTypeAdoptionRegistry is the interlock: the seeder reserves the path before it opens the stream, i.e. before the activation that arms the kickoff, and the kickoff waits for the reservation to clear and then re-evaluates. It delays, never cancels β€” a declined adoption still compiles, so there is no skip-trapdoor β€” and the wait is bounded, so a leaked reservation costs a delay rather than an unbuilt type.

Source resolution without a mesh

At runtime the mesh performs source discovery: NodeSources.GetSources expands the NodeType's Sources/Tests queries and asks workspace.GetQuery, which reaches the storage adapters. A build step has none of that, so MeshWeaver.Compiler gained a second implementation β€” NodeSet / NodeSetQuery / NodeSetCompiler β€” that answers the same queries against an in-memory node set the caller assembled from the tree.

That code lives inside the toolchain assembly on purpose: which Code nodes a compile consumes is part of the generated input of that compile, exactly like the skeleton generator and the join order, so it has to sit inside the full-MVID identity boundary. A resolver outside it could change what a bake consumes without moving the framework identity, and every portal would adopt the changed bytes as if nothing had happened.

Two rules keep the second implementation honest:

The equivalence pin

🚨 Getting this wrong is silent. A baker that resolves sources even slightly differently emits assemblies that are subtly not what the mesh would have built. The bundle is well-formed, the framework identity matches, every consumer adopts it, and the defect first appears as a page rendering empty in production β€” no exception, no log line.

So the equivalence is a test, not an argument. BakeEquivalenceTest (test/MeshWeaver.PluginTester.Test) bakes one content set BOTH ways and asserts the producers agree on: the framework identity, the bundle and node-path sets, the resolved source set per type, the per-type dependency records, and the emitted assemblies' type-and-member surface β€” over a fixture that exercises the default Source/+Test/ subtree queries including a nested folder, a cross-package shared=@… query, an @@ include of a node no query matches, an executable code cell that must be excluded, and a // NodeType: Scope source the nodeType:Code filter must exclude.

Bytes are deliberately not compared β€” for a reason that is a property of the platform, not of this test. See the next section.

🚨 A stated property: the mesh-driven bake is NOT byte-reproducible, even against itself

This is not a quirk of the comparison above; it is true of every bake this platform has ever produced, and anyone reasoning about bake artifacts needs it up front.

A NodeType's compile input is the concatenation of its source Code nodes. The mesh discovers them by folding each query's results into an ImmutableDictionary<string, MeshNode> and emitting dict.Values β€” which is hash-bucket order over string hashes that .NET randomises per process. NodeCompileShaping.CombineSources then joins the files in exactly that order. So two runs of the same mesh-driven bake, over the same commit, on the same machine, concatenate the same sources in different orders and emit different bytes. (The generated skeleton independently stamps // Generated at: {UtcNow}, and the emit embeds a temp pdbFilePath, so even a fixed order would not give byte equality.)

Three consequences worth carrying:

🚨 An in-mesh build is the ABSOLUTE FALLBACK β€” if a pod is sweeping, the bundles are missing

A portal should never compile content at boot. It should ADOPT files CI already produced. When you see a pod stuck on

Health check nodetype_bake: 'NodeType bake in progress β€” enumerating dynamic NodeTypes'

that is not the system working slowly. It is the fallback, and it means no bundle matched this image's framework identity. Treat it as a missing artifact, never as "boots are slow here".

Where the files actually come from (measured on memex, 2026-08-22)

There are two adoption sources, and only one of them is real today:

Source State
prebuilt/ inside the image (ShippedPrebuiltBundles) EMPTY β€” ls /app/prebuilt returned 0 files on the running portal
the published store on the shared volume, /data/prebuilt-bundles/<identity>/ 101 identities present

So the store is the only lane that feeds adoption. A pod adopts iff its own identity is one of those directories β€” and when it is not, it sweeps.

Why the identity can be missing even though bakes are green

The bake follows releases and the pin governs gates β€” that separation is right and has been in place since 2026-08-18. What it does not fix on a busy trunk:

🚨 MW_TEST_IMAGE is mw-plugin-test:latest, a MOVING tag. The bake resolves it at run time; the instance later rolls to the newest portal tag. On a trunk that builds every few minutes those are different commits, so the bake seals identity A while the instance wants identity B. Three bakes in one morning published s429a849…, s14290dce… and se78f65ed… while memex held for se3bf749… β€” every job green throughout.

The half that makes this converge is the CONSUMER: an instance must roll to the newest release that is actually baked, not the newest release that exists. Newest-only selection can never win the race, because the newest tag is always the one least likely to be baked yet.

Checking it, in order

  1. ls /app/prebuilt on the pod β€” if empty, the image lane contributes nothing (it does not today).
  2. ls /data/prebuilt-bundles | wc -l β€” the store; then whether THIS image's identity is among them.
  3. The bake job's bake published: identity=… versus the instance's heldReason identity. Different values are the whole bug.

Byte-equality IS reachable β€” through adoption, not through recompilation

Two independent compiles of the same content still cannot be byte-equal, and that is not only the old lane's fault: CSharpCompilationOptions here is not WithDeterministic(true), so Roslyn mints a fresh MVID per emit, and DynamicMeshNodeAttributeGenerator stamps // Generated at: {UtcNow} into the generated skeleton β€” both are normalised out of the content key (GeneratedInputIdentity) precisely because neither can be removed from the bytes. So "compare the digests" remains the wrong check between producers, and BakeEquivalenceTest's surface comparison remains the right one.

What the split makes byte-equal is something else, and it is the property the gate needs: the bytes the gate judges are the bytes the bake produced, because they are the same bytes, adopted rather than rebuilt. Measured on the Doc tree, comparing the bake's bundle with the bundle a seeded gate re-emits from its own store:

bake/Widget.zip     c97474b87fb87ae4921096ba77c6a3124cddb0d1d2eb72d8d7794bc47a384517
gateout/Widget.zip  c97474b87fb87ae4921096ba77c6a3124cddb0d1d2eb72d8d7794bc47a384517

BakeGateSplitTest asserts exactly that, with the unseeded gate as its control in the same test: its bytes must DIFFER, because a compile is not reproducible β€” which is what makes byte-identity evidence of adoption rather than of coincidence. If compilation is ever made deterministic, that control fails loudly instead of the assertion quietly proving nothing.

The identity rule: adoptable when the SURFACE is unchanged

Adoption is gated by PrebuiltAssemblySeeder.DeclineReason on the framework build identity (NodeTypeCompilationHelpers.FrameworkVersion / FrameworkBuildIdentity β€” #1660 WS3). For the hosts that matter here β€” the bake host and the portals, which both ship a meshweaver-surface.manifest β€” that identity is the API-surface hash s<hash>: per compile reference, the SHA-256 of its reference assembly (the compiler's own definition of the API surface β€” byte-stable under body-only and private-member edits, changed by any surface change), hashed over the canonical content-surface set, with the generated-input-shaping exceptions contributing their full implementation MVID: the toolchain roots MeshWeaver.Compiler (THE compile toolchain since #1707 β€” skeleton generation, source-query resolution, @@-include shaping, aggregation, options, generator execution, emit; namespace MeshWeaver.Compiler) and MeshWeaver.NuGet (the #r "nuget:" parser/resolver β€” what Roslyn is fed and which assemblies a directive adds), plus their computed MeshWeaver dependency closure (Mesh.Contract, ContentCollections, transitives β€” the toolchain CALLS into what it links, so a body-only change in a closure member can change what it emits; the set is derived from the shipped assemblies' AssemblyRef metadata, so every host computes the identical set and a new toolchain dependency can never be silently outside the identity). Before #1707 the toolchain lived inside MeshWeaver.Graph and pinned ALL of Graph β€” the highest-churn assembly β€” so nearly every merge rebaked the world; the extraction is what makes "rebuild only when we need to" hold in practice.

Three consequences:

🚨 The identity is a property of the BINARIES, not of the source (#1725)

The stability above holds across rebuilds of the same build invocation. It does not hold across different build invocations of the same source, and a delivery lane was once built on the belief that it did. Measured on commit babb3bc β€” same sources, same runner path β€” between the Build-and-Test job's dotnet build output and the dotnet publish -t:PublishContainer image the same commit shipped:

half of the identity result
implementation MVIDs (FullMvidAssemblies β€” the toolchain closure) all differ, controls (Data, Layout, AI, Utils) included
reference-assembly hashes (the other 33 canonical entries) 29 identical, 4 differ: MeshWeaver.Graph, MeshWeaver.Hosting, MeshWeaver.Kernel, MeshWeaver.Markdown.Collaboration

The two hosts therefore resolved sd0d0daa… and s377941f… for one commit. The same four reference assemblies also differ between the amd64 and arm64 variants of one multi-arch image, so a multi-arch image carries two identities and a bake is valid for the architecture it was taken on. That is why publish-bake is a matrix with one lane per architecture, each pinning --platform to its own leg's value and running on that architecture: each lane publishes the bytes it actually produced, under the identity those bytes resolve. Until then only amd64 was baked β€” every AKS node is amd64 β€” and an arm64 install resolved the other identity and compiled every NodeType at boot.

None of this is a defect in the identity β€” it is the identity doing its job. A bake is an ABI claim about bytes, and bytes from another compilation are not the bytes a pod loaded. The operational rule that follows is absolute:

The producer of a bake must be the binaries the consumer runs. Never publish a bake under several identities, never let a pod scan for a "nearest" one, never relax the sentinel or the identity check β€” adopting bytes from an identity you did not resolve is exactly what the check exists to prevent.

Manifest-less CI processes (test hosts) fall back to the commit identity g<sha> stamped by Directory.Build.props; local manifest-less builds fall back to the identity anchor's MVID (MeshWeaver.Compiler.dll β€” single-file attributable, which is what lets a packer read it without loading anything). The commit stamp doubles as provenance everywhere.

🚨 …and both hosts must RECORD the same canonical set β€” the address check (#1814)

The rule above is about the bytes. There is a second way for two hosts of one commit to resolve different identities, and it has nothing to do with bytes: a canonical assembly that one host's surface manifest does not record at all. ComputeSurfaceIdentity hashes every name in ContentSurfaceAssemblies, and a name the manifest has no line for contributes the literal absent. So a host that stops compiling against an assembly stops recording it β€” and forks its identity away from every other host β€” while its binaries are otherwise identical.

That is what took memex.meshweaver.cloud's course covers down for two hours on 2026-08-17. The sequence, measured:

The manifest itself is emitted by MeshWeaverSurfaceManifest.targets at the repo root β€” a separate file, imported by the root Directory.Build.props and by the plugins repo's src/Directory.Build.props against $(MeshWeaverRoot), because the portal hosts live there since #2293. While the targets sat inline in the props they were invisible to that repo: the first portal image built from it (3.0.0-rc8.ci.5768) declared MeshWeaverSurfaceManifest=true and shipped no manifest β€” the fallback identity below, which no bake matches (#2395). The plugins import is deliberately unconditional: a core checkout without the file is an MSB4019 error, not a manifest that silently never appears.

Two checks now stand where nothing stood.

  1. Offline, at PR time. CanonicalContentSurface_IsRecordedByEverySurfaceManifestHost (FrameworkBuildIdentityTest) recomputes, from the csproj graph, the compile closure of every project that sets MeshWeaverSurfaceManifest=true and fails naming any canonical assembly a host does not record. CanonicalList_MatchesTheTesterClosure had always pinned one side of that equality; this pins the other, which is the half whose absence let a one-line-per-host change ship.

  2. On the artifact, at release time. main-cd's publish-bake job resolves the identity of the promoted memex-portal-ai image and compares it with the identity the bake published under, before publishing. The comparison runs the bake image's own framework-identity verb:

    mw-plugin-test framework-identity <app-dir> [--expect <identity>]
    

    which resolves the identity of another host's /app from that directory's manifest and assemblies as files β€” nothing is loaded, so one container answers for another image. It refuses to answer for a directory with no usable manifest rather than degrading to the fallback identity: two manifest-less hosts of one commit resolve the same fallback, and a comparison that passes on degraded input is a check that cannot fail. On a mismatch it prints the canonical assemblies the target does not record, because "the hashes differ" is not actionable and the real defect was eight named assemblies.

Both pulls pin --platform to this lane's architecture, out loud β€” never the runner's default: the identity is per-architecture, so comparing across legs would be meaningless. The bake above pinned the same value, which is what makes the two sides of this comparison describe the same bytes. Before the lane was split per architecture that per-arch difference was the second independent way to mint an unread address (memex.localhost is arm64 while the bake published amd64); the same guard covers it either way, because it compares the values two concrete hosts resolve rather than assuming which architecture either of them is.

🚨 …and the bake must compile against the PORTAL, not against the process it runs in (#3022)

The two checks above make the two hosts record one identity. They say nothing about what the bake compiles against, and the third outage came from there. After #3041 the identity gate was green β€” mw-plugin-test and memex-portal-ai of 3.0.0-rc9.ci.7534 both resolve s8fe4902c0b2f5974f824be2867221dbd, every one of the 25 assemblies they share is byte-identical β€” and the platform's plugins-bake was red on four NodeTypes with CS0234 'Maps' does not exist in the namespace 'MeshWeaver'. The bake took its reference set from the tester image's own /app (88 assemblies); the portal's has 219, and MeshWeaver.Maps.dll is one of the 21 MeshWeaver.* assemblies that exist only in the portal. Nothing in the verdict named a reference β€” it named Cornerstone/Pricing and three map galleries, whose source nobody had touched. No seal, so no dependent was woken and no portal could adopt any release since.

Since #3022 the node-repo lanes (node-repo-publish-bake.yml, node-repo-gate.yml) take platform-image + platform-image-digest (required β€” the portal), assert with the tester's framework-identity /portal --expect <tester identity> that the two images are one build, compose the gate host (compose-gate-host.sh: the portal's /app with the tester CLI laid beside it β€” the portal's bytes win, the tester's manifest and deps.json never ride), and run both verbs from it on the portal image's own dotnet: compile … --app /app --shared-frameworks /usr/share/dotnet/shared compiles against the portal's /app plus its implementation frameworks, keys framework-mvid.txt to the identity the portal's directory resolves, computes every dependency record against the portal's manifest and MVIDs, and refuses a host whose compile toolchain the process is not running (the closure's MVIDs must match member by member β€” the one invariant that makes recording the portal's identity honest). The gate's --app /app is the precondition that the process is the portal host; a gate running as another host would decline every bundle and pass. A CS0234/CS0246 the reference set explains is named in the verdict β€” reference set lacks <assembly> (portal-shipped, not composed: modules/…) β€” never left reading as a content bug. The full shape, the measurement table and the cost are in Module Build Architecture β†’ "The NodeType bake and its gate run AS the platform image too".

The address check this section describes stays where it is meaningful: the platform's own Doc bake (main-cd.yml publish-bake) still bakes inside the tester image and compares against the promoted portal. For the node-repo lanes the bake's identity is the portal's by construction; the lane keeps "the bake is keyed to what the portal's /app resolves" as a cheap invariant after the compile, and the comparison that can lose β€” the two images being one build β€” runs before anything is composed.

🚨 The fix direction is always "give the host the reference back", never "shrink the canonical list". Removing a name would make the two hosts agree by making the identity blind to that assembly's surface β€” an under-invalidation, which is how a portal ends up adopting NodeType assemblies compiled against a framework that has since shifted underneath them: a silent TypeLoadException inside an ALC at activation, the failure mode with no diagnostic and no overlay. Memex.Portal.Distributed therefore declares the eight as compile-only references (Private="false" ExcludeAssets="runtime" PrivateAssets="all"): the manifest records them, while the bits still ship only via modules/MeshWeaver.Import/ and nothing downstream inherits the declaration.

The image: prebuilt/ beside the app

Memex.Portal.Distributed.csproj accepts -p:PrebuiltBakeDir=<dir>: the bundle zips are laid into prebuilt/ in the publish output (and therefore the container image). Without the property β€” every local build, and the CD legs until they bake in-job β€” the image simply ships no bundles and boot behaves exactly as today.

The consumer: boot seeds before the sweep decides

ShippedPrebuiltBundles.SeedAll (MeshWeaver.Hosting) runs inside the dynamic-type pre-warm pipeline, after the static repo import settles (the nodes a bundle names must exist) and before the sweep probes the assembly store:

  1. every *.zip under prebuilt/ (override: PreWarm:PrebuiltDirectory) has its manifest read with BundleReader.ReadManifest β€” a few KB at a known entry, no assembly decompressed;
  2. the bundle's framework MVID is checked once against the running process; a mismatch declines the whole bundle, loudly;
  3. one enumeration of the mesh's NodeType nodes filters the entries down to types this deployment actually holds (an image ships one content set; a mesh serves a subset) β€” no per-missing-path waits. The enumerated nodes are kept, not just their paths: they carry the record that answers step 4;
  4. each remaining entry is asked whether adopting it would change anything β€” PrebuiltAssemblySeeder.IsAlreadyAdopted, which defers to the same NodeTypeBakeStatus.Classify the sweep's probe uses, plus one store probe at the record's LastCompiledVersion. An entry the store already backs is skipped entirely;
  5. only the deviating entries are extracted (BundleReader.Read(stream, nodePaths)) and adopted through PrebuiltAssemblySeeder.Seed: the bytes land in the assembly store under the node's version and the record is stamped exactly as a successful compile stamps it.

🚨 Step 4 is not an optimisation detail β€” adoption is expensive. Seed opens the type's own mesh-node stream, which activates its per-node hub, then re-uploads the bytes and writes the node. Before the skip, memex-cloud re-adopted all 43 of its assemblies on every boot β€” 43 activations, 43 uploads, 43 writes, 13.5 s of a 101 s warm-up β€” to establish that nothing had changed since the previous pod did the same. The framework identity is an API-surface hash and is stable across internal-only merges, so that is the common roll. It also grew the assembly cache by a whole generation per boot: Seed stamps the version it read before its own write, so each re-adoption uploaded the same bytes under a new key that nothing ever read.

The skip stays level-triggered on the store: the record's claim is believed only when a probe confirms the bytes are still at that key, so a cleared, remounted or stale-restored assembly volume re-seeds exactly as before (BakeState.BytesMissing). The reported count is adopted + already-current, so the coverage signal below does not collapse to zero on a healthy steady-state boot.

The sweep's store probe (NodeTypeBakeStatus) then classifies each adopted type Baked β€” for fully covered shipped content the boot log reads pending=0 and no Roslyn runs. Everything here is best-effort and loud: a corrupt bundle, an unadoptable identity, or a seed that cannot settle is logged and skipped, and the sweep compiles that type as it always has. Nothing certifies anything from this path β€” the bake gate keeps probing the store, which only ever holds what was actually adopted.

Adopt, then compile on demand β€” the boot bake is retired everywhere

Adoption and its coverage report are unconditional; only compiling is configurable. Every boot seeds both bundle sources and then probes the assembly store:

  1. both bundle sources seed as above (SeedAll, then SeedPublishedRoot);
  2. DynamicTypePreWarmer.ProbeDynamicTypes enumerates the mesh's dynamic NodeTypes and asks the assembly store about each β€” the same NodeTypeBakeStatus.Probe the sweep uses to decide what to build, stopping at the answer;
  3. the boot log reports adopted=N uncovered=M, and every uncovered type is NAMED at warning, with the BakeState and detail saying why.

PreWarm:DynamicTypes then decides only whether the leftover is also compiled at boot. It is false everywhere, including the fleet.

🚨 These two used to be one switch, and the fusion was a real defect: the chart's default is DynamicTypes=false, so the ordinary deployment adopted nothing β€” it ignored bundles sitting in its own image and lazily compiled every type instead. Splitting them is what makes "no boot bake" cheap rather than a regression.

Why the sweep is retired rather than tuned: adoption made it redundant. Once the satellite repos published under the live identity, a prod boot measured compiled=0 alreadyBaked=84 β€” the sweep compiled nothing and still charged 32.1 s of warm-up (64.8 s when adoption was broken). On a miss it was worse than useless: it blocked readiness on compiling types no user had asked for. On a laptop it was pure waste β€” one developer machine had 15 generations under /data/assembly-cache/.generations, fifteen full sweeps that rebuilt what CI had already built.

An uncovered type is still correct: it builds on first access, via NodeTypeEnrichmentHelpers.WaitForCompileSettled, when someone actually reaches it. Measured cost ~2.0–2.1 s per type locally (~2.4 s on the fleet), paid once, by that type's first visitor. That is also the deliberate escape for content with no CI bake by construction β€” a NodeType someone is authoring on a laptop has no published bundle and never will. What the report refuses is the silent version: a gap you only discover when a page renders empty.

What readiness means now

The bake gate certified a bake by compiling every type and refusing readiness when one that used to build no longer did. With nothing compiling at boot there is no such verdict, so PreWarm:GateReadiness is turned off rather than left armed β€” an armed gate with no sweep behind it reports healthy on every rollout and protects nothing, which is the exact failure it exists to prevent. (The portal already says so at Critical; NoValuesFileArmsTheBakeGateWithoutTheSweepBehindIt pins it at build time.)

What that gives up, precisely: a NodeType that regresses on a new image is no longer caught at rollout; it surfaces when a user first reaches it. What replaces it: the boot coverage report β€” a broken bake lane (an identity mismatch declines every bundle wholesale, #1725) shows up as a coverage collapse in the logs of the first pod of a bad roll.

🚨 Do not "fix" this by gating on full adoption coverage. uncovered > 0 is the normal steady state of a real portal: users author NodeTypes in their own partitions, and those have no CI bake by construction β€” the live memex share holds two such types under rbuergi. A coverage gate would never go ready.

🚨 A PreWarm__* key in a values file does nothing until the configmap renders it. deploy/helm/templates/memex-portal/config.yaml enumerates keys explicitly β€” it does not iterate .Values.config β€” so an untemplated key is dropped with no warning from helm, kubectl, or the portal. PreWarm__PrebuiltBundleRoot was set in values.aks.yaml from the day this lane shipped while the configmap never rendered it, so every chart-deployed portal ran with the consuming half inert and recompiled content CI had already baked for it. Adding a key to a values file is half the change; PlatformBakeLaneGuard.EveryPreWarmKeyInValues_IsTemplatedInTheConfigMap asserts the other half.

The delivery: main-cd bakes IN THE IMAGE, then publishes

main-cd's publish-bake job runs the content the image itself embeds β€” the Doc tree, staged by .github/scripts/stage-doc-gate.sh, the same staging the PR gate judges β€” in two steps against the mw-plugin-test image this very CD run built and promoted:

docker run … --entrypoint /app/mw-plugin-test "$IMAGE" compile /repo/doc \
  --output /bake --allow /repo/doc-gate.allow --source-sha "$SHA"     # the BAKE β€” no mesh
docker run … --entrypoint /app/mw-plugin-test "$IMAGE" /repo/doc \
  --allow /repo/doc-gate.allow --seed /bake                            # the GATE β€” consumes it

The gate still fails the job: the platform's own shipped content failing to render or execute its Tests areas against the image that ships it is a release defect, and splitting the steps must not lose that. PlatformBakeLaneGuard pins both halves β€” --bake-output (the mesh-driven bake) is banned in that job, --seed is required, and the bake must come first. The job then copies the resulting bundles to the portals' shared storage (.github/scripts/publish-bake-bundles.sh), laid out prebuilt-bundles/<identity>/<source>/<bundle>.zip with platform-surface.json beside them, sealed by a _complete sentinel written strictly LAST. Each booting pod seeds ONLY its own identity's SEALED source directories (ShippedPrebuiltBundles.SeedPublishedRoot, config PreWarm:PrebuiltBundleRoot) before its sweep β€” an unsealed or torn publication (a publish that died mid-way) is refused loudly and the sweep compiles instead. "Rebuild only when we need to" applies to the publish too: when the identity's directory is already sealed β€” an internal-only merge resolves the same surface identity as its predecessor β€” the script skips with a notice instead of re-uploading. See The Continuous Delivery Contract for the job's preflight discipline and the dependent-repo dispatch.

🚨 Replacing a publication unseals it first, on purpose, so nobody can read a mix of old and new bundles under a stale sentinel β€” which means the directory is deliberately unreadable for about a minute and a half, per target, per publish, and the plugins prefix has two writers. Sealed Publication Reads is the reader's half: the three answers that window produces (404 / 503 / 412), the generation that lets a multi-read consumer pin one publication instance, and what is still not closed.

🚨 CD compiles ONLY what the image embeds β€” everything else is adopted

The bake is scoped to src/MeshWeaver.Documentation/Data, the one tree every portal ships inside itself (Memex.Portal.Shared references MeshWeaver.Documentation). Nothing else, on purpose:

Content Who bakes it Why not CD
node-repo content (Plugins, Education, Reinsurance, SocialMedia) and Store packages each repo's own node-repo-publish-bake lane, against the same image β‡’ the same identity it arrives already compiled and is adopted; main-cd.yml checks out no other repository, so it could not compile them even by accident
samples/Graph/Data nobody β€” compile-gated only no deployment embeds them, and memex receives them over the GitHub link into the MeshWeaver partition, where node paths read MeshWeaver/samples/Graph/Data/ACME/… while bundles are keyed ACME/…. The seeder matches by node path, so the bundles are inert everywhere. Measured: 7 packages / 24 assemblies per CD run for bytes nothing can adopt

So the CD bake is 1 package / 4 assemblies, down from 8 / 28 when it also baked the samples. Correctness of the samples content is unaffected β€” dotnet-test.yml's doc-gate still compiles, renders and tests both trees on every PR. What changed is only that CD stops shipping assemblies no deployment can use. PlatformBakeLaneGuard pins both halves of this: the Doc tree must be baked, the samples tree must not, and the workflow must check out no other repository.

The end state this serves is a boot that compiles nothing: with the four satellites publishing under the pods' identity, a prod portal boot reached compiled=0 alreadyBaked=84 β€” everything adopted, nothing rebuilt. The platform's own Doc types are the remaining slice, and this lane is what delivers them.

Three properties fall out of baking in the image rather than shipping a CI artifact across jobs:

The platform deliberately does not call node-repo-publish-bake.yml even though the two lanes are the same idea: it authenticates to the registry by OIDC rather than the reusable workflow's username/password secrets, and it bakes two trees against two known-debt ratchets in one bake directory, where the reusable workflow bakes one mount. Both run the identical publish script, which is the part that must never drift.

Retention: the published store is pruned by REFERENCE, never by age alone

The current policy is the 30-day age window and release/consumer protection contract in Released Artifact Retention (#3842). It supersedes the earlier request to keep ten CI builds. Module repositories resolve the released platform at run time; retention must not restore platform pins.

Every CI build that publishes a bake adds one <root>/<identity>/ directory to the store, and until this pass nothing ever removed one. Measured 2026-09-08 on memex.systemorph.com through the memex API: the /data share (16384 MiB) had 3 MiB free; prebuilt-bundles held 13398 MiB in 482 identity directories, modules 2269 MiB, assembly-cache 704 MiB. A full share truncates writes silently and reports the failure far from the cause β€” every runtime NodeType recompile landed as Bad IL format, and CD's bake read-back got ResourceNotFound for 39 of 45 files.

The rule, stated once for two stores

The same required outcome applies to this store and the container registry (#3438):

An artifact that anything pins, names, runs or may adopt is kept β€” regardless of age and regardless of how many newer ones exist. Only an artifact NOTHING references is collected, and an artifact whose references cannot be READ counts as referenced.

Registry protection must cover advertised last-green sets and their consumers; the legacy pin scanner is still a migration component, not that complete inventory. In this store the following KEEP rules are ORed (PrebuiltBundleStore.Plan, MeshWeaver.Hosting):

# an identity directory is kept when… why that is a reference
1 it is the framework identity this process runs its own boot and every install-time adoption read it
2 a clean release marker _releases/X.Y.Z names it support has not been established as ended, so official releases stay available
3 a NodeType record's adoption stamp (CompiledFrameworkVersion, written by PrebuiltAssemblySeeder and by every local compile) names it a record was built under it; a re-seed may ask for it again
3b a Deployment reference or registered instance report identifies it, directly or through _releases/<version> (PinnedPlatformReferenceSource) running consumers need their adopted version regardless of age; an unresolved version aborts cleanup rather than keeping nothing
4 it holds the newest sealed publication per source for each represented major a registry can serve consumers on a different major from its own process; unsealed successors cannot displace this protection
5 its newest content write or a release marker naming it is younger than 30 days (or an explicitly longer MinimumAge) rapid publishing must not delete recent history; this covers sealed and unnamed identities alike
7 a source under it is unsealed and younger than UnsealedGrace additional in-flight publication protection; the 30-day floor also applies
8 its seal cannot be read unreadable is never unreferenced (the modules GC's #2509 rule)

Everything else is collected oldest first, one identity at a time, each removal logged with the bytes reclaimed. The sentinel of every source is deleted before the directory, so a reader that lists mid-removal sees "unsealed" and backs off rather than a sealed listing whose bundles are vanishing. The -ci markers of a removed identity β€” and of an identity already gone for longer than the minimum age β€” are removed with it, so _releases/ does not grow forever; a clean release marker is never removed. The release gates read the same directory (Release Availability Gates), so a retired -ci version simply reads as "published no bake" there, which is the truth once its bundles are gone.

Fail closed, the way ModuleSetStore.Prune does. A store that cannot be listed, or a release marker that cannot be read, or a consumer version that cannot be resolved, aborts the pass with nothing collected β€” which identity an unreadable marker names is unknown, so no identity can be called unreferenced. The NodeType stamps are read from the mesh on every pass (system-scoped, mesh-wide β€” the pre-warmer's own enumeration); an enumeration that cannot be taken aborts that pass too. A removal that fails is counted and the rest proceeds; a half-removed identity reads as unsealed to every reader and the next pass plans it again.

Scope. This sweep is identity-level. The per-source generation retention that follows a pointer swap (_current) is the publisher's, at the end of its own run β€” Sealed Publication Generations Β§ Retention β€” and this pass never reaches inside a kept identity.

Where it runs

PrebuiltBundleRetentionHostedService, registered by ConfigureMemexMesh beside the modules GC (AddModuleGenerationsGc) β€” the two are sibling collectors of the same volume and run the same way: registered at boot, never run there; kicked from ApplicationStarted, behind PreWarmCompletion so a listing of thousands of files on a network share never shares a window with the boot compiles; blocking filesystem work on the file-system IIoPool, never a hub. Then recurring, every Interval (default daily); passes never overlap. Inert without PreWarm:PrebuiltBundleRoot.

key default meaning
PreWarm:PrebuiltBundleRetention:Delete true false measures and reports only
PreWarm:PrebuiltBundleRetention:MinimumAge 30.00:00:00 may extend the window; values below 30 days cannot shorten it
PreWarm:PrebuiltBundleRetention:KeepNewestPerSource ignored legacy key retained for compatibility; no count-based cleanup rule
PreWarm:PrebuiltBundleRetention:UnsealedGrace 02:00:00 rule 7
PreWarm:PrebuiltBundleRetention:Interval 1.00:00:00 the recurrence

Every pass appends to the ledger <root>/_retention/ledger.txt β€” one line per pass (kept / would collect / collected, with bytes) and one per removed identity and retired marker β€” and records its last result on PrebuiltBundleRetentionStatus (a mesh-scoped singleton). There is no on-demand operator surface in core: the assembly-cache-prune Job is a chart object in the private Memex repository, not a wire message, and none is invented here; a pass on demand is a pod restart.

The free-space signal

/health now carries data_volume_free_space (DataVolumeHealthCheck, Memex.Portal.ServiceDefaults, evaluated by DataVolumeFreeSpace in MeshWeaver.Hosting): Degraded β€” never Unhealthy, pulling the pod frees nothing β€” when the volume any configured store root sits on (PreWarm:PrebuiltBundleRoot, Modules:Root, plus any DataVolume:Paths) has less than DataVolume:MinimumFreeBytes free (default 1 GiB), naming the path, the used and total bytes; Degraded too when a configured path cannot be measured; Healthy with nothing configured. The retention above is what keeps it green.

Node repos run the same lane β€” as reusable workflows

Every satellite content repo (MeshWeaver.Plugins, MeshWeaver.Education, MeshWeaver.Reinsurance, MeshWeaver.SocialMedia, MeshWeaver.Crm, MeshWeaver.Manufacturing) bakes and publishes its own content through the SAME contract, and since #1707 the jobs live HERE, as reusable workflow_call workflows the satellites call instead of vendoring. Adoption is per job: the target is that every repo calls node-repo-publish-bake (the lane whose script contract must not drift), while a repo whose variant of a gate carries repo-specific machinery (Plugins' Tests-area ratchet, Education's course checks) keeps that job vendored until the machinery generalizes.

🚨 node-repo-validate is not one lane among several β€” it is where the FLEET-WIDE guards run

The other lanes do a repo's own work. node-repo-validate also carries the checks that apply to every repository, and that makes a hand-rolled copy of it a different kind of mistake:

The guards live INSIDE the shared lane. A hand-rolled copy therefore opts out of every guard that lane grows LATER β€” silently, retroactively, and invisibly from the repo that made the copy.

check-workflow-timeouts.py (the 45-minute cap) and check-pr-secret-preflight.py (every PR-reachable secrets.NAME is asserted by a preflight) are both invoked inside it. Neither existed when the older copies were made, so nobody chose to skip them β€” the copy chose, months of commits later. A repo that forked a lane before a guard was written looks identical to one that passes it.

Calling the lane is NECESSARY and NOT SUFFICIENT β€” the PIN must carry the guard

🚨 This is the second half of the same trap, and it is the one an adoption table cannot see. The lane is pinned by a 40-character sha; a caller whose pin PREDATES a guard calls the lane and still does not run it. Measured against every satellite's main, 2026-09-07:

repo node-repo-validate pin secret guard in that pin check-pr-secret-preflight verdict on its main
MeshWeaver.Reinsurance 1b5350d54 βœ… 0 violations
MeshWeaver.Manufacturing 1b5350d54 βœ… 0 (adopted 2026-09-07, #3504)
MeshWeaver.Plugins c7fef7a2d βœ… 0 (adopted 2026-09-07, #3504)
MeshWeaver.Crm 0a2b9017d β†’ c7fef7a2d ❌ β†’ βœ… 4 β€” incl. MW_REGISTRY_KEY β†’ 0 (adopted 2026-09-07 12:30Z, 9228b8be)
MeshWeaver.SocialMedia 0a2b9017d β†’ c7fef7a2d ❌ β†’ βœ… 4 β€” incl. MW_REGISTRY_KEY β†’ 0 (adopted 2026-09-07 12:30Z, 9dea3668)
MeshWeaver.Education 8ffbe4762 β†’ c7fef7a2d ❌ β†’ βœ… 4 β†’ 0 (adopted 2026-09-07, Education#283)

Reinsurance is the control, and it is what makes the table evidence rather than an assertion: it is the only pre-existing caller whose pin carried the guard from the start, and it was the only pre-existing caller at zero. Every other row was a repository that called the lane and was not checked by it β€” until the pin moved. The arrows record the move (MeshWeaver#3576), re-measured the same afternoon with the guard at core main against each repo's origin/main: 8/8 secrets asserted on Crm, 9/9 on SocialMedia, 6 of 8 reachable on Education with none unasserted β€” 0 violations on all three. 🚨 A caller whose lane sha carries the guard but whose paired platform-ref does not is STILL not checked: the lane fetches the guard at platform-ref, and answers "a pin older than the guard needs the older lane" (Education#278, a Dependabot bump of the lane alone, red for exactly this) β€” which is why the two move in ONE commit.

🚨 And MW_REGISTRY_KEY in that column is not a hypothetical: an absent MW_REGISTRY_KEY consumed by a PR-reachable job no preflight asserts is exactly Reinsurance#128 β€” compose-sealed-modules.sh: --registry-url needs --registry-key, a message that names no secret at all, one job after a GREEN preflight. The three repos above are one Dependabot pull request away from the same log.

So a lane pin is not only a reproducibility knob. The staleness reporter in each caller's preflight ("Shared CI logic pinned to <sha> β€” cut N days ago") exists for this: a pin nobody bumps is worse than a moving ref, because the divergence is silent. Bump every uses: and its paired platform-ref in ONE commit.

Workflow Job it unifies
.github/workflows/node-repo-validate.yml JSON/manifest shape gate β€” the caller's scripts/validate-repos.py, plus the PLATFORM's .github/scripts/gen-manifests.py (--check, main-only --check-versions) fetched at platform-ref like compile-check.py, configured by the caller's scripts/gen-manifests.config.json
.github/workflows/node-repo-compile-check.yml the compile gate β€” every NodeType's resolved Source vs the assemblies of the digest-pinned platform image
.github/workflows/node-repo-gate.yml the tester gate β€” mw-plugin-test over the (optionally affected-narrowed) mount, cross-repo requires staged in; since #3022 executed by the tester as the portal (platform-image, composed gate host, --app /app)
.github/workflows/node-repo-publish-bake.yml the main-only bake + publication β€” compile --output then --seed over the full repo or (opt-in) the affected closure, staged-module exclusion, OIDC publish via the canonical publish-bake-bundles.sh; since #3022 the bake compiles against and is keyed to the portal (platform-image + platform-image-digest, both required-or-explicit exactly like the tester's)
.github/workflows/node-repo-tag-modules.yml the <Module>/vX.Y.Z tag publisher (scripts/tag-modules.py)
.github/workflows/node-repo-platform-ref-bump.yml the scheduled MW_PLATFORM_REF bump β€” polls the upstream's default branch and opens a PR (never a push) so the source pin cannot silently lag; mints a GitHub App token, because a pull request opened with GITHUB_TOKEN starts no CI at all. Called by MeshWeaver.Plugins and MeshWeaver.SocialMedia β€” the two node repos that carry such a pin. See Keeping the Platform Source Pin Current

The design rules the extraction preserves:

Rebuild only what a change AFFECTS β€” narrow-by-affected

"When updating plugins, we should check from git history which modules are affected, and we should rebuild only these."

The bake used to be the whole repo, every time β€” every main push and every scheduled release poll ran ~40 minutes of Roslyn over every package to republish bundles that, for all but a handful of them, were the ones already sealed in storage. The stated reason was the atomicity of the publication, and it was a real constraint applied to the wrong half of the job:

the _complete sentinel is written LAST and lists the whole bundle set, and a portal seeds only what the sentinel lists β€” so publishing a delta would not ADD bundles, it would REPLACE the sentinel and shrink what every portal adopts.

That constrains what is uploaded. It says nothing about what must be recompiled. With narrow-by-affected: true the two are separated:

compile only the modules the diff affects (+ their dependencies, because the tester's fresh mesh installs what it mounts)
publish still the complete set β€” every bundle not rebuilt is carried forward from the current publication before the seal

Three pieces, in order:

  1. bake-scope.sh decides. 🚨 Its baseline is the PUBLICATION, not github.event.before β€” the sealed directory carries source-commit.txt, and that is the only commit that answers "what changed since the bundles that are actually out there". Diffing the push instead would silently under-build after any run that did not publish: a cancelled run, a superseded push, a red gate, a re-run of an older commit. Reading the baseline off the publication is self-correcting β€” whatever was published is what we diff against, however many runs it took.
  2. The caller's scripts/affected-modules.py answers. It is the caller's file because the dependency edges are the caller's content, and it mirrors the runtime's own resolution 1:1 (LocalNodeRepo.CollectDependencies): changed modules β†’ transitive dependents β†’ their dependencies, emitted dependencies-first. It ships its own --self-test.
  3. carry-forward-bundles.sh keeps the publication whole: every bundle the sealed listing names that this bake did not produce is downloaded into the bake directory, so publish-bake-bundles.sh runs completely unchanged over a full set and the resulting publication is indistinguishable from a full bake's.

The bias is toward a full bake, always, and out loud. Narrowing a build is the shape that produces a silent under-build β€” a module that should have been recompiled and was not becomes a stale assembly every portal seeds at boot, and the evidence of the miss is the absence of evidence. So each of these resolves to a FULL bake, naming itself in the log and the job summary:

🚨 The MODULE lane's copy of this decision β€” node-repo-scope.py β€” had two blind spots that made "a repo-root file" and "the two NOOP_DIRS copies differ" cost a full run each, measured at 105 jobs / 345 job-minutes for a one-file documentation diff and at every pull request three satellites ever opened. Both are closed, with the falsification evidence and the numbers: What a Pull Request Rebuilds.

And one verdict that is neither: scope=none, when the sealed publication already records this commit for this identity. It is a positive finding β€” read from every target, logged with both shas and an explicit green step, never a grey skip β€” and it is what the every-30-minutes release poll hits almost every time it runs. publish-bake-bundles.sh already skipped the upload in that case; nothing had ever gated the 40-minute build in front of it.

A missing carried-forward bundle is fatal: the only alternatives are "publish less than is published today" and "stop", and shrinking is the one that fails silently. The job goes red with the bundle named, and the existing publication stays sealed and intact because nothing has been written yet.

narrow-by-affected is mutually exclusive with pre-bake-script (a hook that builds its own mount owns its composition) β€” asserted in preflight, not resolved silently at run time. Both scripts carry --self-test, run on every platform PR from dotnet-test.yml's preflight job, and both are proven non-vacuous by mutation: delete any single fallback and the step goes red.

Still full, deliberately: a framework release. A new identity has no publication to carry forward and every module must be compiled against the new binaries β€” which is also what makes that bake the gate that catches an API removal (the 2026-08-09 AddTracking outage) before every portal parks at its next restart.

πŸ”’ The workflow ref is PINNED β€” and bumping it is a deliberate act

Every satellite calls these workflows at an immutable commit, never at @main:

  test-repos:
    needs: [preflight, validate]
    uses: Systemorph/MeshWeaver/.github/workflows/node-repo-gate.yml@731620dc6be030c964aa2c6a1e87ac11a1e6bfc4

Why. The platform image is pinned by digest so CI is reproducible and an image regression lands on the commit that bumps the pin. The CI logic needs that for the same reason and with a wider blast radius: on @main, a single edit to a reusable workflow changes every satellite's gates at once, no satellite's PR can reproduce yesterday's behaviour, and "did my change break this, or did the shared workflow move under me?" stops being answerable β€” that exact question cost a full day on MeshWeaver.Education. Pinned, the answer is in git log of the caller's own ci.yml.

Why a SHA and not a version tag (node-repo-workflows-v1 and friends):

GitHub does not allow the uses: ref to come from an input, an env, or any expression β€” it must be a literal β€” so a workflow-ref input is impossible and the SHA lives literally on each uses: line in each caller.

A reusable-workflow change does not reach the satellites until each one bumps. That is the point, not a bug: it is what turns a shared-workflow regression from a simultaneous four-repo outage into one satellite's PR that goes red and is trivially attributable.

How a bump is triggered

The pin is bumped by the person who changes a reusable workflow, as the last step of that change β€” the platform PR lands first, then one follow-up PR per satellite. Concretely:

  1. Merge the node-repo-*.yml change to the platform's main; note the merge commit.
  2. In each satellite that calls the changed workflow, replace the SHA on every Systemorph/MeshWeaver/.github/workflows/node-repo-*.yml@… line β€” all of them, in one commit, so a repo never runs two different revisions of the shared contract.
  3. Open the PR and let the repo's own gate suite run against the new logic. This is where a bad shared workflow surfaces: on the bumping PR, in the repo it affects, attributable to the bump.
  4. Repeat per satellite. They may lag each other; each bump is independently revertable.

Adopting a new workflow additionally renames that repo's required contexts (previous bullet); a plain bump does not.

Staleness is surfaced, not scheduled

A pin nobody bumps is worse than a moving ref: the satellites diverge in silence and the shared workflow's fixes never land anywhere. So each caller's preflight job prints the pin's age on every run β€” in the job someone already opens when a gate goes red:

workflows pinned to 731620dc6…, cut 3 days ago (2026-08-17T09:45:53Z)

Past STALE_AFTER_DAYS (30) the step summary adds a "a bump is due" callout pointing back here. This is the same instinct as the known-debt allow-lists β€” surface staleness at the point of use rather than inventing a place people must remember to check β€” and it is deliberately not a scheduled job that opens issues.

Two properties that make the age trustworthy rather than decorative:

The one ref that still floats, on purpose

node-repo-publish-bake's platform-ref input still defaults to main. It selects the checkout of the canonical publish-bake-bundles.sh, whose bundle layout and _complete sentinel must keep matching the ShippedPrebuiltBundles constants in the portal that consumes the bundles β€” and the portals self-update from main. Pinning that to a satellite's cadence would let a satellite publish in a layout the live portals no longer read. It is the publish script tracking its consumer, not CI logic tracking a moving trunk; pin it (the input exists) only to bisect a script regression.

The satellites' OIDC publish is provisioned (2026-08-17): the Azure managed identity github-actions-bake (in the cluster's resource group) holds Storage File Data Privileged Contributor on the portals' storage account and carries 8 federated credentials β€” the four satellite repos Γ— the two GitHub subject formats (classic and immutable; register both, always β€” see The Continuous Delivery Contract) β€” with the AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_SUBSCRIPTION_ID secrets set on all four repos. A red publish-bake was designed debt until 2026-08-17 and is a real failure after it β€” treat any surviving allowlist or "credentials pending" reference to a satellite's publish lane as historical.

🚨 Following the release is a POLL in every satellite β€” and only one repo had it

This is the single defect that makes a fleet boot on an identity nobody baked, and it has been rediscovered at least four times. Read this before touching a bake trigger.

Two cadences produce the bundles and consume them, and they do not match:

minted by how often (measured 2026-08-22)
a framework identity every platform main merge (promote phase C arms memex-portal-ai:<version>) 1733 release-shaped portal tags
a bundle for that identity a satellite publish-bake run a handful of pushes per repo per day

An instance self-updates to the newest release tag. If no satellite baked against that release, prebuilt-bundles/<identity>/<source>/ does not exist, every pod falls back to the in-mesh Roslyn sweep, and the boot takes minutes instead of seconds. The bundles are not missing because publication is broken β€” they are missing because nothing told the satellites a release happened.

So a satellite must follow the release. The event is memex's: the registry's PlatformBuildInboxWatcher dispatches meshweaver-framework-released to every repository the Hosting/Deployment records name as a registry source, from the build fact core CD POSTs into Hosting/PlatformBuilds β€” core itself dispatches to no repository (its notify-dependents fan-out, which discovered subscribers by reading other repositories, was withdrawn on 2026-09-03; the earlier PAT-driven variant was removed on 2026-08-21 for the same reason β€” credentials that let one repo drive another's CI are not the architecture we want). The schedule poll below is the FALLBACK, and a satellite carries it because a lost dispatch must cost one delayed wave, never a fleet held on stale bundles. A poll needs nothing provisioned and nobody to remember; it uses the ACR credentials the satellite already has for its gates.

The four parts β€” a repo has ALL of them or it follows nothing

  1. on: schedule: with a cron. Stagger the minute per repo so four repos do not wake together.

  2. A framework-release concurrency lane that schedule shares with repository_dispatch:

    concurrency:
      group: ${{ github.workflow }}-${{ (github.event_name == 'repository_dispatch' || github.event_name == 'schedule') && 'framework-release' || github.ref }}
      cancel-in-progress: true
    

    🚨 The schedule MUST be in that lane. A scheduled run carries the DEFAULT BRANCH ref, so left in the github.ref group it shares the main-push group and β€” with cancel-in-progress β€” a half-hourly poll can cancel a main run part-way through tag-modules, work the scheduled replacement does not do and nothing else recovers. Release polling supersedes release polling; it never supersedes a push.

  3. A FOLLOW_RELEASE bake-target resolution in preflight that resolves BOTH images of one wave β€” the tester AND the portal β€” and passes both down; on a push keep both pins (that bake certifies the bits the gates just ran). It must fail loud when either digest cannot be resolved β€” a silent fall back to a pin republishes an already-published identity and leaves the instance held, reporting success.

    🚨 BOTH, from ONE tag. Resolving only the tester is the defect this list used to prescribe (this page said "resolve the digest MW_TEST_IMAGE currently points at" and stopped there, which predates the portal becoming a second required image in MeshWeaver#3022). A caller that moves the tester on a release trigger and leaves platform-image-digest: a literal composes two CD waves, and the lanes refuse it by name:

    framework-identity: MISMATCH β€” the bake published under 's4e84d301…'
    but '/portal' resolves 's556a0d43…'.
    ::error::the tester image and the platform image do NOT resolve one framework identity
    

    Measured 2026-09-08: MeshWeaver.Manufacturing's poll failed this way in its test-repos gate (Manufacturing#67) and MeshWeaver.Crm's in its publish-bake (Crm#66) β€” two repositories, one recipe, both of them faithful to what this page used to say. MeshWeaver.Reinsurance#179 is the landed shape.

  4. schedule in the publish-bake job's if: β€” parts 1–3 do nothing if the bake itself is still gated to pushes and dispatches.

What a wake is entitled to resolve β€” the set is named by the wake, never by a floating tag

trigger how it learns the identity tester portal
push / pull_request the repo's PIN MW_IMAGE_DIGEST MW_PORTAL_IMAGE_DIGEST
meshweaver-framework-released client_payload.version <tester>:$version <portal>:$version
meshweaver-upstream-published the upstream's payload client_payload.image client_payload.platform_image
schedule (the poll) it resolves a moving tag itself <tester>:main <portal>:main

🚨 :main, never :latest, for the poll. promote phase B applies main to every repository of the set but latest to mw-plugin-test only β€” the portal repository deliberately carries no latest, so deriving the portal's tag from vars.MW_TEST_IMAGE's :latest yields 'memex-portal-ai:latest' returned no digest and reddens every release wake (Reinsurance run 34179478079, 2026-09-08). mw-plugin-test:main and :latest are the same object, so the poll sees exactly what it saw before; what changes is that the portal is now resolvable at the same tag.

🚨 node-repo-publish-bake.yml already resolves branch for branch and node-repo-gate.yml does not. The bake lane reads client_payload itself, so a dispatch wake composes one wave there even when the caller hands it a mixed pair β€” which is why Crm's meshweaver-upstream-published wakes are green while its poll is red. The gate lane reads no payload at all and takes the caller's two inputs verbatim. So the caller is the only place where both lanes can be made to agree, and it must resolve both halves itself on every release trigger.

A missing upstream publication is a WAIT β€” and it still fails RED

upstream 'plugins' has no SEALED publication … for identity <id> is a fact about the upstream, not about the repository whose run is red. Both lanes refuse it β€” node-repo-gate.yml's seed and node-repo-publish-bake.yml's check-release-availability.sh gate β€” and both refuse on every trigger. There is no exit-zero arm, no continue-on-error: and no if: asking about an input: a gate that cannot fail is not a gate. What the event changes is what is said, never whether the gate runs.

the run was it learned the identity from an unsealed upstream means
push / pull_request the repo's PIN the PIN is wrong β€” move it to an identity the upstream has published for
repository_dispatch the wake's own payload the wave is mid-flight β€” a WAIT
schedule a moving tag the run resolved itself nobody promised the upstream published for it β€” a WAIT

What re-enables a waiting run is the upstream's own publish-bake: on sealing, it POSTs the publication to the control instance, which dispatches meshweaver-upstream-published to every repository declaring that source. The wake carries both images of the upstream's wave, so the rebuild lands on the identity the dependency is actually sealed for. Measured 2026-09-08 on MeshWeaver.Crm β€” a meshweaver-framework-released run failed the availability gate at 15:36Z and the meshweaver-upstream-published wake for the same set succeeded at 16:01Z.

🚨 The poll does NOT retry the identity it failed on, and this lane's message used to claim it did. A poll re-resolves a moving tag, so the next run asks about whatever has been promoted since β€” a different identity. Waiting for tomorrow's poll to clear today's is waiting for something that never happens. The dispatch above is the only thing that re-asks the question, and if no wake ever arrives then the upstream is not publishing for released identities at all β€” that is the upstream's red, visible on its own publish-bake and its own poll, and nothing in the dependent repairs it.

🚨 Do NOT "fix" this by having the poll walk back to the newest identity that HAS a complete publication. It reads like a narrowing and it is a fallback: an upstream that stops publishing would leave every dependent's poll green forever, which is the same trapdoor as falling back to the pin β€” "a silent fall back to the pin republishes an already-published identity and leaves the instance held, reporting success", one bullet up. The condition is reported once, at its source.

Use docker manifest inspect -v … | jq '… .Descriptor.digest'. Not imagetools inspect --format '{{.Manifest.Digest}}': that template reads a member an OCI manifest does not carry, so it yields nothing and every scheduled run takes the fail-loud branch.

Measured 2026-08-22 β€” three of four repos followed nothing

Counting occurrences in each repo's origin/main:.github/workflows/ci.yml:

Repo schedule: FOLLOW_RELEASE followed releases?
MeshWeaver.Plugins 1 2 βœ…
MeshWeaver.Education 0 0 ❌ pushes only
MeshWeaver.Reinsurance 0 0 ❌ pushes only
MeshWeaver.SocialMedia 0 0 ❌ pushes only

All four follow releases as of 2026-08-22 (Education #195, Reinsurance #80, SocialMedia #46), on staggered crons in dependency order: Plugins 17,47, SocialMedia 7,37, Education 32 (hourly β€” a run there boots four disposable meshes), Reinsurance 22,52.

🚨 MeshWeaver.Education satisfies part 3 differently, and the grep below reports it as a ZERO. It has no pin to diverge from: it deliberately tracks mw-plugin-test:main, so its existing "Resolve the bake image" step already targets the current release on a poll and it carries no FOLLOW_RELEASE variable at all. That is correct, and :main IS the newest promoted release β€” promote phase B moves the tag in the same job that arms the release in phase C. Do not "fix" it by adding the variable; check that the repo resolves a released image on its poll, by whatever means its bake target is chosen. Counting a string is a shortcut, and this is the row where the shortcut lies.

All three carried the repository_dispatch receiver and a comment calling it "DORMANT until the platform provisions…" β€” so the wall of green ticks was truthful about the gates and silent about the fact that nothing had baked for the current release in weeks.

🚨 A dormant receiver reads exactly like an armed one. That is the whole trap, and it is the same shape as if: ${{ vars.X != '' }} on a gate: the evidence that it did not run is the absence of evidence. Do not conclude a repo follows releases because it has a repository_dispatch: block.

Re-measure before acting on that table β€” it is a snapshot, and this is exactly the kind of thing that gets fixed in one repo and left in three:

for r in MeshWeaver.Plugins MeshWeaver.Education MeshWeaver.Reinsurance MeshWeaver.SocialMedia; do
  printf "%-26s " "$r"
  c=$(gh api repos/Systemorph/$r/contents/.github/workflows/ci.yml --jq .content | base64 -d)
  echo "schedule=$(echo "$c" | grep -c '^  schedule:')" \
       "bake-runs-on-poll=$(echo "$c" | grep -c "event_name == 'schedule'")"
done

schedule=1 and a non-zero bake-runs-on-poll are the two that hold for every repo. How the bake TARGET is resolved is the third part and it varies (see the Education note above), so read that one rather than counting it.

The production signature

When this is missing you do not see a red gate. You see:

Every one of those reads as "the bake is broken". The bake is fine. Nothing invited it.

See also Release Availability Gates β†’ "How a fleet goes stale while every check is green" β€” the instance-side half of the same defect, and why the dispatch was REMOVED rather than provisioned: a publisher must not know its readers, and the credential to tell them is not worth holding.

Measured in production, 2026-08-17 β€” for satellite content the lane is not a design any more, it is observed behaviour. On memex running 3.0.0-rc4.ci.4049 (identity s377941f549f721e01ac764e0fb8db84a), boot adopted 68 prebuilt assemblies from 31 sealed bundles in 18.9 s and Roslyn-compiled zero healthy types (warm-up 32.1 s, compiled=0, alreadyBaked=84). The comparable boot before any satellite bake existed did 80 compiles in 64.8 s.

🚨 That measurement is satellite content only. The platform's own publication is NOT adoptable today β€” issue #1725: the platform bakes from CI build output while the pod resolves its identity inside the shipped image, so the identities differ and every boot recompiles the platform's shipped types. The satellites escape it precisely because they bake INSIDE the image.

What this step does not do yet

See also: Plugin Packaging (the compilation-unit rules and the MVID rationale) Β· Build Coordination (who bakes when several replicas boot) Β· Modules Β· Release Availability Gates (who READS these publications, and the _releases/<version> marker that names each release's identity).

A reconcile must know which release it heals

The release version (3.0.0-rc8.ci.<run#>) is minted by portal-image per run, and a bake-only reconcile skips that job β€” so its output is empty there. The first reconcile that ever fired (2026-08-27, run 33063843072, once #2491 gave the cron its own concurrency lane) published the bake correctly and then failed the availability assert on an empty argument; quietly, it had also written no release marker, which is the one thing a reconcile exists to write.

The version is not recomputable, but it is recorded: promote's Phase C arms the release as memex-portal-ai:<version> on the same digest Phase A tagged <short-sha> β€” the tags SelfUpdateHostedService rolls from. So publish-bake resolves the version ONCE, in a release step (this run's output, else the promoted image's tag set via az acr manifest list-metadata), and both the publish and the assert read that step. A sha with no version tag was never armed as a release; the step stops loudly rather than publishing a marker for an invented version. Pinned by PlatformBakeLaneGuard.PlatformBake_ResolvesTheReleaseVersionOnce_AndEveryConsumerReadsIt.

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