Platform and Module Deploy

"after platform we always deploy control" · "we separate platform deploy 100% from module deploy" · "control will always be on latest platform and will always update to latest modules."

Three policies cover this. The register is Policy Not Prose:

policy the rule
platform-deploy-control-first Every platform build that passes the control image's own acceptance becomes the control instance's next image, with no approval, in the run that built it. The fleet is offered the build only after control is running it and healthy.
platform-module-deploy-separate The platform deploy ships the host image only: the core platform plus the boot-time infrastructure modules. It does not pack, bake or seal any module, and no module verdict gates it. Modules publish on their own lanes, and every instance updates them on its own (packages-auto-update).
control-always-latest The control instance runs the newest platform build it was given and the newest compatible version of every module it has installed. If it falls behind on either for longer than a declared bound, an alarm names the lag.

The two deliveries

PLATFORM (core main-cd)                               MODULES (each module's own lane)
core merge ─► gate ─► images ─► promote               module merge ─► pack ─► test ─► publish
                 │                                    (MeshWeaver.Plugins ci.yml, satellites)
                 └─► control image ─► acceptance ─► control-promote        │
                                                    │                       ▼
                                                    ▼             registry: newest version + floor
                              control-first: memex-control:<version>        │
                                                    │                       │  every instance, on its
                          control self-update rolls to it (no approval)     │  own schedule:
                                                    │                       │  newer version published
                                   control runs it, /health 200             │  AND floor ≤ running
                                                    │                       │  ⇒ lands + activates
   arm (any later run): ladder green + control runs it                      │  (live, or one restart)
                                                    │                       │
                         memex-portal-ai:<version>, release event           │
                                                    ▼                       ▼
                         the fleet rolls the platform              the fleet updates modules

Neither side waits for the other. A platform roll keeps the module bytes an instance already runs. This is the ladder in policy platform-backwards-compatibility. A module update never waits for a platform build, seal, pair tag or framework identity.

What guards a platform roll now

The fleet's arming lives in arm-promoted-set.py, function judge. It reads three things, and none of them comes from a module lane:

  1. The platform's own tests. A set is promoted only from a commit whose required check is green. This is gate in main-cd.yml.
  2. The compatibility ladder (platform-ladder-compat). It takes the module set the fleet runs and links it, unchanged, against the promoted portal image, checking types, assembly versions and every member by signature. The bytes are the published ones: published-modules reads them from the sealed plugins publication for this run's identity, and never builds them. A platform change that breaks a module's declared compatibility turns this step red. The fix is to restore compatibility or declare an epoch bump, never to re-bake.
  3. Control first. The control instance named in .github/control-instance.json must be running a build that contains the set's core commit. Containment is checked by ancestry, using the GitHub compare API on its public /api/version. Control's /health must also answer 200.

How each state is read:

On the module side, two things still guard what an instance runs:

Control first, in practice

The alarm (control-always-latest)

Platform half. The core workflow .github/workflows/control-always-latest.yml runs every 30 minutes and calls arm-promoted-set.py control-lag. It finds the newest promoted set whose Deploy control first job succeeded, and when that job ran, then checks whether control's running commit contains that set. The results:

Module half. This half runs in MeshWeaver.Plugins as FleetTarget.ControlBreaches (Hosting). It reads the control instance's own module inventory report. A module whose served version is newer than the installed one is a breach once it has been behind for longer than the bound. This applies when the served version's floor is at or below control's running platform, and it does not matter whether a hold reason exists. A module held because its floor is above control's platform is the floor working as designed, not lag. Each breach is filed through the existing FleetTargetIntake as a fleet-behind-target triage item that names the module, both versions and the hours behind.

What was measured before the change

The measurements were taken on the live system. They are evidence and are not to be updated.

Coupling points that were in main-cd.yml:

job / step what it coupled
gate → "Is plugins sealed for the identity this set resolves?" (plugins_seal_due) a platform reconcile re-attempted a MeshWeaver.Plugins seal
plugins-bake-image resolved digests for the Plugins bake
plugins-modules ("Plugins: pack the module bundles the bake composes") packed AI, Markdown.Collaboration, Maps and Payments.Stripe from Plugins source in core CD
plugins-bake ("bake + seal the publication for this identity") re-sealed the plugins publication on every platform build, although the identity (c003e001) is per epoch, not per build
report-plugins-seal + the cd-plugins-seal ledger judged the above
arm → "Mint a MeshWeaver.Plugins token" + select the fleet was armed only on a green Plugins dependent-suites verdict for the exact pair <core7>-p<plugins7>
control-arm memex-control:<version> followed the fleet's arming, so control received a build only after Plugins' verdict, which made control last
satellite-compat, ladder baseline read the module bundles that core CD packed
Memex control.json rollGate: {after: [memex-cloud, memex], soakMinutes: 120, approval: required} control rolled last, with an approval

How long a platform roll waited on module work. The figures come from CD run 9965 (260b3c4), which promoted at 07:12Z:

Image composition. Measured on memex-control staging-bd3ea73 (linux-x64, 10 layers, 1.20 GB compressed). The app layer is 472.6 MB uncompressed, of which modules/ is 251.8 MB:

module MB kind
MeshWeaver.Hosting.Cosmos 39.1 boot-time (storage backend)
MeshWeaver.Fleet.Control 37.8 boot-time (control only)
MeshWeaver.Hosting.Snowflake 25.7 boot-time (storage backend)
MeshWeaver.Hosting.Instance 14.5 boot-time (gates the registry it would arrive through)
MeshWeaver.SelfUpdate.Aks 11.8 boot-time (control only: rolls itself)
MeshWeaver.Blazor.Chat 23.5 not boot-time
MeshWeaver.Mcp 22.5 not boot-time
MeshWeaver.AI 22.2 not boot-time
MeshWeaver.Markdown.Export 14.0 not boot-time
MeshWeaver.Markdown.Collaboration 13.5 not boot-time
MeshWeaver.Blazor.Graph 13.2 not boot-time
MeshWeaver.Blazor.EntityViews 13.1 not boot-time

That makes 122.0 MB of the image module seeds that are not boot-time. The registry already replaces each seed in place when it serves a newer version.

Migration with no unguarded moment

Every step below keeps the platform roll guarded, and the steps run in this order:

  1. Core: control first, and the platform verdict. This is in this change.

    • control-first tags control on every accepted build.
    • arm judges ladder plus control instead of the Plugins verdict.
    • published-modules reads the sealed publication in place of plugins-modules.
    • plugins-bake, report-plugins-seal and the gate's seal probe are removed.
    • control-always-latest.yml starts alarming.

    No moment is unguarded. The ladder already ran on every build, so it has been part of the verdict from the first run. The control half can only make the arm stricter: until control rolls, the fleet waits and the alarm says so. Because the published-modules artifacts keep the module-bundle-<Module> names, the satellite baselines and every core pull request's ladder (fetch-deployed-plugin-set.sh) read them unchanged.

  2. Memex: the control record goes first. Drop control's rollGate and declare moduleUpdatePolicy: Auto. Until this lands, control-first tags are rolled only with an approval, so the fleet waits and control-always-latest stays red, naming the lag. That is loud, not unguarded.

  3. MeshWeaver.Plugins: the module half of the alarm (FleetTarget.ControlBreaches, the carried BehindSince clock, and FleetTargetIntake.AllBreaches).

  4. Owed — removing the non-boot seeds from the image, guarded by a boot-time declaration and a shrink-only ratchet test ("the image contains no non-boot module"), neither of which is built yet. This follows the live module update (packages-auto-update, core #6124 and #6121/#6123, Plugins #2893). An instance whose Modules:Required names a module must be able to land it from the registry before readiness. Until #6124's reload lands that, a seed is the bootstrap copy. The ratchet shrinks one entry per module as each is proven to land on a fresh instance.

  5. Owed — the pair tag <core7>-p<plugins7>. The portal HOST still lives in MeshWeaver.Plugins, so the tag now names the host commit: platform provenance, which no delivery decision joins on any more. The gate hosts-stale probe still rebuilds the image when any Plugins commit moves. Narrowing that to host-relevant changes, then retiring the tag (Memex image-contains.py and Plugins portal-image-rebuild.yml read it), is the next step.

  6. Owed — Plugins' promotion-candidate.yml / core-candidate.yml. They still run the dependent suites against each promoted pair, and arm-promoted-set.py pending still answers them, but no verdict they write decides anything. They report only, and they can be retired by Plugins.

Tests and self-tests

What is not established