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:
- one prebuilt-assembly bundle per package β
<package>.zip, written byBundleWriter(MeshWeaver.Plugin.Packaging): themeshweaver/manifest.jsonmanifest (node path β assembly, framework identity, source-version provenance, and β #1707 slice 2 β each assembly's per-type dependency record, which the consumer validates against ITS environment before adopting and stamps on adopt) plus each compiled assembly and its symbols; framework-mvid.txtβ the framework identity every bundle in the directory is keyed to.platform-surface.jsonβ the platform's TYPE SURFACE (MeshWeaver#3651): every assembly the bake compiled against and the full type names each exports, keyed to the same identity. The bake runs inside the platform image, so it is the one process that can write what that platform carries; the release gate reads it back to link an instance's landed modules against a release that is not running anywhere it can reach β see The Module Platform Link Gate.
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 address check, before the mesh boots. A bake keyed to a framework identity this process
does not resolve would be declined assembly by assembly, and the gate would compile the whole
tree itself and exit GREEN having judged none of the bytes that ship.
--seedrefuses such a directory as a usage error naming both identities. Same for a directory with no bundles, noframework-mvid.txt, or bundles from mixed producers. - The consumption postcondition, after the run. The gate is RED if the bake declared assemblies for types it installed and adopted fewer. Adoption leaves no trace in a gate verdict β an adopted type renders and tests exactly like a compiled one β so "the consuming half silently stopped working" cannot be noticed unless it is a verdict.
π¨ 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:
publish-bake-bundles.shseals the composed bundles WITH the publication βprebuilt-bundles/<identity>/<source>/modules/<pkg>.module.nupkg, listed inmodules/_index, written strictly before_complete. A bake that composed nothing seals an empty index, so a reader can tell "composed nothing" from "predates module sealing"; a sealed publication with no index is republished on the source's next bake even when its content is unchanged β that is what converges the fleet without anyone re-baking by hand.- The registry serves the set:
GET β¦/prebuilt/<identity>/<source>/modules(the index's list, 404 saying "predates module sealing" for an old seal) andβ¦/modules/<bundle>(listed names only). compose-sealed-modules.shβ called bynode-repo-gate.yml,node-repo-compile-check.ymlandnode-repo-publish-bake.ymlwhenever the repo declares an upstream (upstream-seed/upstream-sources) β takes eachregistry-modulespackage from the first upstream whose seal lists it. No fallback: an upstream with no seal for the identity, a seal without a module set, or a package no upstream sealed is RED naming the identity. Falling back to the registry would reproduce the decline under a green tick. A repo that declares no upstream still composes from the registry, and the log says so in a::notice::β those bytes are the module lane's, and a decline against a publication that composed different ones is the reason to declare the upstream.
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:
- Query EXPANSION is not re-implemented.
CodeQueryResolver.ExpandAllis the same call the runtime makes, so$self, thename=prefix, the@/@@shorthand, the bare-namespace rebase and the implicitnodeType:Codefilter cannot fork. The same is true of the@@-include walk, the dedup/executable filter, the join order, the skeleton and the emit β the tree baker is an orchestrator of the runtime's own shaping, not a parallel copy of it. - Query EVALUATION refuses what it does not understand. Only
path:,namespace:,scope:andnodeType:are supported β everythingCodeQueryResolvercan emit. Free text (which routes to vector search on a real mesh), wildcards, alternations and any other selector make the resolution unestablished, and the bake then refuses to compile rather than matching less. This is the same fail-loud directionSourceSnapshottakes at runtime and for the same reason: a source set that is short compiles into completely genuine-lookingCS0246/CS0103diagnostics about code that is fine.
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:
- This is why the framework identity is SURFACE-based, not byte-based. Hashing emitted bytes
could never have worked as an identity: it would change on every run without anything changing.
FrameworkBuildIdentityhashes reference assemblies β the compiler's own definition of an API surface β precisely because that is stable where bytes are not. - Any "did these two bakes produce the same thing?" check must compare SURFACE, never hashes.
Comparing digests of bake outputs will report differences that do not exist, on every run.
BakeEquivalenceTestcompares node paths, resolved source sets, dependency records and the emitted assemblies' type-and-member surface; that is the shape such a check has to take. - The compiler-driven bake is deterministic (query order, then ordinal by path). That is strictly better and costs nothing, but it does not make the two producers byte-equal β the old lane is the non-deterministic one. What the surface comparison proves is that the concatenation order of independent top-level declarations does not affect what is emitted, which is why the old lane's non-determinism was survivable.
π¨ 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
ls /app/prebuilton the pod β if empty, the image lane contributes nothing (it does not today).ls /data/prebuilt-bundles | wc -lβ the store; then whether THIS image's identity is among them.- The bake job's
bake published: identity=β¦versus the instance'sheldReasonidentity. 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:
- a bundle is adoptable across images and internal-only merges β the bake taken from image X seeds at boot on image Y whenever nothing in the content-facing surface changed between them ("rebuild only when we need to");
- a breaking surface change (or any toolchain change) mints a new identity β every cached and published build for the old surface is stale, and the next release bakes fresh;
- a declined bundle costs exactly what today costs β a compile. Shipping bundles is strictly safe; declines are logged with both identities.
π¨ 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:
feat: Excel/CSV import becomes its own module(82481e024, merged 18:46 that evening) movedMeshWeaver.Importand its private closure βMeshWeaver.DataSetReader{,.Csv,.Excel, .Excel.BinaryFormat,.Excel.OpenXmlFormat,.Excel.Utils}andMeshWeaver.DataStructures, eight canonical names β out of both portals' compile reference graphs into themodules/<Name>/runtime lane. Correct on its own terms: the module lane still contributesMeshWeaver.Importto the in-mesh compile reference set.The manifest is written from
@(ReferencePathWithRefAssemblies)β a host's compile references β so those eight lines vanished from the portal's manifest only.mw-plugin-test, the bake host, still referenced them.Measured on the shipped images of
3.0.0-rc4.ci.4276, both--platform linux/amd64:image manifest resolves mw-plugin-test(bakes)38 lines s7293e54297ec28e213bd82f30d59e709memex-portal-ai(runs)54 lines sa6d587a25d64d11774f22348664bca0cThe 29 shared entries had byte-identical hashes. Presence, not drift, was the entire difference β and the net line counts (38 vs 54) hide it, because the portal legitimately carries 25 Blazor/Orleans/hosting names that are outside the canonical set by design.
Consequence: every bake was published, intact, under an address no pod ever opened. Publication succeeded, the CD job was green,
check-release-availability.shpassed β and each deploy's first pod loggedcompiled=269 alreadyBaked=0and spent 10 m 29 s (+1598 MB working set) recompiling what CI had already compiled. During that window ~12 instance hubs latched a compilation-fallback card and served it to anonymous visitors long after the compile finished.
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.
Offline, at PR time.
CanonicalContentSurface_IsRecordedByEverySurfaceManifestHost(FrameworkBuildIdentityTest) recomputes, from the csproj graph, the compile closure of every project that setsMeshWeaverSurfaceManifest=trueand fails naming any canonical assembly a host does not record.CanonicalList_MatchesTheTesterClosurehad 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.On the artifact, at release time.
main-cd'spublish-bakejob resolves the identity of the promotedmemex-portal-aiimage and compares it with the identity the bake published under, before publishing. The comparison runs the bake image's ownframework-identityverb:mw-plugin-test framework-identity <app-dir> [--expect <identity>]which resolves the identity of another host's
/appfrom 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:
- every
*.zipunderprebuilt/(override:PreWarm:PrebuiltDirectory) has its manifest read withBundleReader.ReadManifestβ a few KB at a known entry, no assembly decompressed; - the bundle's framework MVID is checked once against the running process; a mismatch declines the whole bundle, loudly;
- 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;
- each remaining entry is asked whether adopting it would change anything β
PrebuiltAssemblySeeder.IsAlreadyAdopted, which defers to the sameNodeTypeBakeStatus.Classifythe sweep's probe uses, plus one store probe at the record'sLastCompiledVersion. An entry the store already backs is skipped entirely; - only the deviating entries are extracted (
BundleReader.Read(stream, nodePaths)) and adopted throughPrebuiltAssemblySeeder.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.
Seedopens 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:Seedstamps 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:
- both bundle sources seed as above (
SeedAll, thenSeedPublishedRoot); DynamicTypePreWarmer.ProbeDynamicTypesenumerates the mesh's dynamic NodeTypes and asks the assembly store about each β the sameNodeTypeBakeStatus.Probethe sweep uses to decide what to build, stopping at the answer;- the boot log reports
adopted=N uncovered=M, and every uncovered type is NAMED at warning, with theBakeStateand 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 > 0is the normal steady state of a real portal: users author NodeTypes in their own partitions, and those have no CI bake by construction β the livememexshare holds two such types underrbuergi. 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.yamlenumerates keys explicitly β it does not iterate.Values.configβ so an untemplated key is dropped with no warning from helm, kubectl, or the portal.PreWarm__PrebuiltBundleRootwas set invalues.aks.yamlfrom 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_IsTemplatedInTheConfigMapasserts 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 identity always matches, by construction β producer and consumer are the same binaries, so there is no compatibility question left to get wrong;
- the bake is a stronger gate, not just a producer: it proves the platform's shipped content
compiles, renders and passes its
Testsareas against the binaries that actually SHIP. A red bake fails CD loudly (the images are already promoted; nothing silently ships less); - there is no "nothing to publish" state. The old lane had one β a reuse-green Build-and-Test run produced no artifact and the publish warned and skipped β which is the shape that let a lane publishing to an unusable identity look healthy for a whole release train.
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:
- Every externally-provisioned value is an explicit input/secret β nothing implicit, so the
publish-bake preflight can assert the full set and fail RED naming what to provision. The
caller keeps its own
preflightjob (and the fork exemption) for the gate lane, and gates run unconditionally behindneeds:β no input-shapedif:anywhere (no skip-trapdoors). - The publish script has one home β
publish-bake-bundles.shin this repo, next to theShippedPrebuiltBundlesconstants its_completesentinel must keep matching. The reusable publish-bake checks this repo out (byplatform-ref, defaultmain) and runs it, retiring the per-repo vendored copies. This repo is public, so private satellites call the workflows and read the script with their default token. - Repo-specific policy stays in the caller: the digest pin (
MW_IMAGE_DIGEST) and its bump cadence β an unpinned image is an explicitallow-unpinnedopt-in, never a silent fallback β gating (if:/needs:on theuses:job), therepository_dispatchreceiver, the module-bundle job of mixed packages, and each repo'sscripts/(validate / compile-check / affected-modules / tag-modules stay caller-side β they encode the repo's own layout). - Adoption renames the required checks: a reusable-called workflow's check runs report as
<caller job> / <name>, so each repo's required-status-check contexts are renamed in the same change that adopts a workflow β a context left at the old name would wait forever. On a protected repo this is a required step of the adoption, not an afterthought: SocialMedia's contexts are nowvalidate / Validate node repos,compile-check / Compile every NodeType (vs core)andtest-repos / Compile + render node repos (MeshWeaver from ACR). π A later@refbump does NOT rename anything β it changes neither the caller's job id nor the inner job'sname:β so the contexts stay valid across bumps. Only renaming a job in the reusable workflow would break them, which is why that rename is itself a breaking change to every caller's branch protection. - The caller PINS the workflow ref β
@<40-char commit sha>, never@main. See below; this is the same rule as the image digest, applied to the CI logic instead of the CI runtime. - Staged cross-repo modules are excluded from publication (e.g. Store is staged so
requiresresolve but is owned and published by MeshWeaver.Plugins) β each source directory seals independently, which is also why no cross-repo bake ORDERING is needed: a dependent repo's publication never contains its dependency's bundles, so there is nothing to wait for. The framework-release dispatch fans out to all satellites concurrently.
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
_completesentinel 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:
bake-scope.shdecides. π¨ Its baseline is the PUBLICATION, notgithub.event.beforeβ the sealed directory carriessource-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.- The caller's
scripts/affected-modules.pyanswers. 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. carry-forward-bundles.shkeeps the publication whole: every bundle the sealed listing names that this bake did not produce is downloaded into the bake directory, sopublish-bake-bundles.shruns 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 caller ships no
scripts/affected-modules.py; - a
repository_dispatch(a framework release mints a NEW identity β everything is recompiled against it), or any event with no meaningful content diff; - no sealed publication yet for this identity, a malformed target, or an unreadable sentinel;
- publish targets that disagree on the published source or bundle set (one narrowed bake carries forward one publication, so it cannot serve two);
- a baseline commit this checkout cannot resolve, or one that is not an ancestor of HEAD (history rewritten);
- the selector refusing β an empty diff is a broken range, never "nothing to do";
- the selector answering ALL modules: a change under
scripts/,.github/, a repo-root file the platform'sNOOP_FILESdoes not name, or a module directory that no longer exists. That last one is why a DELETED module still shrinks the publication correctly.
π¨ The MODULE lane's copy of this decision β
node-repo-scope.pyβ had two blind spots that made "a repo-root file" and "the twoNOOP_DIRScopies 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):
- A tag is mutable. Moving it changes all satellites simultaneously with no commit in any
satellite to attribute the change to β the blast radius stays exactly as wide as
@main, only the trigger moves from "someone edited a workflow" to "someone moved a tag". A SHA is what makes the bump be a satellite commit, which is the entire point. - It is the digest's analogue: a SHA is to a workflow what a digest is to an image; a tag is
:latest. - It matches what the callers already do β
MW_PLATFORM_REFis a full 40-char platform SHA with this same rationale beside it. One convention, not two. node-repo-tag-modulesexists to guarantee a module tag is never "silently moved under everyone who pinned it". A movinguses:tag would be precisely that, for CI logic.
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:
- Merge the
node-repo-*.ymlchange to the platform'smain; note the merge commit. - 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. - 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.
- 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 SHA is read back out of the caller's own
uses:lines, never kept as a second copy that could drift from the pin actually in force. A partial bump (one caller moved, the rest left behind) is therefore reported as "pins disagree" instead of averaged into one age. - It is a reporter, so it never fails the run β a red preflight would block every gate on a
GitHub API blip. Every miss is announced (
::warning::plus an explicit age UNKNOWN): it can report nothing, but it cannot fake freshness. π¨ Noteghwrites its error body to stdout, so an emptiness check is not enough to detect an unresolvable SHA β the step validates the timestamp's shape.
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
on: schedule:with a cron. Stagger the minute per repo so four repos do not wake together.A
framework-releaseconcurrency lane thatscheduleshares withrepository_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.refgroup it shares the main-push group and β withcancel-in-progressβ a half-hourly poll can cancel a main run part-way throughtag-modules, work the scheduled replacement does not do and nothing else recovers. Release polling supersedes release polling; it never supersedes a push.A
FOLLOW_RELEASEbake-target resolution inpreflightthat 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_IMAGEcurrently 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 leavesplatform-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 identityMeasured 2026-09-08: MeshWeaver.Manufacturing's poll failed this way in its
test-reposgate (Manufacturing#67) and MeshWeaver.Crm's in itspublish-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.schedulein thepublish-bakejob'sif:β 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:
- an instance held on an old version, or rolling and then taking minutes per pod to boot;
/data/prebuilt-bundlesholding many identities (101 on memex-cloud, 2026-08-21) and none of them the one the booting pod resolves;/app/prebuiltempty β the image lane contributes nothing, so the store is the only source;- boot logs showing a full Roslyn sweep (
compiled=<N>) instead ofadopted β¦ from β¦ sealed bundles.
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
DB-resident types (user/partition content CI cannot see) stay on the runtime bake.
A bundle is matched to a deployment by node PATH, so a portal that mounts a tree somewhere other than its canonical root adopts nothing from it. That is why CD no longer bakes the samples trees at all (see "CD compiles ONLY what the image embeds"): memex holds them under
MeshWeaver/samples/Graph/Data/ACME/β¦while a bundle from that tree is keyedACME/β¦. If a deployment ever wants them prebuilt, the fix is to agree one canonical path per shipped tree β a content-layout question, not an identity one.A narrowed bake still recompiles the affected closure's DEPENDENCIES.
mw-plugin-testtakes no module list β the mount IS the filter β and it has no seed-from-directory seam: it compiles every package it discovers under/repo. A dependency is mounted because the fresh gate mesh must install it for the affected package to activate and forshared=@β¦to resolve, and it is then compiled too. Almost every package requiresStore, soStoreis recompiled on nearly every narrowed run. The win is still large (3β5 packages instead of ~40); closing the rest needs the tester to ADOPT a published bundle for a mounted-but-unaffected package β the samePrebuiltAssemblySeederpath a portal takes at boot, which is #1707 item 5 ("at install, check whether a pre-built lib exists and consume it") pointed at the gate.The platform's OWN content bake (
main-cd.yml'spublish-bake) is not narrowed. It bakessrc/MeshWeaver.Documentation/Datainside the shipped image and fuses gate and bake in onemw-plugin-testrun; its content moves with the platform commit it is baking, so a content-diff baseline is a different question from the node repos' one.The cross-repo rebuild cascade is a separate axis.
narrow-by-affectedanswers "which of THIS repo's modules changed". "Which repos must rebuild, when an upstream publishes" is theupstream-sourcesgate plus each repo's ownscheduleβ there is no dispatch and no dependent list. A repo missing the schedule never rebuilds for a release, and the only symptom is an instance HELD on bundles from that repo's source.An arm64 install adopts nothing the amd64 lane publishes β the two architectures of one image resolve different identities (see the identity rule above). Local arm64 installs compile at boot as they always have; nothing may paper over this by publishing the same bundles twice.
π¨ That rule is now ENFORCED, not just stated β and it had to be, because it holds only for part of the identity space.
FrameworkBuildIdentityresolves surface identity (s<hash>) β stamped commit identity (g<sha>) β MVID set. The first is architecture-sensitive (the four reference assemblies above genuinely differ), so the two lanes get different directories and cannot collide.g<sha>is not: it is the same string for every CI build of a commit, whatever it was built on. Under that identity a second architecture would either be told "already published" by the contentΓframework sealed-skip and ship nothing β leaving its pods adopting the other architecture's bytes β or unseal and overwrite the incumbent. Both silent.So
publish-bake-bundles.shrecords the producing architecture beside the content marker (architecture.txt) and refuses a publication whose architecture differs from the one already under that identity, rather than skipping or overwriting. An incumbent with no marker predates the recording and can only be the amd64 lane, so a non-linux-x64bake refuses that too. SetBAKE_ARCHITECTURE(linux-x64|linux-arm64) in any lane that is not amd64.This is what a per-architecture publish needs first: adding an arm64 lane without it does not produce two lanes, it corrupts one.
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.