The Execute-Time Build-Provenance Interlock

Refusing to load stale bytes is the second line of defence. Refusing to run them is the first.

Adopting a prebuilt assembly is what makes installs and restarts cheap. It is also the one path that could make a NodeType assert something nobody established: on 2026-08-30 a GitSync update pulled new source, adopted a prebuilt built from older source, reported success, and the stale code destroyed four client documents' bodies β€” one unrecoverable (#2813).

BuildProvenance (that issue's fix) makes the state visible and refuses a provably-stale adoption at load time. This page is the other half (#2820):

The damage needed TWO ingredients β€” stale bytes, and something armed to run them. Stale bytes sitting unloaded harm nobody.

The verdict is three-valued, and the middle one is the important one

NodeTypeExecutionGate.Evaluate(NodeTypeDefinition?) answers with exactly one of three, never a boolean:

provenance verdict why
AdoptionRefused Refused The bundle NAMED the sources it was built from, they are not this mesh's, and the module MAJOR moved β€” a declared incompatibility. The one hard refusal.
StaleAdopted Permitted 🚨 The source moved past the build but the two share a module MAJOR: the last build this mesh holds keeps serving, marked (#3583, below).
AdoptedUnverified Permitted 🚨 A legacy bundle carries no fingerprint, so nothing was compared. Unknown is not proven-stale.
AdoptedVerified Permitted Fingerprints compared and equal.
Compiled Permitted Roslyn built these bytes here, from this mesh's source. Also the zero value, so a record written before the field existed reads honestly.
(definition unreadable) Inconclusive No verdict was reached. Neither a clean bill of health nor a refusal.

🚨 AdoptedUnverified must never be folded into AdoptionRefused. Every bundle published before producers recorded a source fingerprint adopts as unverified. Refusing those would park every legacy type on every mesh β€” and on a Modules:RequirePrebuilt mesh a local compile is refused by design, so there would be no recovery path at all. That is precisely the outage "refuse every unproven bundle" was rejected to avoid, arriving through a different door. The same reasoning governs the legacy row in ApplyAdoptedSourceStamp, and it is pinned by NodeTypeExecutionGateTest.AdoptedUnverified_IsPermitted_TheAntiOutageProperty plus the AnUnverifiedAdoption_StillArms_TheAntiOutageProperty rows of ExecuteTimeInterlockTest.

Where the predicate lives. NodeTypeExecutionGate is in MeshWeaver.Compiler.Pipeline, not MeshWeaver.Graph.Contract β€” even though the latter is where NodeTypeDefinition now lives and would be the tidier home. Graph.Contract is inside MeshWeaver.Compiler's reference closure, which is a full-MVID toolchain root, so a body-only change there re-bakes every NodeType on every mesh. The pipeline is surface-hashed and is referenced by both enforcement sites, so it carries the predicate at no rebake cost. See Graph / Compiler Layering.

BuildExecutionVerdict.Inconclusive is a separate member for the reason ErrorType.Unavailable is: a probe must not answer its scariest branch β€” or its friendliest one β€” on its own inability to run. A boolean gate would force the caller to pick, and both picks are wrong.

Where a NodeType's compiled code can actually run

A census, because a check in only some trigger surfaces is worse than none β€” it makes the gap look closed. Every surface below either loads a NodeType's assembly or invokes something out of it.

# surface what it does gated?
1 NodeTypeEnrichmentHelpers β€” hot activation path Binds the type's HubConfiguration onto a per-instance node; MonolithRoutingService / MessageHubGrain then build the real hub from it βœ…
2 …its pinned-release branch Same, from RequestedReleasePath βœ… (same check, above both)
3 NodeTypeContractHandler β€” legacy GetCompilationPathRequest Hands a configuration to an activating instance βœ… (via the same node state)
4 CellSurfaceAssemblyProvider β€” the kernel cell-surface join Loads the assembly straight through NodeAssemblyLoadContext so every script submission in the session can call its functions by bare name, with full write access βœ… (its own check)
5 NodeTypeDataModelAreas.ProbeInstanceModel Renders the type's $Model / data-model page through a transient probe hub ❌ deliberate
6 MeshDataSource.HandleNodeTypeSchemaRequest Answers one SchemaReference, transient probe ❌ deliberate
7 MeshOperations.ReadFromContentType Schema validation for agent-facing tools, transient probe ❌ deliberate
8 NodeTypeBatchBake / DynamicTypePreWarmer Boot-time compile/pre-warm sweep β€” produces Compiled provenance by construction n/a

Surfaces 1–3 all pass through ApplyStreamResult, so one check above every assembly-resolving branch covers them; that is where the gate sits. Surface 4 is architecturally separate β€” it never enriches and never builds a HubConfiguration β€” so it carries its own check rather than an inherited one.

Read vs write: the split the issue asked for is not available at the type level

The natural design would be "reads keep serving, writes refuse". It is not implementable here, and the reasons are worth stating rather than rediscovering:

So the cut is not read-vs-write but durable-arming vs transient-probe, which the framework already draws for its own reasons: AsTransientNodeProbe hubs get the data context but not the per-node control plane, no persistence sampler and no node identity β€” their own documentation says "a probe hub must never be used to WRITE" β€” and they are disposed in the same breath. Refused bytes may still answer a schema question inside one of those; they may not be given a long-lived, message-processing, persistence-capable home, and they may not be joined into a live kernel session.

The practical effect: the refused type's own pages keep rendering β€” Overview, data model, schema, compile diagnostics β€” which is exactly what an operator needs in order to diagnose it. Its instances serve the refusal card instead of their real areas.

Where this is actually reachable

On a mesh that can compile locally the gate is mostly unreachable by construction: a refusal already clears the assembly coordinates and flips Pending, so HasUsableBuild is false and the ordinary bytes-missing branch takes over. Two states remain, and they are the ones that matter:

  1. Modules:RequirePrebuilt β€” where the refusal deliberately keeps the coordinates, because clearing them would leave the type with no assembly at all, indefinitely, and only a human rebake can replace them. That is the state in which proven-stale code was left executing.
  2. The window between PrebuiltAssemblySeeder.Seed stamping the coordinates and the owner judging them.

The refusal is a verdict, not a timeout

A refusal nobody can see becomes "the portal is broken", and a refusal that reports as slow costs an hour β€” #2818 documents exactly that. So the refusal announces itself four ways, all carrying the same sentence:

The recovery verb

Recompile the type β€” the Recompile button, or the compile verb over MCP. Since #2824 a forced release skips on-demand re-adoption and compiles the live source, which is what makes this a real remedy rather than a re-adoption of the same bytes. A successful compile stamps BuildProvenance = Compiled, the overlay's self-heal watcher sees the type's version advance with a usable build, and every stuck instance recycles itself onto its real page.

On a Modules:RequirePrebuilt mesh there is no local compile: rebake and republish the package, then request a release. The log line says so, in those words.

🚨 The self-heal is what makes the refusal safe to ship. Without ApplyCompileSuccess resetting the provenance, one refused adoption in a node's history would mark it permanently and this gate would refuse a type whose live source it had just compiled itself.

What this deliberately does NOT do

Refusal is keyed on version compatibility, not on the fingerprint (2026-09-09)

Measured on memex.systemorph.com, platform 3.0.0-ci.8057 (#3583):

11:56:53Z  Plugins#1555 merges β€” a one-line CSS change in Essentials/Email/Source/EmailLayoutAreas.cs
12:30:56Z  the tree sync rewrites that source on the portal; the only bundle for the portal's identity predates it
12:32:45Z  Essentials/Email: compilationStatus = Error, buildProvenance = AdoptionRefused
           adoptedSourceFingerprint 572183a89fca620b, currentSourceFingerprint 4ed23561cfd142d1
           latestAssemblyPath Essentials_Email/v331-… β€” a WORKING assembly, still on the record

Every page of the type rendered "the platform refused to run it" for the afternoon. The #2813 gate was right about the bytes (they were older) and the untracked-module gate was right about the source (nothing syncs that partition's files); together they turned a cosmetic upstream change into an outage of every instance of the type. The portal owner's rule replaces "keep the last adopted build" with something more precise:

The adoption decision is a module-version compatibility check, not an exact fingerprint match. Same MAJOR β‡’ compatible β‡’ the adopted build MUST keep serving (stale-but-serving, naming both versions and that a bundle for the running identity is pending). Only a MAJOR bump β€” a declared incompatibility β€” may refuse the adopted build, and even then the type must not error: it reports "incompatible, awaiting bundle". The fingerprint stays as the signal that the source moved; it no longer drives refusal.

The rule is one pure function, ModuleVersionCompatibility.Classify(adopted, current), with three answers β€” Compatible, Incompatible, Unknown β€” and only Incompatible refuses. The two versions are:

Where it lands, and what each seam now settles to (BuildDeliveryHold is the one place):

seam fingerprint differs, same MAJOR / unknown fingerprint differs, MAJOR moved nothing to serve
owner's judgement (ApplyAdoptedSourceStamp) StaleAdopted; Ok on a mesh that will not compile, Pending (compile the live source, build serves meanwhile) on one that will AdoptionRefused; Unavailable + "incompatible, awaiting bundle" on a mesh that will not compile, Pending with the coordinates cleared (#2813, unchanged) on one that will β€”
compile-watcher delivery gates (RequirePrebuilt, untracked module) Ok + StaleAdopted, coordinates untouched, no park Unavailable + notice, gate refuses execution Error park, named β€” the truth
bundle seeder, bytes declined on fingerprint live build resolves β‡’ declined, record untouched; nothing resolves and the mesh will not compile β‡’ adopted as StaleAdopted declined; Critical when nothing can serve β€”

Unavailable, not Error, for a refused-but-held build: an Error is a Roslyn verdict on the code, the instance overlay tells the author to fix it, and the readiness gate reads it as a regression that freezes self-update β€” none of which is true of a bundle that has not arrived yet.

Announcing readiness. A hold is a transition a person should hear about, and so is its lifting. BuildDeliveryHold.EventOf(before, after) decides β€” HeldStale, HeldIncompatible, Adopted, Compiled β€” and every writer that can make the transition (the three stamp-request fulfillers, the two gates, the compile write-back) notifies on it exactly once, to RequestedReleaseBy when there is one and to the platform operators' bell otherwise: "Essentials 1.2.3 adopted: 'Email'" is the signal a person can act on, in place of discovering a dead page by reloading it.

What the change does not do: a fingerprint that differs on a mesh that CAN compile still compiles the live source (the build serves meanwhile); a MAJOR bump on such a mesh still takes the #2813 path unchanged; and the sync still lets source run ahead of bytes β€” holding a module's tree sync until a bundle for the registering portal's identity exists is SealedSyncGate's job (#3600/#3612), which is inert until a seal written by a post-#3612 lane exists for a live identity.

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