Module Build Architecture
This is THE build process, unified across every repo (maintainer directives, 2026-08-31 → 2026-09-01: "we want the memex to take care of the build", "update once at beginning for everyone", "create one roslyn workspace, all plugins to be rebuilt plus dependencies", "maybe put in blob storage for build", "unify buildprocess"). The platform repo carries the mechanics — the reusable lanes, the builder, this page; a consuming repo carries ONLY policy (which modules, which pins). A repo whose CI deviates from this page is behind, not different. Never hand-roll a repo's build.
The pipeline, end to end
select ─┬─► prepare (ONCE) ──► build (ONE workspace) ──► pack ─┬─► verify
│ │
└─► tests (a lane of its own — publish: false) ────────┘
…or INLINE inside pack, before the hand-over, when the call publishes
- select — which bundles this diff can reach. The selector can say "these" or
"everything", never "skip"; a workflow/pin change legitimately selects everything, because
the compiler itself changed. A
src/change is bounded by the compile tree, not treated as "everything". Amainpush builds the dependency network changed since the last successful run and is never cancelled — see Per-module deploy below; a repo that has not adopted it cancels a supersededmainrun by construction (Superseded runs onmain). - prepare, once per run — everything every job consumes identically is staged here, never
per module: the platform image as a per-digest zstd tarball in the actions cache (GitHub's
blob storage, colocated with the runners), the tester-app and platform-refs extractions,
the module-pack tool. On a warm digest the run touches no registry at all — measured
live:
platform image: cache HIT — docker load, no ACR. A cache miss falls back to pulling the same digest-pinned bytes, loudly: a perf fallback, never a verification fallback. - build — one Roslyn workspace. The builder compiles the selected modules plus their
in-repo dependencies as one graph: every project shares the same
PortableExecutableReferenceper path (each assembly read from the filesystem once), one body pass like csc (parse+declaration diagnostics up front, body diagnostics from the singleEmit), fail-fast (the first red blocks every not-yet-started node by name — a sweep that must enumerate every verdict opts out of fail-fast instead). - pack fans out from the build's outputs — it consumes, it never recompiles.
- tests — beside the chain, or inline, and
publishdecides which. See below. - verify — unconditionally pairs the selection against the receipts; a skipped pack with a non-zero selection is RED, and so is a delegated suite that produced no test receipt.
Where a module's own suite runs — and why publish decides
A needs: on a uses: job waits for the whole called workflow, so anything inside the last
job of this lane sits on the critical path of every gate the caller hangs off it. Measured on
MeshWeaver.Plugins run 33656010754, a 39-minute pull request:
19.1 -> 32.5 Module bundles (floor) / Module bundle (MeshWeaver.AI) <-- 13.4 min
12.7 min Run the module's tests, when it ships any
0.2 min Build the module — and say which compiler produced it
32.5 -> 32.7 Module bundles (floor) / All selected bundles built
32.8 -> 37.0 Compile every NodeType (vs core) (a REQUIRED context)
32.9 -> 38.6 test-repos / Compile + render node repos (the Tests-area gate)
The bundle artifact — the only thing those two gates consume — was ready 0.2 minutes in. They waited another 12.7 for a suite they never read. The floor call exists precisely so the gates do not wait for the other 30 bundles (Plugins#892); the suite put the wait straight back. It also lengthens the recovery loop: a run cannot be re-run until it completes, so a flaked shard's retry waits out a suite whose verdict nobody is reading — every flake pays that.
So the suite's position is a function of publish, the input that already means trunk in this
lane's caller contract:
publish |
where the suite runs | what the position buys |
|---|---|---|
true |
inline in pack, after the bundle upload, before the hand-over |
a failing suite never publishes — the registry serves what every installation reads |
false |
the tests job, in parallel with select → prepare → build → pack |
nothing is publishable on such a run (the hand-over step's own if: is inputs.publish && …), so the ordering protected nothing and delayed everything |
It is a switch on the input, never on github.event_name: the platform's own main-cd calls
this lane with publish: false for the bake's compose set, and that call is not a pull request —
it wants the fast path for exactly the same reason.
Moving the suite off the critical path does not move it out of the gate, and three structural things say so rather than a comment:
- the
testsjob is part of the called workflow, so a red suite fails the caller'suses:job exactly as a red inline suite did; verify—All selected bundles built, this lane's one stable context and the one a repo's branch protection requires —needs:the tests lane and goes RED when it did not succeed. No context is renamed, so no repo's protection changes;- a suite that ran in neither lane is caught by receipts, not by re-reading two conditions.
Two mutually exclusive
if:s can both be false, and a suite that ran nowhere renders exactly like one that passed. So every pack receipt records which lane owns its suite (tests: none | inline | lane), the tests lane drops a receipt of its own, andnode-repo-pack-verify.pyfails when a delegated module has no test receipt, when a test receipt arrives for a module that delegated nothing, or when the tests lane skipped while modules delegated to it.
bundles-built is deliberately untouched by all of this: it still answers from the built markers
dropped before any suite, so a red suite still does not read as "bundle missing" to a gate that
only COMPOSES the bundle (#2710, Plugins#937). Those are two different questions.
The receipt carries one more thing, and it is the lane's only OUTCOME-level assertion: the
framework identity the bundle states, read off the packed manifest on the build and reuse legs
alike. A lane pins one platform, so verify refuses a lane whose bundles state more than one — and
refuses an absent identity separately and by name, because "absent" must never read as "agrees".
See The Module Identity Anchor.
Every stage asserts its OWN postcondition — never the next one's
A stage's exit code answers "did my work throw?". It does not answer "did I produce what the next stage requires?", and the two were read as one claim until it cost a day.
The build is where this bites. build reported success and the pack matrix then died, seven
jobs at once, with the global workspace build produced no MeshWeaver.AI.OpenAI.dll — on a pull
request whose entire diff was one XML doc comment. The message was accurate and one job too late:
the stage that knew the selection was the green one, and the stage that discovered the gap knew
only its own module. Two enumerators — the projects handed to the build and the modules the matrix
expands — were free to disagree, and nothing compared them.
So the build now checks, before it uploads anything, exactly what its consumers demand, per selected entry:
| the consumer demands | the producer now asserts |
|---|---|
<Module>/<Module>.dll |
present and non-empty (a truncated emit reads as "the file is there") |
<Module>.closure.txt |
present — without it a pack job cannot know which in-tree siblings ride this bundle, and a glob would ride every other module's |
platform AssemblyVersion in the build log |
readable — it is the expected side of the binding-identity check every pack job runs, and since --bind-to-image it is also the value the builder STAMPED: a module's AssemblyVersion/FileVersion come from the image, never from the repository's props (the evaluator runs no MSBuild property functions, so a repository cannot derive them, and a literal is right for one platform only) |
Three properties make it a postcondition rather than a second opinion:
- The same enumerator on both sides. It is fed the selection the matrix expands, not a re-derivation and not a glob of the output directory. A postcondition derived from a different enumerator is the original defect, one level up.
- A glob would pass the case it exists to catch. The workspace legitimately holds every entry's assemblies side by side, so "there are DLLs in there" is true in exactly the failure being diagnosed. Only a per-selected-module check can see it.
- No
if:on it, and its self-test runs first. The selection-with-no-container-entries case is handled inside the check (it asserts nothing and says so) rather than by a condition that would render as a grey tick indistinguishable from a pass. An unproven postcondition is the thing this section is about.
It deliberately never asks the reverse question: the workspace carries in-tree dependencies of selected modules beside them, so "emitted but not selected" is normal and is not a finding.
The generalisation, for any stage you add to this lane: name the artifacts the next stage opens, and assert them where they are produced. One accurate failure upstream is worth N accurate failures downstream, and a downstream failure can only ever describe its own shard.
A bundle states what it was built against — or it is not published (#3211)
A module's published version encodes its content only. Rebuild unchanged source against a new
platform and the hash is the same, the version is the same, and the bundle overwrites its own
registry slot with different bytes. So "already landed" can only mean this content against this
framework, which is why ModuleUpdateDecision.Decide compares (version, framework identity)
before answering SkipUpToDate (#3154, Doc/Architecture/Modules).
That comparison needs something to compare, and the producing end was not supplying it.
Measured, MeshWeaver.Plugins run 33773265959 (2026-09-03): every one of the 34 bundles packed
warning: MeshWeaver.Compiler.dll not found at …/MeshWeaver.AI.OpenAI/MeshWeaver.Compiler.dll
packed MeshWeaver.Plugin.OpenAI.1.1.11.module.nupkg — … built-against MVID (unrecorded)
on both compilers. The packer probes for the identity anchor beside the module, and where the anchor is moves with the platform:
| how the platform arrives | where MeshWeaver.Compiler.dll is |
|---|---|
pinned image (every satellite call) — platform-refs, the docker cp of its /app, passed to the sdk build as MeshWeaverRefs and compiled inside by the container build |
in platform-refs; deliberately not in the module's output, which carries no platform assembly |
built from source (core's own main-cd call, no image pinned) |
beside the module — the platform ProjectReferences are real, so dotnet publish copies it |
Only the second case ever matched the default probe, which is why core CD records an identity
(measured: built-against MVID ce81fd4e…, run 33779812466) and every satellite records none. The
field was optional, so nothing was red; #3154 shipped into a fleet where every consumer of every
module was permanently in the "up to date — the identity could not be checked" branch.
The asymmetry is why this had to be fixed at the producer. An unknown on the landed side heals after one fetch, because landing writes the identity back. An unknown on the served side never heals: a registry that states no identity will state none next time either.
Three refusals, all naming what is missing:
| where | what it refuses |
|---|---|
module-pack (ModulePackCommand) |
exit 2 when neither --framework-mvid nor --graph-dll yields an identity — the bundle is not written at all |
| pack step | names the anchor explicitly (--graph-dll), picking the location from the table above — and both arms are RED when the anchor is not there. The branch decides where to look, never whether to check. The manifest is then read back and the field asserted on the bytes |
| publish step | RED before the POST when the bundle's manifest states none — on the bytes about to be handed over, so the reuse leg (an artifact an earlier run packed) is covered too |
Which identity string lands in the manifest — and why it need not be the portal's. The packer
reads the anchor with FrameworkIdentity.ReadIdentity, which sees only what is IN the assembly: the
stamped MeshWeaverFrameworkIdentity (g<sha> on every CI build) or, failing that, its MVID. It
cannot see a surface manifest, so it never answers the s<hash> a portal resolves for itself.
That is fine, because nothing compares a module bundle's value to a live process: LandFromBundle
only records it, and ModuleUpdateDecision compares served against landed — both the
producer's own string, so they converge after one landing whatever the flavour. It is also the
finer discriminator of the two: s<hash> is breaking-change-keyed and holds still across many
platform commits, while g<sha> moves with every one — and "the platform this module was compiled
against moved" is exactly the question being asked. (The DeclineReason identity gate lives on the
bake bundle path, BundleReader.Read/SeedAll, and never sees this value.)
The publish-step refusal is the load-bearing one: the inspection runs on the build leg only, and a
guard bound to it alone would pass while pre-#3211 bytes went to the registry. RECIPE_VERSION
moved 1 → 2 in module-build-key.py for the same reason — the lane now packs different bytes
for the same source, so every recorded key is invalidated and no run reuses a bundle whose publish
would be refused.
Armed 2026-09-05: the registry's own 400 (#3240). ModulePublish.Validate now refuses a
manifest stating no identity, by name, through the publish route's existing 400 — so the registry no
longer depends on its producers being correct, which is the only version of this check that is worth
having. A registry that trusts its publishers is not a registry.
It was deliberately NOT armed when the producer-side refusals landed (#3237), and the reason is the
reusable part: arming a refusal before every producer can satisfy it takes the fleet's publishes
down instead of the nulls. On 2026-09-03 every one of MeshWeaver.Plugins' 34 bundles still packed
built-against MVID (unrecorded) (run 33773265959). The runway was a WARNING at the publish
endpoint naming the package, module and version — a measurement, not a fix — and the arming
criterion was that it fall silent. Both halves, measured on 2026-09-05:
| half | measurement |
|---|---|
every publishing repo pins node-repo-module-pack.yml past #3237 (da2bb12d3, 09-03 17:49Z) |
MeshWeaver.Plugins c41a34fda (09-04 05:57Z), MeshWeaver.SocialMedia fec69fc66 (09-03 21:27Z). Education and Reinsurance do not call the lane. |
| a full publish wave from each, stating an identity | Plugins run 33941672487 (09-05 03:31Z) built against: g7d644de95…; SocialMedia run 33941835795 (09-05 03:27Z) built against: cef92e9759…. Both publish: true, both jobs green, so the producer-side refusal never fired. |
🚨 The refusal is shape-agnostic on purpose: an image-pinned lane states g<sha>, a from-source
lane a 32-hex MVID, and FrameworkIdentity documents a third (s<hash>). Only ABSENCE is refused.
Narrowing it to one shape would red a lane that is stating its identity perfectly well.
Content-addressed outputs = the module build ledger (as built, 2026-09-02)
"For Plugins, merge as quickly as possible, as tolerant as possible; only hard conflicts flagged. We should not start the same build multiple times ⇒ coordinate which packages are in progress; track progress through memex. Build and test only when we have to: if Plugin X was built against Platform version Y, we don't have to rebuild this." (maintainer, 2026-09-02; Plugins#889, #931)
Every module build has a content address, and the fleet keeps a ledger of what happened at
each address — on the registry portal, as mesh nodes. The lane consults it before it compiles, and
writes it at every transition. This section is the contract; the two scripts that implement it are
.github/scripts/module-build-key.py and .github/scripts/module-build-ledger.py, both self-tested
on every lane run, and ModuleBuildLedgerLaneGuard pins the lane's wiring.
The key — what a build is a function of
K = sha256( canonical JSON of {
recipe the lane's build-recipe version (a constant in module-build-key.py; bumped on a
byte-changing lane edit, never on a cosmetic one)
package, module the matrix entry
entry {build: sdk|container, accept: <sorted tokens>}
moduleVersion <package>/manifest.lock → moduleVersion (the package's content hash, Plugins#878)
closure {project dir → tree hash} — the entry project, its sibling <dir>.Test (the lane RUNS
it) and every in-repo ProjectReference either reaches, transitively: module-owned
MeshWeaver.* siblings RIDE the bundle, so their bytes are the bundle's bytes
packages {package → moduleVersion} for every package whose module project is in that
closure, and every package the entry `requires`, transitively
globals {path → sha256 | null} for src/Directory.Build.props, src/Directory.Build.targets,
src/platform-shipped.txt, Directory.Packages.props, global.json, nuget.config …
(null records ABSENCE — an input too)
testerDigest, platformDigest, platformRef
} )
Design decisions, and why:
- Tree hashes over
moduleVersionalone. The version derivation covers the module's own project (and, since Plugins#1118, its riders); the key hashes the whole compiled+tested closure directly, so it cannot inherit a blind spot ingen-manifests.py—MeshWeaver.Blazorriding in seven bundles unhashed (see ModuleVersioning) is exactly the shape the key must never have. A$(MeshWeaverRoot)-relative reference is the platform's and never an edge: the platform enters through the two digests andplatformRef. platformRefIS in the key, deliberately.dotnet testbuilds core FROM SOURCE at that ref, so the verdict really depends on it. This is also the lever a satellite pulls: a caller whoseMW_PLATFORM_REFtracksmaingets a new key on every core commit; a caller that pins the ref to the promoted set's commit keys identical trees identically across runs. The lane cannot make that choice for the caller, and must not hide it.- Refusals, not guesses. A package with no
manifest.lock, or an entry whose project does not exist, cannot be keyed and fails the selection RED — keying on nothing would collide every build.
The ledger — one node per key
Admin/ModuleBuilds/<K>, nodeType ModuleBuild, content ModuleBuildRecord
(src/MeshWeaver.Graph.Contract/ModuleBuildRecord.cs; the type ships in the framework, like Build,
so every registry portal has it without a content package): package, module, moduleVersion, version,
platformRef, both digests, platformIdentity, status ∈ {Claimed, Built, Tested, Published, Failed},
phase, blocking, attempts, run {repo, runId, attempt, url, event, lane}, claimedAt, heartbeatAt,
finishedAt, bundleSha256, bundleArtifact {repo, runId, name, expiresAt}, tests {passed, failed,
names[]}, failure, previous.
- Why
Admin/ModuleBuildsand notAdmin/Build/….Admin/Buildis the in-portal NodeType bake's coordination root (BuildCoordination) — its hub arbitrates claims over its children with cluster-membership takeover. The CI ledger is a different protocol with a different writer, and must not sit where that arbiter enumerates chunks. It stays in the Admin partition because the subject of a build decision must not be able to write the decision for everyone; the CI user gets a partition-admin grant scoped to exactly that root (Admin/ModuleBuilds/_Access/{user}_Access,MainNode = "Admin/ModuleBuilds") — never a global-admin one (AccessControl → "The Admin partition"). - The wire. The lane speaks MCP JSON-RPC over HTTP to the portal's
/mcp(initialize, thentools/callget/create/patch; JSON or SSE answers),Authorization: Bearer <mw_ token>— the same three tools every MCP client uses, no bespoke route. platformIdentityis the PORTAL's.prepareruns the tester'sframework-identity /appverb with the platform image's owndotnetover the platform image's/app— thes…surface identity the adopting host resolves (#3022), not the tester image's own and not theg…provenance stamp of one assembly. That is #931's producer half: the record says which framework these bytes are for. The consumer half landed beside it: the registry advertises a module bundle's identity per bundle on/api/plugins/bundles/index.json, andModuleUpdateDecisionskips as up to date only when the version AND that identity match what is landed — so a rebuild of unchanged source against a new platform, which republishes under the same version, is no longer invisible to the updater (Modules → "Already landed means this content against this FRAMEWORK").
The protocol
| step | what it is |
|---|---|
| claim | CREATE the node. Creation fails on an existing path, so exactly one run holds a key; "already exists" is the follower's success case. After every create the claimant re-reads the node and holds the key only if the record names ITS run — a claim you cannot read back is a claim you do not hold. |
| heartbeat | the holder's sign of life: at claim, at pack-job start, after the workspace build, at every transition. A claim whose heartbeat is older than 45 min — the fleet's job cap — is dead by construction (a job that cannot heartbeat inside its own cap has been killed) and may be taken over; the takeover is itself re-read for the same reason as the claim. |
| reuse | a terminal record (Built/Tested/Published) whose bundle artifact this run can fetch is not rebuilt. The pack job downloads that run's module-bundle-<module> artifact, verifies its sha256 against the record, and runs ONLY the phases the record lacks — tests if this run needs a verdict and none is recorded, publish if this run publishes and nobody has. It drops the same artifact, built marker and receipt a built leg drops, so a caller composing the bundle cannot tell the two apart. |
| wait | a fresh, unfinished claim by ANOTHER run: the follower polls the ledger every 30 s (bounded to 40 of select's 45 min). The same key is never built twice at once. |
| tolerance | a Failed record blocks a later run of the same key only when the same inputs give the same result: a compile failure blocks (RED with the holder's run URL and the compiler lines); a test failure blocks from the second failed attempt on — one re-claim, so a flaky suite does not pin the fleet (attempts counts); pack, publish, workspace-abort and cancellation never block. |
| degrade | the registry portal answering 5xx or unreachable is not a verdict: after a bounded retry honouring Retry-After, select builds every affected module without coordination and says so in yellow in the job summary; every later write is a ::warning. The ledger may cost a duplicate build, never a green (core #3119). |
Why run ARTIFACTS, not the actions cache. The first design keyed bundles in the actions cache
(module-bundle-<K>). The cache is branch-scoped: a run on main can restore only what main
created, so the one flow that matters most — the PR built it, the push to main reuses it — is
structurally impossible there. Run artifacts are repo-wide; the bundle artifact's retention is 7 days
(the reuse window; the record carries the expiry) and the caller's job needs
permissions: actions: read for gh run download — without it select observes the 403 and answers
build, loudly. A bundle in ANOTHER repository's run is never fetchable with this run's token, so
cross-repo keys (the platform CD packing Plugins) are rebuilt rather than reused.
What this does to the scope
node-repo-scope.py still decides what a diff REACHES (a PR narrows; push, release-follow and
manual dispatch are FULL). The ledger decides what of that must be COMPILED. So the push row is no
longer "full by fiat": it is every module whose key has no usable Published record — which is
exactly the baseline Plugins#889 asked for, derived correctly: a cancelled, red or superseded run left
no Published record, so its modules are rebuilt and published; a PR that built and tested the same
bytes minutes earlier is reused and only the hand-over runs. Release-follow (repository_dispatch /
schedule) still builds everything, correctly: a new platform digest is a new key for every module.
The repository_dispatch is memex's event — meshweaver-framework-released, emitted by the
registry's PlatformBuildInboxWatcher from the build fact core CD POSTs into Hosting/PlatformBuilds,
to the repositories the Hosting/Deployment records name as registry sources; schedule is the
fallback. Core dispatches to no repository (maintainer, 2026-09-03: "core publishes an event and
finishes").
What a satellite passes
permissions:
contents: read
actions: read # gh run download of an earlier run's bundle — without it, no reuse
uses: Systemorph/MeshWeaver/.github/workflows/node-repo-module-pack.yml@<sha>
with:
ledger: required # default `off` = today's behaviour; the summary says which on every run
…
secrets:
publish-token: ${{ secrets.REGISTRY_PUBLISH_TOKEN }}
ledger-token: ${{ secrets.REGISTRY_LEDGER_TOKEN }} # a mw_ ApiToken of the registry's CI user
One-time on the registry portal (as a global admin): create the Admin/ModuleBuilds root node,
grant the CI user the Admin role there (MainNode = "Admin/ModuleBuilds"), mint that user an API
token, store it as the satellite's REGISTRY_LEDGER_TOKEN. ledger: required with an empty token
is RED in select — a ledger that silently did not run and one that ran must never look alike.
🚨 Per-module deploy — a merge ships its dependency network, and nothing supersedes it
Maintainer directives, 2026-09-11, after MeshWeaver.Plugins main published NO module for nine
hours — every merge superseded the run before it reached its publish, the baseline then fell back to
FULL, the full run was superseded in turn, and the release-follow runs went red on an unrelated
portal-host test: "why do deployments depend on a complete main run?!" · "each module should
update individually ⇒ mono-repo" · "only flooring" · "make sufficient tests" · "builds are not
superseded then, however they must be atomic" · "no huge runs ever" · "on platform build we will
do a full run to ensure compatibility … [it] will also be superseded by next platform run" · "we
will only force consistency inside a dependency network, not just all — ever".
MeshWeaver.Plugins adopts it first. Every lane default is unchanged, so a repo that has not opted in keeps the supersede model in the next section.
The rules
- A push run on the trunk is never cancelled — not by the concurrency group
(
cancel-in-progressis already false on the default branch) and not by the supersede lane, which a per-module repo does not call (scripts/check-main-runs-not-cancelled.pyrefuses the call). Every merge's run reaches its own verdict. - A push builds its DEPENDENCY NETWORK, never everything. The scope is the affected closure —
the in-mesh
requires/dependents walk plus theProjectReferencecompile tree — over the history union since the newest SUCCESSFUL trunk run (node-repo-publication-base.py). Failed, cancelled and in-flight runs after it are walked past, because the union carries every change they held. Only a toolchain change resets to the full set: a run in between that attested a different platform, image or build-logic set may have published with it, which git history cannot show. Unrelated modules are not rebuilt, not retested, not republished. - A module publishes on its OWN verdict. The module lane needs only the platform resolution and
the scope — no repo-wide validate gate, no portal-host suite, no sibling module's test. Each leg
builds, runs the module's own suite, and POSTs one bundle, whole or not at all
(content-addressed, keyed
{package}@{version}). A red sibling leaves this module's publication untouched; a red leg leaves its module at the version it last published. - Newest wins, per module (
publish-newest-only). Runs are no longer cancelled, so two of them can build one module and finish in either order — and the registry keeps what arrives last. Immediately before the POST the leg fetches the trunk tip and asks the same scope script whether a newer commit reaches its module's network. If one does, the leg stands down (receiptpublication: superseded, naming the tip): that commit's own never-cancelled run builds the module from a tree that contains this one. A module never goes backwards and never waits for an unrelated module. - The seal never goes backwards. The same reordering reaches the NodeType bake. A bake whose
commit is an ancestor of the commit already sealed answers
scope=noneat decision time (bake-scope.sh) and skips at write time (publish-bake-bundles.sh, through the compare API): sealing it would move every instance's sources back, sinceSealedSyncGateholds a repository's sources at the sealed commit. The bake is narrowed too (narrow-by-affected), so a merge re-bakes only what its diff affects. - The platform release is the ONE full run.
repository_dispatchand theschedulepoll rebuild and republish everything against the new platform — the compatibility check across the whole catalog. It is the only run that is superseded, and only by the next platform run: the release lane shares one concurrency group, so a newer platform run replaces a queued older one. - Compatibility is decided by floors. A bundle states the framework it was built against and its
minMeshVersion/requiresranges, and an instance adopts it only when they hold. Consistency is forced inside a dependency network (rule 2) and never across unrelated modules. - No job spans more than one atomic unit — and the atomic unit IS the dependency network.
The maintainer, 2026-09-11 ~19:40–19:50Z, typed in the DeepSign session (Claude Code session
01R6Cbf8RzXXHJvsjMBYLmjg) and relayed from there verbatim: "we wanted to disentangle in atomic
units" · "we must not have any job going across the atomic unit" · "(atomic unit == all
dependency patterns in repo)". A unit is a changed package together with everything that depends on it (rule 2's
network). One job covering one whole network is right; a job that covers two UNRELATED networks
couples their verdicts — one network's red or flake holds the other, and neither can be skipped on
its own. The shape is the module lane's: one leg per affected network, and a receipt-count aggregate
(
… / All selected bundles built) as the one required context. Two items below are therefore violations to remove, not costs to accept: a workspace build spanning unrelated networks, and portal-host shards that mix test projects from unrelated networks.
What it costs, and what it does not cover
- Overlap. A push landing while an earlier run still builds rebuilds that run's network too (rule 2's union). That is the price of never cancelling; rule 4 makes it harmless.
- A red newest leg. When the newest run's leg for a module fails, rule 4 has already stood the older leg down, so the module stays at its previously published version until the fix lands. The red is on the trunk and names the module.
- The seal window. A module built while a platform release is still sealing can be built against the outgoing platform; the release run, and at the latest the daily poll, rebuild it.
- One workspace per run — a rule 8 violation when the run spans unrelated networks. A push touching two unrelated networks still compiles them in one fail-fast workspace, so a compile error in one stops the other's legs in that run. A workspace per network is the next step.
- Compiled test projects and the portal-host shards have no content key, and each shard mixes
test projects of unrelated networks in one job (rule 8). A module is skipped
when its key is already published, but a
src/test project has no key and noTestedrecord:node-repo-project-scope.pynarrows WHICH suites run, and every selected suite re-runs from scratch on every push. Measured on Plugins run 34618468550 (a pull request, 2026-09-11): the four portal-host shards cost 38 + 21 + 19 runner-minutes, plus 11 for a shard cancelled with the run. The next step after a workspace per network: a per-project key in themodule-build-key.pyshape (the project closure's tree hashes + globals + the platform set) and aTestedrecord per test project in the same ledger, so a push re-runs only the projects whose key changed and an unchanged one carries its previous verdict — the rule the modules already follow: re-run only when the atomic unit actually changed. (Raised by the DeepSign session, 2026-09-11.) - An in-flight platform run is not cancelled by a newer one (rule 6 replaces only a queued one): cancelling it mid-seal is the torn publication of Plugins#826.
🚨 Superseded runs on main — cancelled by construction, selected by the compile tree
Repos on per-module deploy (above) do not cancel
mainruns at all — this section is the model for every repo that has not adopted it.
Maintainer directives, 2026-09-08 (during the memex roll block): "cancel superseded", "superseded means overlapping code", "they are monorepos — walk the dependency tree, find all affected code including Roslyn dependencies", "we need to build the compile tree anyway, it's not even wasted time". This section is the rule; the lane implements it.
Why a superseded main run can be cancelled without comparing anything
Every satellite is a monorepo: one push to main touches some modules and leaves the rest
alone, so two pushes are only "the same work" when the newer one reaches everything the older one
reached. It is tempting to compute both affected sets and compare them. That comparison is
unnecessary, by construction of the baseline: a push run does not diff against
github.event.before — it diffs against the commit the repo's own sealed publication records
(source-commit.txt), and the ledger rebuilds every module whose key has no usable Published
record. A run that is cancelled before it publishes leaves no Published record, so the next run
on main rebuilds and republishes exactly what the cancelled run would have — plus its own changes.
So for two push runs on main, O is superseded by N iff O's commit is an ancestor of N's
(compare API: O is behind N). Nothing else needs measuring: affected(N) ⊇ affected(O) follows
from both narrowing against the same seal. Cancelling O loses nothing and removes a race — an older
run publishing between a newer run's select and its hand-over.
The two guards, and the two failures they come from
- A run that has begun publishing is never cancelled. Once a superseded run has entered
publish-bake, it finishes and seals; the newer run seals after it, newest identity last. Only the hand-over is protected — not select, build or pack: their outputs are content-addressed, so the next run reuses identical builds from the ledger and cancelling there loses nothing (a guard that also matched the bundle stages would protect every run from its first minute and cancel none — measured on the live queue while this was written). Two reasons, both measured:- Torn seals (Plugins#826). A scheduled poll cancelled a framework-released dispatch "with all 29 bundle jobs running — one of them MID publish-bake, leaving a torn, unsealed publication."
- Starvation (Plugins#888).
mainonce shared a single concurrency group — GitHub's built-in "newest wins". In a merge burst every new push displaced the previous one before it finished: "main last completed a run at 07:04:52Z; 43 commits / 23 merges landed after it; 22 of the last 25 main runs were cancelled with jobs=0." Nothing published for hours. Protecting past-gates runs is what stops that: in a burst, every run that proves green still seals, so the fleet gets a publication roughly every gate-suite length instead of none. - 🚨
jobs=0alone is NOT the starvation signature — read the in-flight run beside it. Core's ownmain-cdkeeps ONE group on the ref withcancel-in-progress: false, so GitHub applies both guards itself: the run in flight (past its gates by construction) is never touched, and a run still waiting is replaced by the next arrival, reportingcancelledwith zero jobs. Measured 2026-09-08: seven such evictions in an hour, both in-flight runs sealed, nothing starved — the slot always held the newest commit. What GitHub does not check is ancestry, and the hourly reconcile bounds that to one tick. Reading a zero-job cancellation as #888 without an in-flight run that was killed is the misread to avoid: Reading CI Signals → "A CD run cancelled with ZERO jobs".
- Only
push→mainsupersedespush→main. Arepository_dispatch(the platform wave) and theschedulepoll are never cancelled by this rule and never cancel anything — the #826 rule, kept intact. Eachmainpush keeps its own concurrency group keyed on the commit, so GitHub itself cancels nothing; the lane does, explicitly, under the guards above.
The lane cannot pass silently: if it cannot list runs or resolve ancestry it goes red naming why
— no continue-on-error, no if: secret-is-set (AGENTS.md → "A gate NEVER tests its own inputs").
It lives in the platform as a workflow_call lane and every satellite calls it first on a main
push; never hand-rolled per repo. Proof of shape, 2026-09-08 by hand: three superseded Plugins
main pushes cancelled, the one already inside publish-bake correctly left to finish.
Selection reads the compile tree — src/ changes are no longer "everything"
affected-modules.py already walks the dependency tree for in-mesh content, 1:1 with the
runtime (requires, sources/tests queries, nodeType-by-path), closed over transitive
dependents and then forward dependencies for the mount. Its blind spot is stated in its own
docstring: "anything else — scripts/, .github/, src/, test/ … → ALL modules (conservative)." A
change to src/MeshWeaver.AI selects every bundle in the repo.
The precise answer is already computed in the same run. build compiles every selected container
entry in ONE workspace, and the caller's modules: input declares the mapping the walk needs —
each entry is {package, module, project: <csproj>, …}. So select derives the project graph
from those project files and their transitive ProjectReference closure — the same tree the
workspace build resolves moments later, evaluated without compiling — and for a src/ change it
walks: changed file → owning project → transitive dependent projects → entries whose project is
in that set → packages, then hands those packages to the existing in-mesh dependents closure.
The graph is a byproduct of the build the run does regardless; using it in select costs nothing
and replaces "everything" with a measured set — fewer bundles through the gates and the seal on
every src/ touch, and a supersede relation that stays exact even where the baseline argument
above does not apply (a PR narrowing against main).
What stays conservative, deliberately: a change under .github/ or to the platform pin still
selects everything — the compiler itself changed, and no dependency walk can bound that.
The compiler is the platform image
A module never runs against a source tree or a NuGet feed: it is loaded into the platform
IMAGE and bound by the assemblies in there. So the honest compiler is that image's own —
mw-plugin-test build-project runs INSIDE the pinned platform container; its /app is the
reference set, its runtime is the shared-framework surface. No SDK, no restore, no platform
source checkout. A declared mode that cannot run FAILS — there is deliberately no SDK
fallback, because a fallback makes "the container built it" and "the SDK built it"
indistinguishable in a green log.
Additional libraries resolve from the curated module-libraries shelf, and a
PackageReference's compile surface is its transitive closure from the shelf's deps.json —
exactly what the SDK hands a consumer (PackageReference Microsoft.Graph lets code
using Microsoft.Kiota…; anything less re-creates that gap as a CS0234 wall).
🚨 What a module COMPILES against and what its bundle CARRIES are two questions with two
answers. For the compile the image is authoritative — a module binds the assemblies of the host
it is loaded into. For the bundle it is not: the image is a PORTAL, and a portal with a module
compiled into it also carries that module's private package dependencies, so "/app has the file"
is a fact about one host, never a platform guarantee. A bundle carries its own package closure
minus the SHARED FRAMEWORK, exactly as the SDK lane's --deps-closure does — see
ModuleClosureAccounting for the rule and the two outages that came
from conflating the two questions.
Gates compile against IMPLEMENTATION frameworks
Every compile-check extracts the image's /usr/share/dotnet/shared beside /app and, seeing
System.Private.CoreLib.dll, drops the SDK ref pack entirely: the check compiles exactly what
the mesh compiles. This is not a preference — a container-built module references
System.Private.CoreLib's identity directly, which the ref pack cannot resolve (11 NodeTypes
false-red with CS0012 the moment the floor bundles were container-built) and which CS0433-s
beside it. Measured: 80/80 NodeTypes in 44s vs 161s + 11 false regressions.
The NodeType bake and its gate run AS the platform image too (#3022)
The rule above — the platform image is the compiler and the reference set — applied to module
compiles since #2907 and did not apply to the NodeType bake until 2026-09-02. The node-repo
bake (node-repo-publish-bake.yml) and the node-repo gate (node-repo-gate.yml) ran the tester
image against the tester's own /app: reference set, framework identity and the environment
the per-type dependency records were computed against all came from the process the bake happened
to run in, while the bundles it produced are adopted by the portal. Measured on the two images
of one promoted set, 3.0.0-rc9.ci.7534, both linux/amd64:
mw-plugin-test (baked) |
memex-portal-ai (adopts) |
|
|---|---|---|
assemblies in /app |
88 | 219 |
MeshWeaver.* assemblies |
26 | 46 |
MeshWeaver.* only in this image |
1 (Hosting.Monolith) |
21 — Maps, AI, Markdown.Collaboration, ContentCollections.Indexing[.Graph], Blazor[.Portal,.Views], Hosting.{AspNetCore,Blazor,Orleans,PostgreSql,SignalR,Grpc,Embeddings}, Connection.Orleans, InstanceSync, Documentation, {Speech,Observability,Markdown.Export}.Contract |
| the 25 assemblies both carry | byte-identical (25/25) — one build | |
| surface-manifest lines | 26 | 46; the 25 shared names carry identical hashes |
| framework identity | s8fe4902c0b2f5974f824be2867221dbd |
the same |
So the identity gate (#1814/#3041) was green — both hosts record the canonical set identically —
while the bake could not see 21 assemblies every portal compiles against. Five NodeType sources in
MeshWeaver.Plugins bind MeshWeaver.Maps (Cornerstone/Pricing, the AppleMaps, GoogleMaps and
OpenStreetMap galleries); the day Maps left the tester's closure (#2941) all four went RED in the
platform's plugins-bake with CS0234 'Maps' does not exist in the namespace 'MeshWeaver', no
seal was written, no dependent was woken, and no portal could adopt any release since — with every
line of the verdict naming the CONTENT.
The shape now — the same as the module lane, deliberately:
- Both lanes take
platform-image+platform-image-digest(required; resolved branch for branch as the tester'simage-digest, so a framework release, an upstream's publication and a push can never pair two waves). The tester executes; the portal supplies. - The lane pulls both images, extracts both
/apptrees, and asserts with the tester's ownframework-identity /portal --expect <tester identity>that the two resolve one identity — they are one build — before it trusts anything else. On a mismatch the verb names the canonical assemblies each side lacks. - It composes the gate host (
.github/scripts/compose-gate-host.sh): the portal's/app, complete, with the tester CLI laid beside it. The portal's bytes win a shared file; the tester'smeshweaver-surface.manifestnever rides (the host's identity is the portal's) and the tester'smw-plugin-test.deps.jsonnever rides (with an app-local deps.json the dotnet host builds the TPA from that file's entries only, and every portal-only assembly would be on disk yet unloadable — without it the host probes the directory and every assembly is in the TPA). compileruns from that host, started by the portal image's owndotnet, with--app /app --shared-frameworks /usr/share/dotnet/shared: the reference set is the portal's/appplus its implementation frameworks (the ASP.NET Core framework included — a console runtime does not have it), never the process's TPA;framework-mvid.txtcarries the identity the portal's directory resolves; every dependency record is computed against the portal's manifest pairs and MVIDs. The same seam (BakeHost) serves thebuildverb.- 🚨 One invariant makes recording the portal's identity honest: the toolchain that ran must be
the portal's own bytes. The identity folds the implementation MVIDs of the toolchain closure
(
MeshWeaver.Compiler,MeshWeaver.NuGetand their MeshWeaver dependencies) in because their code shapes the compile input, and that code executes from the tester's copy.BakeHostcompares the closure member by member against the portal's files and refuses a mismatch naming both MVIDs — a bake keyed to a host whose toolchain it did not run is never written. - The gate runs from the same host with
--app /app, which is a precondition, not a reference set: the process must resolve the portal's identity (GateHostCheck) or the run is refused before a mesh boots. A gate running as any other host would not fail — it would decline every bundle the bake addressed to the portal, compile the tree itself and exit green having judged none of the bytes that ship. - A reference-set gap is named in the verdict. When a NodeType fails with CS0234/CS0246, the
bake indexes the reference set and the host's
modules/**(assemblies the image ships outside/app— not in any reference set unless composed) and appendsreference set lacks <assembly> (portal-shipped, not composed: modules/…)orno assembly in the reference set declares namespace '…'with its two possible causes. A namespace some reference does declare adds nothing — that is a content error, and the compiler's line is the whole truth.
What this does to the identity gate. For the node-repo lanes the bake's identity is now the
portal's by construction, so "the bake's identity is the one the portal resolves" is an invariant
the lane asserts after the compile (cheap, kept so the day a lane edit drops --app is the day it
goes red) rather than a comparison that can lose; the check that can lose moved in front of the
composition and asks the honest question — are the two images one build? The platform's own
Doc bake (main-cd.yml publish-bake) still bakes inside the tester image and keeps the original
comparison against the promoted portal, where it is not tautological. check-image-build-identity.sh
is a different check (MESHWEAVER_PLATFORM_VERSION in the image config) and is unaffected.
Cost. One more image pull and /app extraction per bake or gate job (~1 min on a runner; the
portal's /app is ~300 MB) and a larger reference set for Roslyn to map lazily. Unchanged: what is
compiled, how the publication is sealed, every caller's other inputs.
CI is silent — warn/error plus verdicts
Per-item narration (resource names, per-package resolutions) sits behind --verbose; the
default log carries verdicts (start/OK/FAIL, phase timings, the compiler declaration, test
names), warnings, and errors. Docker pulls are -q. In-run artifacts expire in 1–3 days.
No component ever publishes from the mesh router — the router names a spokesman
(RouterCarrier → the nodeops execution hub) and infrastructure speaks through it.
Measured (2026-09-01, the night the shape landed)
| before | after | |
|---|---|---|
| MeshWeaver.AI compile (builder) | 161s local / 559s CI (double pass) | 80.6s local (single pass); ~97% of the remainder is nullable flow analysis in a few very large methods — source-side work |
| NodeType gate | 161s + 11 false CS0012 reds | 80/80 in 44s (implementation frameworks) |
| Registry trips per warm run | 2 pulls × N jobs (the connection refused stampede) |
0 |
| Module pack tool | 48–79s dotnet build × N jobs |
~5s download, built once |
| Orleans test suite | ~90 silo boots + disposal drain | 3 clusters (the mesh pool; see WritingTests § The Mesh Pool) |
One producer per (module, framework identity) — and the set is what is gated (#3175)
A module's bytes have exactly one producer for a given framework identity, and every dependent in a sealed publication was built against THAT build. This is not a style preference; it is the invariant the adoption contract already assumes, and on 2026-09-03 it was broken twice in one morning, with no diff in any repo:
| where | what the record said | what the consumer held | outcome |
|---|---|---|---|
| every satellite gate (Reinsurance 33727661313, Manufacturing 33727661850) | 'MeshWeaver.Maps' built against mvid:4d04617… |
live is ref:1D8FDE5B… |
4 of 240 DECLINED, GATE FAILED, nothing sealed, memex-cloud HOLDING |
| memex.meshweaver.cloud on ci.7621 | 'MeshWeaver.Markdown.Collaboration' built against mvid:A |
live is mvid:B |
SocialMedia adopted 0/4, /Posts rendered empty (#3174) |
The first row is a second producer in space: a portal host had taken a direct project reference
to MeshWeaver.Maps, a Store module. The bake composed Maps with --module — the id resolver puts
modules first, so every record said mvid: — while every portal and every gate host carried the
app-closure copy and resolved the name from its surface manifest as ref:. Two schemes, never
equal, every map gallery declined. The maintainer's ruling closes it structurally: "all maps should
be in plugins and removed from core — move 100% to plugins." The portal host references no module
project; a module is landed from the registry and composed into bakes, nowhere else.
The second row is a second producer in time: core CD's plugins-modules rebuilds
MeshWeaver.Markdown.Collaboration per platform release to feed the Plugins seal (mvid A), while
the registry's package endpoint serves the Plugins lane's last content-versioned publication (mvid
- — the bytes a portal actually installs. The availability gate saw it from #3242 on instead of rolling onto it; the serve side now resolves it (#3244, below), so the two producers can no longer disagree in the archive a consumer installs.
The third shape: live is absent — ZERO producers, not two (2026-09-06)
A bake-consumption decline reads almost identically whether a module has two producers or none, and the three fixes are different. Read the TAIL of the line first:
| tail of the decline | shape | what is wrong | fix |
|---|---|---|---|
live is ref:<X> |
two producers in SPACE | the host ships the assembly in its app closure and the bake composes it as a module | take it out of the host's closure (above) |
live is mvid:<Y> |
two producers in TIME | the seal's prebuilt assemblies and the module bytes shipped beside them are different builds | consume ONE sealed publication; re-target an identity whose seal is self-consistent |
live is absent |
no producer at all | the module was never composed into the reference set — the repo's registry-modules does not name the package that ships it |
name the package in registry-modules |
registry-modules says WHAT to compose; upstream-seed / upstream-sources say WHERE the bytes
come from. A repo can have the WHERE exactly right — identity-addressed, sealed, no floating
registry reads — and still decline every assembly binding a module it forgot to name. live is absent is therefore a declaration gap in the consuming repo, not a producer conflict in the
publication, and looking for a second producer will find nothing.
Measured 2026-09-06: MeshWeaver.SocialMedia and MeshWeaver.Manufacturing each declared
registry-modules: AI Essentials while their content binds four modules. Both gates declined the
same eight assemblies — the three */Gallery types against MeshWeaver.Maps, and
Cornerstone/Pricing plus four Store/* types against MeshWeaver.Payments.Stripe — each with
0 new failure(s), 0 stale allow entr(ies): everything compiled, every test passed, and the only
defect was the missing declarations. Naming all four took them to 114/114 and 103/103
adopted with zero declines. MeshWeaver.Crm already declared all four and never showed the shape —
the control that makes the reading conclusive.
🚨 The set is not "the modules we started with". Core CD's plugins-modules packs every module
the content can bind (today AI, Essentials, Maps, Stripe) and says so in its own comment:
"THE SET IS 'EVERY MODULE THE CONTENT BINDS', not 'the two we started with'". A satellite whose
list lags that one does not fail loudly — each declined assembly falls back to a local compile,
so the bytes the bake shipped are never judged. The gate's adopted N of M fatal is what turns that
silence into a red; without it the repo would publish a bake nobody had verified.
The three controls
The bake refuses a double by name (
BakeHost.ShippedByHostProblem, run bycompile/--bake-outputunder both--appand in-process). A module composed with--modulewhose simple name the host also ships in its application directory — or lists in its surface manifest — fails the bake RED: "two builds of one assembly name in one bake … remove the assembly from the platform host's closure or stop composing it." Caught where both provenances are in one hand, never sealed and discovered at the fleet. Pinned byBakeAgainstPlatformHostTest.AComposedModuleTheHostAlsoShips_IsRefusedByName.Availability asserts the SET (
ReleaseAvailability.IsUpdatableover the observationPublishedBundleCatalogue.ArtifactsForIdentitymakes). For the candidate identity the registry reads every complete source's sealed module set — everyMeshWeaver.*assembly each module bundle CARRIES, PE header only, for its MVID — and every sealed bundle's per-NodeType dependency record (manifest only, never assembly bytes). Then: everymvid:entry naming a module the set carries must equal the MVID sealed for it, and no assembly name may appear in the set at two MVIDs. A violation isPackageAvailabilityKind.SealedSetInconsistent— a HOLD that names the bundle, the NodeType, the module and both builds — and it is consumed unchanged by the self-update poll, CD's post-promote assertion and/api/plugins/is-updatable, because all three call the same predicate. Both failure directions are pinned (SealedSetConsistencyTest): an unreadable or pre-module-sealing module set isIndeterminate(hold, named — never "compatible"), and a module the set does not carry is not judged, because the gate cannot see the bytes a registry install landed.🚨 "No name at two MVIDs" counts RIDING copies, not just declared ones (#3221). A module-owned
MeshWeaver.*sibling rides every bundle that references it, so one name commonly reaches a mesh from many bundles at once — 19 of MeshWeaver.Plugins' 37 module bundles carry a copy of an assembly another package declares. That shape is sanctioned and is NOT refused; excluding declared modules from the closure would invert the package graph and break solo installs. What is refused is the copies DISAGREEING, because the loader keeps whichever it saw first and declines every NodeType that recorded the other. Only the DECLARED entry defines what the identity's module IS. Full reasoning and the measurement: Module-Owned Siblings Ride.
The third control: the download serves the module its own assemblies record (#3244)
The set check above compares two halves of ONE publication — the sealed bundles' dependency records
against the sealed module set — and passes when they agree. The instance runs neither: it installs
what /api/plugins/bundles/<pkg>/<version> hands it, and that route was asymmetric. Its NodeType
assemblies resolve through each type's Release node for exactly the CALLER's framework identity
(#1751); its module section came off the registry's own modules/ shelf, which is content-versioned
(Plugins #931) and identity-blind — whatever the module's own lane published last, under a version
that does not move when a rebuild changes the bytes. One archive, two producers, and the consumer's
boot seeder declining every NodeType in it.
The rule is evidence, not a preference order. The assemblies about to be written into the archive
carry, per NodeType, the id they were compiled against; for an installed module that id is mvid: +
its raw MVID. So the archive states which build it needs, and ServedModuleBytes.Resolve hands over
the bytes that ARE that build:
| the served assemblies record | what is served |
|---|---|
| nothing for this module | the shelf, unchanged — nothing binds it, so nothing constrains it |
| the build the shelf holds | the shelf (one PE header read; the healthy path costs nothing more) |
| a build a publication sealed for this identity carries | those bytes, off <root>/<identity>/<source>/modules/<bundle> |
| a build nobody holds | the shelf, plus a divergence in the manifest's module section and a warning naming both MVIDs |
| two builds, disagreeing among themselves | the shelf, plus the named disagreement — no single module can satisfy them |
Three properties are load-bearing:
- Server-side, not client-side. The other shape — an instance fetching its module bytes from the
prebuilt/{identity}/{source}/modulesroute directly — needs a whole-source grant (<source>/*) on every installation, and a whole-source grant deliberately bypasses plan tiering ("a sealed publication carries every plan's bundles"). Deciding at the registry needs no new grant, no new credential and no client change, so the fleet converges on a registry roll rather than on every install updating. - It is right where a preference order is wrong. A registry that COMPILED its own NodeTypes records its own live module, and the shelf is then the correct answer. "Prefer the seal" would serve the wrong bytes there; "serve what is recorded" cannot.
- The unsatisfiable case is NAMED, not hidden. When no producer holds the recorded build the bytes are unchanged — the registry cannot conjure a build it does not have — but the archive says so, so "this lane cannot be satisfied" stops being a fact only a consumer's per-type decline could reveal.
What the gate still cannot see
The instance's already-landed module is still outside every check here, because nothing above runs
until the instance decides to DOWNLOAD. ModuleUpdateDecision takes that decision from the index's
advertised framework identity against the landed entry's (Plugins#931/#723), never from the
module MVID — so the re-land happens on the module's next republish (a platform release triggers the
owning repo's lane, which is exactly when the two builds diverge, so the ordinary path does converge),
and NOT when a seal for the running identity appears while the shelf sits still. A portal in that
window keeps the build it has, and ShippedPrebuiltBundles' dependency record mismatch … live is mvid: line is that residue rather than a new defect.
The remaining structural half is core CD's, and it is a credential decision rather than a code one:
plugins-modules REBUILDS the modules it composes instead of taking registry-modules, because core
holds no registry credential (MW_REGISTRY_KEY is validated in node-repo-publish-bake.yml as the
caller's secret and is not among core's own). Composing the registry's bytes would make the
publication and the shelf one producer at the source, which is strictly better than resolving them at
serve time — but it needs that credential provisioned, and the resolution above is correct with or
without it.
Moving a type OUT of an image-shipped assembly into a module (the shadow set's blind spot)
The reference set for a container build is the whole image's /app, minus
Graph.ShadowedAssemblyNames — and that set holds only the assemblies this run compiles from
source. Membership is reachability-driven: a project joins the graph through a
ProjectReference edge under the source root.
That is exactly right until a type MOVES OUT of an image-shipped assembly into a module in the same repository. Then:
- the module's source defines the type;
- the pinned image still ships the assembly that also defines it, because the image is one wave behind;
- and the
ProjectReferenceedge that would have put the old assembly in the graph — and therefore in the shadow set — is precisely the edge the move deletes.
So the shadow set structurally cannot see the one case that needs it, and the compile fails
CS0436: … conflicts with the imported type … in '<OldAssembly>'. It is the duplicate-producer
problem of One producer per (module, framework identity)
one level down: two definitions of one TYPE rather than two producers of one ASSEMBLY, for exactly
one wave. Measured on MeshWeaver.Plugins#1268, moving three Razor views out of
MeshWeaver.Blazor.Views into the MeshWeaver.Markdown.Collaboration module.
The remedy is a declaration, not an inference. The pack lane takes
superseded-image-assemblies: (comma- or whitespace-separated), passed to build-project as
--superseded-image-assembly <name>; each name is added to the shadow set, so the image's stale copy
leaves the reference set for that run.
Three properties are load-bearing:
- Declared, never inferred. Resolving such a collision automatically in favour of the source
would mask a genuine two-producers-of-one-assembly defect — the thing
BakeHost.ShippedByHostProblemexists to make RED. The caller names the assembly. - Fails closed. An empty name, a name the image does not carry, a name the run already builds from source, and an entry with nothing left to supersede each refuse the build and say which entry. An input that quietly does nothing reads exactly like an input that worked.
- No "is anything else still needed from it?" check. The compiler already answers that with
CS0246/CS0234naming the missing type, which is a positive, specific signal. A second check would only be able to agree.
The entry cannot outlive its reason (#3223)
A superseded entry is correct for exactly one wave. The moment the pin moves onto an image built AFTER the move, that image's copy no longer defines the moved types — but the assembly still exists, so "the container carries no such assembly" never fires. Nothing in the first three refusals can see this, because all three read the input's SHAPE and this one is about its LIFETIME.
A stale entry is not untidy, it is live: it keeps a real image assembly out of the reference set, so
the day something legitimately needs a different type from it the build dies CS0246 pointing at
the consuming code with the declaration that caused it nowhere in the message. An allow-shaped entry
that outlives its reason with nothing to retire it is a recurring defect class here.
So SupersededEntryStaleness refuses the build, before a single project compiles, when the
image's copy of the named assembly defines none of the type names this repository's source
declares — the precise statement of "there is nothing left to supersede", since a collision needs one
name on both sides. The error names the entry to delete.
Two choices in it are load-bearing, and both were made against a failure that would otherwise be routine:
- 🚨 The source side is the REPOSITORY, never the run's selection. The pack lane narrows what it compiles — a PR-scoped diff, plus a build ledger that hands back reused bundles — so on most runs the module that owns the moved type is not in the graph at all. Reading "this run's compilations" as the source side would therefore turn the entry RED on ordinary narrowed PRs during exactly the one wave the entry exists to serve. The source side is the whole tree under the workspace's source root, which the lane mounts and which no selection can shrink.
- 🚨 A
.razorcomponent counts as a definition. The move that motivated the option (MeshWeaver.Plugins#1268) was three Razor views, whose types exist in no.csfile — a check that indexed only C# would call the entry stale in precisely the case it was built for. And matching on the folder alone is not enough either:CS0436needs the SAME fully-qualified name on both sides, so a view that leaves an image-shipped assembly keeps its old namespace with an@namespacedirective (or an_Imports.razorcovering its folder), and its new folder then says nothing about its namespace. Both declared forms are read and recorded exactly; the file-name-plus-directory-path rule is an additional fallback for the derived case, never the only rule — theRootNamespaceprefix is the Razor SDK's to compute, and a re-derivation that got it wrong would produce the false red this check must never produce.
Every other ambiguity is resolved the same way — towards quiet. Type FORWARDERS in the image's copy count as definitions (a forwarded type is as visible to the compiler as a defined one), and an image copy whose metadata cannot be READ is a refusal of its own rather than a staleness verdict: naming the wrong entry to delete is worse than saying nothing.
The index is a syntax parse — no compilation, no semantic model — and it is paid only on the runs
that pass the option at all, which is the rare wave after a move. Measured over core's src/:
1249 files, 2028 top-level type names, 1.76 s, with bin/obj/.git never descended into. The
build narrates the file count, the name count and the elapsed time, so a tree that makes it
expensive says so rather than merely being slow.
Adoption contract (every repo)
- Pin the reusable lanes (
node-repo-*.yml) at a MeshWeaver main SHA — never copy them. - Scripts are centralized: the lane fetches the platform's
.github/scripts/compile-check.pyand.github/scripts/gen-manifests.pyat the pin and runs them against the caller's tree — a repo keeps ONLY itsscripts/compile-check.allowandscripts/gen-manifests.config.json(policy). Per-repo script copies are retired; three compile-check copies had already drifted apart when that landed, and by the time gen-manifests followed on 2026-09-07 its six copies were five vintages, so each of #434, #942/#1023 and #1426 was fixed in one repo and live in the rest (Module Versioning → "One checker, every repo"). ("We can ship in hosting" — the endgame is acompile-checkverb inside the tester image itself, where the reference set is the container's by construction; the fetched-script stage is the unified interim.) - Reference this page from the repo's AGENTS.md — the build section defers here.
- Repo-specific policy (module lists, always-modules, allow-files, registry consumption) stays in the caller; mechanics never do.
The release: the pipeline ends by calling memex
The contract (maintainer, 2026-09-03: "end of github pipeline must call memex, which must register release and publish event") is three sentences:
- Every publishing pipeline ENDS with one call to memex. Core's CD, after the image set is
promoted, POSTs the signed platform build (
event: platform-build) into the control instance'sHosting/PlatformBuildsinbox (notify-platform-update). Every node repository'snode-repo-publish-bake.ymlrun, after its bundles are sealed for an identity, POSTs the signed publication record (event: bundle-publication— source, identity, commit, tester + portal image) into the same inbox (register-publication, its last job). Nothing runs after that call, and no pipeline sends arepository_dispatchto another repository. - memex REGISTERS the release as a durable node —
Hosting/PlatformBuilds/<version>for a platform build,Hosting/Publications/<identity>/<source>for a bundle publication — the source of truth for "what is published for which identity" (what the self-update availability check reads). - memex PUBLISHES the event from that registration:
FrameworkReleaseBroadcastersendsmeshweaver-framework-released(platform) ormeshweaver-upstream-published(bundle publication,client_payload.version= the identity) to the subscribed repositories — the repositories the control instance'sHosting/Deploymentrecords name as registry sources. The subscribers' CI receives it, resolves both images from the version, builds and publishes for that identity — and ends by calling memex (1).
pipeline (core CD | a node repo's publish-bake) memex (control instance) subscriber CI
─────────────────────────────────────────────── ──────────────────────── ─────────────
promote / seal ✅ WebhookInbox Hosting/PlatformBuilds
└─ ONE signed POST ──(platform-build |──────────────▶│ verify HMAC
bundle-publication)… and FINISH ├─ REGISTER Hosting/PlatformBuilds/<version>
│ Hosting/Publications/<identity>/<source>
├─ subscribers = Hosting/Deployment records'
│ pluginRepos[].isRegistrySource
└─ PUBLISH repository_dispatch ─────────────▶ on: repository_dispatch:
meshweaver-framework-released | types: [meshweaver-framework-released,
meshweaver-upstream-published meshweaver-upstream-published]
→ bake for the version → seal → POST memex
Where the pieces are: the POST steps in main-cd.yml and node-repo-publish-bake.yml (this repo);
the inbox watcher, registration and broadcast in the Hosting module's PlatformBuildInboxWatcher
(MeshWeaver.Plugins, Hosting/Deployment/Source); the broadcaster in src/MeshWeaver.GitSync.
PlatformReleaseNotifyGuard.CoreDispatchesToNoRepository refuses a dispatch SENDER in any workflow
under .github/workflows — there is no ledger — and
UpstreamBuildGateGuard.TheLaneEndsByRegisteringWithMemex_AndDispatchesToNobody pins the lane's call.
In flight, in this order (the contract is complete only when all have landed): MeshWeaver.Plugins#1241
wires the platform half (broadcast + system identity + subscribers from the records) and is
observed firing before core withdraws its dispatcher (MeshWeaver#3185, this change); a Plugins
follow-up makes the watcher REGISTER the nodes named in (2) and handle event: bundle-publication
(register + meshweaver-upstream-published, dependency-scoped through the registry's package
requires graph so a publication cannot wake its own upstream); each node repository passes
webhook-url / webhook-secret to the lane when it moves its pin (the lane is RED, naming them,
until it does — a sealed publication memex was not told about is silent drift). Once Plugins receives
the platform event and publishes its own bundles on it, core CD's plugins-bake job is a SECOND
producer of the same publication and is removed — a follow-up, not part of this change.
Roadmap (agreed, in flight)
The one-workspace lane — landed: one
build-workspacejob compiles the ledger's build subset as one graph; pack/tests fan out from it. The consumer half of #931 landed too —ModuleUpdateDecisioncompares the served bundle's framework identity, not the version alone. The producer half landed with #3211 — a bundle that cannot state what it was built against is refused at the packer, at the pack step and at the hand-over (see A bundle states what it was built against — or it is not published, above). The registry's own 400 inModulePublish.Validateis armed as of 2026-09-05 (#3240) — it waited on every producer's lane pin having moved AND on a publish wave from each stating an identity, both measured before arming. Still open: pinning satellites'MW_PLATFORM_REFto the promoted set so keys survive core commits.pack-module,compile-checkand test verbs inside the tester — the last runner-sidedotnet/python uses move into the image the fleet already ships.Self-hosted runners with a mounted git mirror + warm image store (MeshWeaver#2926): bare mirror volume,
git worktree addper run at the release ref, worktree deleted at job end.The hand-over waits for the full source verdict — landed with MeshWeaver#3878. The POST to the live registry moved out of the pack leg (where it fired while the run's sibling suites, the portal-host shards, the NodeType compile-check and the Tests-area gate had not reported) into
node-repo-module-publish.yml, downstream of the caller's whole validation set.packstages underpublish-mode: stagedand everything a downstream gate consumes stays exactly where it was — see ModulePublicationGate.
See also: ModuleClosureAccounting · ModuleOwnedSiblingsRide · ModulePublicationGate · ModuleVersioning · NodeTypeCompilation · PluginBuildContract · BuildProcess · InMeshBuildAndTest.