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. |
π¨
AdoptedUnverifiedmust never be folded intoAdoptionRefused. Every bundle published before producers recorded a source fingerprint adopts as unverified. Refusing those would park every legacy type on every mesh β and on aModules:RequirePrebuiltmesh 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 inApplyAdoptedSourceStamp, and it is pinned byNodeTypeExecutionGateTest.AdoptedUnverified_IsPermitted_TheAntiOutagePropertyplus theAnUnverifiedAdoption_StillArms_TheAntiOutagePropertyrows ofExecuteTimeInterlockTest.
Where the predicate lives.
NodeTypeExecutionGateis inMeshWeaver.Compiler.Pipeline, notMeshWeaver.Graph.Contractβ even though the latter is whereNodeTypeDefinitionnow lives and would be the tidier home.Graph.Contractis insideMeshWeaver.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:
- Every surface executes assembly code, including the read-only ones.
MeshNodeCompilationService.CompileResultFromAssemblyloads the assembly and then callsActivator.CreateInstanceon everyMeshNodeProviderAttribute-derived type it finds β before any caller has decided whether to use the result. A "read-only render" already runs the assembly's constructors and static initialisers. - A rendered layout area is not write-free. Areas carry
WithClickAction, theEditmacro binds a writable editor, and aWithInitializationwatcher installed by the same configuration fires unattended. "Renders a page" is not a property that implies "cannot write". - Tagging the writer is not available either. Attributing a write back to the assembly it came
from would need an ambient execution identity across
IObservablehops β the shape this codebase forbids, and whichAsyncLocaldoes not survive.
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:
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.- The window between
PrebuiltAssemblySeeder.Seedstamping 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:
- On the page β the instance activates and serves an overlay whose lead-in says a build exists
and the platform refused to run it, "this is not a compilation error in the source". The copy is
resolved through
host.Localizeat render time off the viewer'sAccessContext, so it is German for a German reader (ui.executionRefusedIntro/ui.executionRefusedGuidance). - To every caller β the overlay installs an
UnhandledMessageNackwithErrorType.ExecutionRefusedand the NodeType path, so a typed request gets a terminalDeliveryFailurenaming the type. Deliberately notCompilationFailed(which would send an author to edit source Roslyn never rejected β #641) and notUnavailable(which reads as "retry" when a verdict was in fact reached). - In the log β
LogCriticalat the arming site,LogErrorat the cell-surface join, both naming the type and both fingerprints, so the verdict can be checked against the bundle by hand. - On the record β
BuildProvenanceis on the NodeType's node and mirrored onto the compile-state satellite at{type}/_Activity/compile-state, readable throughGetMeshNodeStream(path).
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
ApplyCompileSuccessresetting 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
- It does not refuse
AdoptedUnverified. See above; this is the anti-outage property and it is the assertion most worth protecting. - It does not gate the transient probe surfaces (rows 5β7). They execute assembly constructors,
which is a residual and is named here rather than assumed away: a probe cannot persist, has no
node identity, and is disposed immediately, so the exposure is a bounded constructor run, not an
armed control plane. Closing it means gating inside
CompileResultFromAssembly, which would require plumbing the definition intoIMeshNodeCompilationServiceβ a contract change worth making on its own evidence, not as a side effect. - It does not attribute individual writes. A refused type that reaches outside the mesh β HTTP, email, a foreign-language kernel worker β is stopped only because it was never armed, not by any per-write check.
- It does not change
HasUsableBuild. That predicate also drives the release watcher's "satisfied by the existing current build" branch; folding provenance into it would change which installs recompile, which is a different decision with a different blast radius.
Related
- NodeType Compilation & Releases β the adoption check that
produces
BuildProvenance, and the conditional decision about whether refused bytes keep serving. - Plugin Packaging β where the producer's source fingerprint is written into a bundle.
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:
- adopted: the bundle manifest's released SemVer (
manifest.lock'sversionat the bake), stamped asNodeTypeDefinition.AdoptedModuleVersionwhen the bytes are adopted; - current: the partition root's
content.versionβ theStore/Pluginroot the tree sync rewrites together with the sources β published by the sources watcher asCurrentModuleVersionin the same write asCurrentSourceFingerprint. The root, not a bundle's manifest, because the incident's whole shape is "the source moved and no bundle for this identity has caught up": the only current version on the mesh is the one the sync wrote.
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.