Module Sync Per Manifest Hash

Policy module-sync-per-manifest-hash, coordinated with platform-backwards-compatibility (Module Versioning):

An instance always syncs every module it has. Nothing holds a whole Space or partition behind a per-identity seal. Each module is judged alone, keyed by the content hash in its own manifest.lock. The seal decides only whether a NodeType adopts prebuilt bytes or compiles from the synced source — never whether, or at which commit, the sources arrive.

Why the whole-Space hold had to go

SealedSyncGate used to let a module-bearing repository's sources advance only to the commit sealed for the running framework identity, and hold them otherwise (When a Publication Seal Stops Advancing). That verdict was per repository, and it applied to every Space of that repository at once.

Under the compatibility ladder the only seal a running identity can use is one produced by a build at or below the running one. When the newer seals all come from newer platforms, the one usable seal stays where it is — for as long as the instance is not rolled — and every Space of the repository stays with it.

Measured on the control instance (2026-09-25): image 3.0.0-ci.9218, Hosting/_GitSync read lastSyncOutcome: Held at Plugins commit 7545d355, and every newer Plugins publication was sealed only by 3.0.0-ci.9321 or later, which the ladder does not adopt here. The migrate-first Roll planner (MeshWeaver.Plugins#2219) is Hosting code, so it never reached the control plane. That control plane went on planning its own rolls with the old planner, which skipped the database migration, so the new pods crash-looped on DbVersionGate for about a day. The hold's own text named the remedy as "the roll". That was a bootstrap deadlock: the thing that plans the roll depended on the roll.

Under the ladder the hold protected nothing. Sources compile against the RUNNING platform, and bytes built for a newer platform are refused at adoption, one type at a time, with both versions named. The hold only ever stopped the sources, and those are the part that was safe to deliver.

The rule, per module

The pure decision is ModuleSyncDecision.Decide (MeshWeaver.GitSync). It runs inside every import, after the fetch, over each manifest.lock in the incoming tree:

Order Condition Outcome Written
1 the module's root index.json declares content.minMeshVersion above the running platform (PlatformFloor.Evaluate — the ONE floor decision every package consumer uses, policy package-min-mesh-version; unknown, unreadable or unorderable on either side, or a local -ci.0 build, is accepted) Declined — the reason names both versions nothing for that module; its siblings sync
1b the module's root index.json declares a content.requires entry (AI@^1.21.0) that the dependency's loaded module does not satisfy (ModuleSyncDecision.DeclineUnmetRequirements, against ILoadedPackageModules; an unknown loaded version, an uninstalled dependency or an unreadable range is not judged; the image's OWN copy is judged at the version its module.seed.json stamp states — see below) — #6067 Declined — the reason names the requirement, the loaded module and its version; UnmetRequirement on the outcome nothing for that module; its siblings sync
2 incoming moduleVersion equals the one the Space recorded when that module last landed, and the import is not a reconcile or a force Unchanged nothing
2b the module's floor is not stamped for these sources — its mesh-floor.lock witness records a contentHash other than the incoming content's (ModuleFloorWitness.ContentHash, the stamp's own rule) — and this instance knows a platform newer than it runs (INewerPlatformReading, the self-update's newest recorded tag) (ModuleSyncDecision.HoldUnverifiedFloors) Declined — FloorUnverified on the outcome, Floor keeps the declared floor, AvailablePlatform names the newer platform, the reason names both hashes, the stale floor and both platforms nothing for that module; its siblings sync
3 anything else: changed, never recorded, or a manifest that states no hash Synced the module, at the incoming commit

A floor nobody stamped for these sources (rule 2b)

A package's floor is stamped after the commit that changes its sources: main's green run stamps it, and until that stamp lands every package whose sources moved still declares the floor of its PREVIOUS sources (PackageFloors). So rule 1 judged a floor that said nothing about what it let through. Measured 2026-10-09 on memex.systemorph.com (running 3.0.0-ci.10310, 3.0.0-ci.10319 available): MeshWeaver.Plugins 73e5065d carried the approvals inbox on the data-bound row selection (Plugins#3214) while Hosting still declared 3.0.0-ci.10305; the import synced it at 12:07Z, the 3.0.0-ci.10310 image had no renderer for the selection, and nothing in Hosting/Approvals could be selected. The stamp for exactly that content, at 11:57Z, was 3.0.0-ci.10317 — but 73e5065d predates the stamp commit.

The witness (<Package>/mesh-floor.lock, contentHash + verifiedOn) says which sources its floor vouches for, and the import now recomputes that hash for the incoming tree exactly as the stamp did: the package's files by raw-byte sha256 (all but manifest.lock, .DS_Store and the top-level witness), the out-of-folder entries manifest.lock records (a mixed package's src/ project), and index.json hashed without its minMeshVersion value as Python's json.dumps(sort_keys=True, ensure_ascii=False) writes it. ModuleFloorWitnessTest pins it against the node repository's own witnesses: two real packages copied byte for byte, plus a synthetic package hashed by the Python rule. An offline sweep of the same rule, which is not part of the suite, matched 76 of 76 witnesses at Plugins 0b8f8754 and detected 23 pending packages at 73e5065d, Hosting among them.

A held module is reported apart from a floor decline. Its declared floor stays in Floor (it is not above the running platform), the newer platform is in AvailablePlatform, and the activity and the settings tab name it with their own message (activity.gitsync.modulesFloorUnverified, ui.gitSync.modulesFloorUnverified).

What a stale floor holds, and what it does not:

What it still cannot see: a source that needs a renderer only an image NEWER than the newest one the instance knows of ships. The instance on the newest platform takes it, and renders it without that renderer until its next roll; only a floor that names the first image carrying the renderer closes that, which is the stamp's job in the node repository.

Recorded hashes live on the sync config as ModuleVersions (module → moduleVersion). They advance only when the import's nodes all landed (MayAdvanceBaseline), the #2229 item C rule the commit baseline follows. If a module that did not fully land had its hash recorded, the next attempt would read it as unchanged and the miss would become permanent. A declined module keeps the hash it had. The first import after this change has no hashes recorded, so every module syncs.

A dependency floor the loaded build does not meet (#6067)

Measured 2026-10-04 on the control instance. Hosting 1.56 declared requires: ["AI@^1.21.0"]. Its sources were GitSync-imported and Roslyn-compiled at 20:00:50Z while AI 1.20.4 was the loaded build, and every thread start — reviews, watchdog fixers, the bug pool — then threw MissingMethodException (ThreadPreparation.set_Group). The module-set proposal already refused a set with an unmet floor (Module Set Convergence); the import that put the sources in front of the compiler never asked.

So rule 1b runs in the same decision, after rule 1: a Synced module whose requirement the loaded dependency does not satisfy becomes Declined, exactly like a platform floor. Its sources are neither written nor pruned, so its NodeTypes keep serving their last good build; the baseline stays, so its files remain in the next diff; the decline is on the sync config (LastSyncNote, ModuleOutcomes[].UnmetRequirement) and in the /health module census, which escalates a decline that persists. Nothing has to be armed to release it: the next import judges again, and the import after the restart that activates a satisfying dependency syncs the module.

🚨 Loaded, never landed. LoadedPackageModuleReader (MeshWeaver.PluginCatalog) maps each package to the generation its module actually LOADED from — the activation head's version when the head loaded, the retained previous generation's when that one did, and nothing otherwise. A landing is restart-as-activation, so judging against the head would let an import compile sources against a 1.21 that is landed but not running — the very shape this rule exists to stop. A mesh with no module host registers no reader and judges nothing, which is the behaviour before the rule.

The range rule is PackageRequirement (MeshWeaver.Plugin.Packaging), shared with the proposal's ModuleDependencyFloor: one reading, so the two checks can never disagree about the same range.

What each lane does now

Lane Before Now
Green-build webhook (DecideBuild) landed on the sealed commit, or held imports the built commit; SealedCommit reports whether this identity's bytes were baked from it. Superseded by policy sources-sync-on-push: the push webhook (and a periodic branch reconcile) imports at the pushed commit, and a green build only records the build — see Sources Sync on Push
A person's Update / Re-import (DecideRequestedImport) redirected onto the seal, or held imports exactly what was asked
First import (DecideFirstImport: discovery and boot install) landed on the seal, or held resolves the configured branch
Seal arrival (SealedSyncReconcile) imported the sealed commit when the source sat elsewhere or had no commit imports nothing by itself. A source on another commit is usually AHEAD of the seal, and a source with no commit yet is brought by its first import or its next green build, which an import at the seal would race. What remains: re-importing a source AT the seal whose types were declined, and releasing a bundle hold at the commit whose sources were held
Adopted type whose new sources no bundle carries (BundleKeyedHold) held at its old sources compiles from the synced sources; the hold is kept only on a Modules:RequirePrebuilt mesh, where a local compile is refused and a move would park the type

The decisions keep their public signatures (other repositories pin them). Their answers are now always a proceed.

The boot default install: one partition, one bookkeeping

The boot install asks the same first-import question, so it now lists the configured ref. That ref is not proven, so where a sync source keeps the target partition current the install defers to that writer, as UnprovenRefHold (MeshWeaver#4588) already did on the control instance. The sync source follows green builds per manifest hash and is the partition's one writer.

Before this change the boot install wrote the seal's tree into a partition its own repository's sync source keeps current. Now that the sync source moves past the seal, that would write an older tree into the partition — the two-writer mix One Partition, One Bookkeeping describes, in reverse. A partition nothing syncs still installs, at the configured ref.

"Cannot read the index" — what it still refuses, and what it no longer freezes

RefusedForUnreadableIndex still says, at Warning, that the publication index could not be read. That remains an absence of measurement and never reads as "nothing sealed" (#3461). What depends on the index is adoption: an unreadable reading adopts no bytes it cannot verify, so each changed type compiles from its synced source, or parks under its own name on a RequirePrebuilt mesh. The reading no longer holds every source of every repository. Freezing all sources on one failed file read was a wider refusal than the thing it protected. The unreadable bundle shelf was already treated this way (see the per-NodeType page).

Where it is reported

What this does NOT do

How to check it is still true