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
  1. 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". A main push 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 superseded main run by construction (Superseded runs on main).
  2. 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.
  3. build — one Roslyn workspace. The builder compiles the selected modules plus their in-repo dependencies as one graph: every project shares the same PortableExecutableReference per path (each assembly read from the filesystem once), one body pass like csc (parse+declaration diagnostics up front, body diagnostics from the single Emit), 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).
  4. pack fans out from the build's outputs — it consumes, it never recompiles.
  5. tests — beside the chain, or inline, and publish decides which. See below.
  6. 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:

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:

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 12 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:

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.

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

  1. A push run on the trunk is never cancelled — not by the concurrency group (cancel-in-progress is 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.py refuses the call). Every merge's run reaches its own verdict.
  2. A push builds its DEPENDENCY NETWORK, never everything. The scope is the affected closure — the in-mesh requires/dependents walk plus the ProjectReference compile 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.
  3. 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.
  4. 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 (receipt publication: 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.
  5. 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=none at 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, since SealedSyncGate holds 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.
  6. The platform release is the ONE full run. repository_dispatch and the schedule poll 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.
  7. Compatibility is decided by floors. A bundle states the framework it was built against and its minMeshVersion/requires ranges, and an instance adopts it only when they hold. Consistency is forced inside a dependency network (rule 2) and never across unrelated modules.
  8. 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

🚨 Superseded runs on main — cancelled by construction, selected by the compile tree

Repos on per-module deploy (above) do not cancel main runs 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

  1. 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). main once 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=0 alone is NOT the starvation signature — read the in-flight run beside it. Core's own main-cd keeps ONE group on the ref with cancel-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, reporting cancelled with 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".
  2. Only pushmain supersedes pushmain. A repository_dispatch (the platform wave) and the schedule poll are never cancelled by this rule and never cancel anything — the #826 rule, kept intact. Each main push 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) 21Maps, 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:

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

  1. — 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

  1. The bake refuses a double by name (BakeHost.ShippedByHostProblem, run by compile / --bake-output under both --app and in-process). A module composed with --module whose 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 by BakeAgainstPlatformHostTest.AComposedModuleTheHostAlsoShips_IsRefusedByName.

  2. Availability asserts the SET (ReleaseAvailability.IsUpdatable over the observation PublishedBundleCatalogue.ArtifactsForIdentity makes). For the candidate identity the registry reads every complete source's sealed module set — every MeshWeaver.* 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: every mvid: 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 is PackageAvailabilityKind.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 is Indeterminate (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:

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:

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:

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:

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)

  1. Pin the reusable lanes (node-repo-*.yml) at a MeshWeaver main SHA — never copy them.
  2. Scripts are centralized: the lane fetches the platform's .github/scripts/compile-check.py and .github/scripts/gen-manifests.py at the pin and runs them against the caller's tree — a repo keeps ONLY its scripts/compile-check.allow and scripts/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 a compile-check verb inside the tester image itself, where the reference set is the container's by construction; the fetched-script stage is the unified interim.)
  3. Reference this page from the repo's AGENTS.md — the build section defers here.
  4. 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:

  1. 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's Hosting/PlatformBuilds inbox (notify-platform-update). Every node repository's node-repo-publish-bake.yml run, 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 a repository_dispatch to another repository.
  2. 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).
  3. memex PUBLISHES the event from that registration: FrameworkReleaseBroadcaster sends meshweaver-framework-released (platform) or meshweaver-upstream-published (bundle publication, client_payload.version = the identity) to the subscribed repositories — the repositories the control instance's Hosting/Deployment records 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)

See also: ModuleClosureAccounting · ModuleOwnedSiblingsRide · ModulePublicationGate · ModuleVersioning · NodeTypeCompilation · PluginBuildContract · BuildProcess · InMeshBuildAndTest.

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