Rolling-Update Build Tolerance

Maintainer directive, 2026-09-12 (relayed by the coordinating session): "it should all be tolerant" — a page of an instance whose type has a last-good compiled assembly must render on that assembly while a recompile is in flight; the 30-second "did not settle" fallback is itself the defect when a loadable build exists. That directive is what this page implements.

Two further statements reached the investigating session mid-task from an unattributed, unverified source (not the maintainer's verbatim words, not a doc): "compile must happen on CI — at runtime you must resolve a package version, not try to compile" and "dynamic compilation should be disabled for CI-built plugins." They agree with the existing #3583 rule (a stale-but-compatible page beats a refusal), and rule 3 below leans on them; the structural remainder in the issue at the end is conditional on the maintainer confirming them.

The incident, measured

On 2026-09-12 at 06:25Z https://memex.meshweaver.cloud/DeepSign — an instance of the Store/Plugin NodeType — rendered:

This item's type has not finished compilation yet, so its page couldn't be built. NodeType 'Store/Plugin' build did not settle within 30s. Instance 'DeepSign' is rendering this fallback until the type's build settles.

get @Store/Plugin at the same moment said compilationStatus: Ok. Every other instance of the type on the new pods (Edu, RolePlay, Chess, ClaudeCode, Voice, WebSearch, DataModelling, LearningRoadmap, Hosting, Approvals, DoublePendulum, AgenticEngineering, …) logged the same TimeoutException: NodeType 'Store/Plugin' recompile did not settle and no compile is in flight (within 30s), and the content-types health check reported Degraded: Store/Plugin ×116 … this replica cannot type.

What the mesh recorded:

Time (UTC) Fact Where
06:10:48 → 06:14:48 Rolling update ci.8372ci.8399 on memex-cloud/memex-portal-deployment (maxSurge 1, maxUnavailable 0); the three old pods get Killing at 06:12:22, 06:13:35, 06:14:48 kubectl get events
terminationGracePeriodSeconds: 1800 with a preStop that waits on /drain for up to 1680 s — the old generation keeps serving hubs for up to 30 minutes after Killing Deployment spec
ci.8399 announces framework identity sa74cfbd… (DynamicTypePreWarmer: … framework identity sa74cfbdd57e3a4f4440792fd1800aa3d, build g4c99ec26…); ci.8372 (still running as memex.systemorph.com) announces s6649734… pod logs
06:17:48 / 06:17:50 / 06:18:02 Three concurrent Update Store to the built commit 38ebf08e activities (one per new pod) Store/_Activity
06:18:38 Release X3ICiS9-: an adoption under sa74cfbd (v13825-sa74cfbd-…dll, no compile activity) — the CI bundle for the live identity was on /data/prebuilt-bundles/sa74cfbd… Store/Plugin/Release
06:18:58 … 06:23:08 Eight compiles, all producing s6649734-tagged assemblies (v13834, v13839, v13843, v13847, v13855, v13859, v13863) — the per-NodeType hub's owner grain was on a draining old-generation pod, and every activation on a new pod read the record as ABI-stale, flipped it Pending, and the old owner rebuilt it under its own identity Store/Plugin/_Activity/compile-*, Release artifacts
06:23:12 One compile producing sa74cfbd (v13872-sa74cfbd-…dll) — the owner grain had moved to a new pod same
06:24:53 The record settles for the new generation: compiledFrameworkVersion sa74cfbd, adoptedSourceFingerprint == currentSourceFingerprint == c4730d4bf8f180fb, buildProvenance AdoptedVerified, version 13874 get @Store/Plugin
06:25 → 06:44 Activations on the new pods still time out — the instance page stays on the fallback; get @DeepSign/layoutAreas/ times out pod logs, MCP
≈ 06:45 The last old pod's grace expires; DeepSign resolves again (its page still shows the overlay it bound earlier until the instance is recycled) MCP

The same shape on 2026-09-11 18:47–18:55Z (ci.8323ci.8372) and on 2026-09-10 14:40–14:47Z. Store/Plugin on memex is at node version 13 870; on systemorph, which rolls less often, 2 895.

The seeder's line names the second defect. On every new pod:

Prebuilt assembly for Store/Plugin DECLINED before writing (#2813): the bundle records source
fingerprint c4730d4bf8f180fb but the live sources are bf501962b9ea4c2a (bundle module version
221c6c286785ddf2, current 1.10: Incompatible) — the owner would not verify the adoption …
ShippedPrebuiltBundles: adopted no prebuilt assembly for 1 of 2 requested NodeType(s) — Store/Plugin.
A bundle entry under /data/prebuilt-bundles/sa74cfbd… DID name 1 of them … so those bytes were
present and did not land.

221c6c286785ddf2 is manifest.lock's content hash (the bake wrote ReleasedVersion ?? ModuleVersion ?? Version, i.e. the hash whenever no released version was recorded); ModuleVersionCompatibility.MajorOf read its leading digits as major 221 against the root's 1.10 and classified the pair Incompatible — the one verdict that refuses. The bundle's fingerprint (c473…) was the fingerprint the new generation itself computed; the "live" bf50… it was compared against had been stamped by the old generation's toolchain.

The mechanism

Three things compose:

  1. A rolling update runs two platform generations against one mesh for the whole termination grace. With a 30-minute drain, that is not a race window; it is the normal operating state of every roll.
  2. A NodeType record holds ONE compiledFrameworkVersion. Each generation reads the other's build as ABI-stale (HasUsableBuild is an equality on the live identity). The record cannot be "usable" for both.
  3. The activation path was intolerant. On a foreign stamp it flipped the type Pending and waited for a usable build (TriggerRecompileAndRetry … requireUsableBuild: true); on an in-flight status it waited for settle (IsCompileSettled rejects Pending/Compiling) and then painted the progress overlay; when the compile ran on the other generation the wait never satisfied and the 30-second no-progress budget produced the fallback. Every activation is a fresh attempt, so the "bounded" retry (MaxRecompileAttempts = 1) bounded nothing across a fleet of instances — that is the storm.

After the record settled for the new generation (06:24:53) the activations still failed. Storage was usable; the node the activation decided on came off the mesh-hub mirror — the workspace's per-path replay subject — which had replayed the old owner's snapshot after that owner drained and its sync stream went silent. This is the shape #2409 fixed on the in-flight branch (ConfirmInFlightAgainstStorage); the framework-stale branch had no such confirmation. (The mirror latch is the inference that fits every measurement; the ENRICH-DIAG lines that would show the mirror's value are logged at Information and were not retained at production level. The fix below does not depend on the inference: it consults storage on that branch regardless.)

The tolerance rules (implemented)

1. An in-flight type that still names a loadable last-good build is BOUND, not awaited. NodeTypeEnrichmentHelpers.IsBindableWhileCompiling — Pending/Compiling and HasUsableBuild for this process — is admitted by the slow path's settle predicate (IsBindableOrSettled) and routed to ApplyStreamResult, whose HasUsableBuild branch precedes every status branch and arms WithStaleAssemblySelfHeal. The instance renders on the previous bytes now; when the rebuild publishes a different usable path, the watcher offers (or, on a converging portal, takes) the new build. An in-flight type with nothing loadable keeps the grace-then-progress-overlay behaviour: there is nothing to render on.

2. The framework-stale branch confirms against STORAGE before it asks for a rebuild. One AuthoritativeTypeRead (the same read the in-flight branch already makes); when storage holds a build for the live identity, the activation binds it and writes nothing. Only when storage agrees the build is foreign does the Pending flip happen — and on a mesh where module content resolves from bundles that flip is the next thing to remove (see the issue).

3. A compatible CI bundle beats a local compile (motivated by the unverified statements above; also the tolerant reading of #3583 — clearing a record and compiling leaves no page in between). PrebuiltAssemblySeeder.DecideAfterStaleDecline is the rule as a table: when the record's build does not load on this process, no compile is in flight, and a bundle for the live identity is in hand whose module version is compatible or unknown, it is adopted as the last build the mesh holds (StaleAdopted) — a page on CI-built bytes immediately — instead of clearing the record and dispatching Roslyn. A compiling mesh still converges (the stale-adopted record reads dirty and the next release request rebuilds it behind a page that is already up).

4. A content hash is never a SemVer major. ModuleVersionCompatibility.MajorOf yields a major only for a version-shaped value (digits followed by ., -, + or the end); a hash reads Unknown, which never refuses. And the bake now records ReleasedVersion ?? Version ?? ModuleVersion, so new bundles carry the SemVer the rule was written for.

Tests: InFlightLastGoodBuildBindsTest (predicates, virtual-time wait), ForeignFrameworkStampOnTheMirrorTest (real mesh: foreign stamp on the mirror, usable build in storage ⇒ bound, version unchanged, no Pending flip), StaleDeclinePrefersBundleTest (the decision table), ModuleVersionCompatibilityTest (the hash pair that refused Store/Plugin).

What this does not fix (tracked)

Tracking issue: #4071 — Rolling-update compile storm: per-identity build records and no runtime compile for CI-built plugins.

Operator notes

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