Deploy, Test the Deploy, Switch Latest

Status: DECIDED DESIGN — nothing on this page is wired yet, except the readiness contract (§2a). It is the target shape for policy deploy-test-switch-latest and policy package-mesh-version-cap. Both are DECIDED and both stay proposed in Policy Not Prose until their wiring (§8) lands, because the register marks a row in force only once its mechanism exists. The owner's answers are recorded in §10; four questions there are still open. Today's mechanism is Build Hosts, with its design of record in MeshWeaver.Plugins at Hosting/StandingBuildHosts, under the in-force policy build-hosts-follow-latest. Every today claim below cites a file and line on main.

The shape in one line: image set → deploy build-<version> → test ON that deployment → on green, switch latest to it. A set is available to modules only once latest points at it, and latest only ever points at a deployment that passed the tests.

 main-cd              control instance (operator)            build-<v> deployment
 ───────              ───────────────────────────            ────────────────────
 gate ─▶ images ─▶ promote ─▶ ladder ─▶ arm
                                    │
                                    ├─▶ build-host-deploy ──▶ InstanceAction BuildHostDeploy(v)
                                    │                           └─▶ record: buildHostSets["v/<v>"]
                                    │                                 └─▶ build-<v> complete, pinned probe CLAIMED
                                    ├─▶ build-host-test ─────▶ follow lane ◀── core suites + dependents'
                                    │                                          affected gates run here
                                    └─▶ build-host-switch ───▶ green: alias latest := <v>
                                                               red:   latest unchanged, build-<v> torn down

1. Today, measured

fact where
Build hosts are merged and switched off in core's CD .github/build-hosts.json:3 ("enabled": false)
The chart renders nothing by default MeshWeaver.Deploy deploy/helm/values.yaml:1113-1114 (buildHosts.enabled: false); the build-host chart lives there, core's is frozen (BuildHosts.md §3)
The control record declares no hosts Memex mesh/Deployments/control.json holds no buildHosts block
The host model is per CHANNEL (latest, stable), never per version src/MeshWeaver.Deployment.Contract/BuildHosts.cs:25-41; MeshWeaver.Plugins Hosting/StandingBuildHosts.md:52-69
A channel's set is one mesh-owned token per channel, and it never moves backwards BuildHosts.cs:90-101; MeshWeaver.Plugins Hosting/Deployment/Source/BuildHostUpdates.cs:105-136
CD's order is gate → images → promote → ladder → control-first → arm .github/workflows/main-cd.yml:402,1350,2675,4658,2329,2908 (arm needs platform-ladder-compat, line 2910)
build-host-latest moves the channel to the armed set. Nothing exercises that set ON a host before the move main-cd.yml:4038-4177 (needs arm, build-host-image, line 4045)
Core's tests run on GitHub-hosted ubuntu-latest, against binaries the run builds itself, in the suite's shards (as many as CI_SHARD_TOTAL declares) .github/workflows/dotnet-test.yml:5,253,658-733,928-965
A host composes an already-built image and runs a repository's checks against it. Core's own CI is a different workload and is not wired BuildHosts.md §5, Status
Plugins resolves its platform at run time: the newest SEALED main-cd set, then the images by tag in ACR. The resolver is core's script, which Plugins fetches at core main .github/scripts/resolve-platform.py:24-45; MeshWeaver.Plugins .github/workflows/ci.yml:488-723
Plugins' gates run inside the mw-plugin-test container on the aks-silos / aks-silos-dind runner pools MeshWeaver.Plugins ci.yml:46-49,723
A package declares only a FLOOR, minMeshVersion src/MeshWeaver.PluginCatalog/Package.cs:236-248; PackagePlatformFloorGate.cs:13-32
The only upper bound is on BUILT BYTES across a declared epoch break, never authored per package src/MeshWeaver.Compiler/platform-compatibility.json:3-4; .github/scripts/previous-epoch-ceiling.py:1-28
Nothing flags a package that is stuck behind a cap there is no cap to flag

2. The pipeline

Five steps, in this order. Each step is a job with a positive success signal. None of them is skipped on missing input (§6).

  1. Image set. Unchanged: gate, the images, promote, the compatibility ladder and arm produce an immutable set (version, tester, portal and runner digests). The set is already the unit the hosts run (BuildHostChannel.ToSetToken, BuildHosts.cs:90). The set gains a FIFTH digest, the mw-core-tests image: core's test projects built from the set's commit with the set's SDK, in the same CD run (§10, D3).
  2. Deploy build-<version>. Job build-host-deploy replaces the move half of today's build-host-latest. It sends the signed event build-host-deploy-requested into the control plane's inbox, the same path and the same HMAC as today (main-cd.yml:4158-4176). The receiver turns the event into a governed Hosting/InstanceAction, BuildHostDeploy, on control. The in-cluster operator executes it. Never kubectl, never a hand-edited Deployment, never helm by hand. The action writes the version's entry in the mesh-owned set (§3). The record change applies itself (policy record-change-applies-itself). 🚨 Readiness is complete plus a CLAIMED probe — there is no ready field (the contract below, §2a). The deploy step waits until GET /api/builds/hosts lists the version with complete: true, then submits a probe pinned to that version (POST /probes with version) and waits for a host to CLAIM it. A claim proves a host of that version is up and serving; no claim inside the deploy window is a deploy FAILURE (§7). The probe's verdict is a test like any other. 🚨 A deploy carries its own warm-up demand. Scaling demand comes only from attempts (§3) and an idle version scales to zero (§9), so before any test attempt exists nothing would keep a replica up while the deploy step waits for the claim. The v/<version> entry the action writes is therefore marked warming, and the chart renders a warming version's ScaledObject with minReplicaCount: 1. Step 3 admits the test attempts BEFORE the action clears warming, so the floor drops back to zero only once the attempts' own demand holds the replica.

2a. The readiness contract

Implemented in MeshWeaver.Deploy#8 and core #7200; the design of record is MeshWeaver.Plugins Hosting/StandingBuildHosts.md §7b.

GET /api/builds/hosts
{ "aliases":  { "latest": "<v>", "candidate": "<v>", "previous": "<v>" },
  "versions": [ { "version": "<v>", "deploymentName": "build-<v>", "maxReplicas": N,
                  "aliases": ["latest", …], "pinned": N, "complete": true|false } ] }
  1. Test the deployment. Job build-host-test queues runs whose claims name the CANDIDATE version, never a channel. It follows each stage through the existing follow lane (build-follow.yml: only succeeded is green, a timeout is red, an unreadable endpoint is red). The runs execute:
    • the core suites, as stages of a new core declaration .github/build-stages.json, run from the set's mw-core-tests image: the suite's shards (as many as dotnet-test.yml declares in CI_SHARD_TOTAL), the doc gate and the ladder's link probe. 🚨 One pinned run PER SHARD, never one run holding every shard. A host runs one run's stages one after another (.github/scripts/build-run.py:1682-1699: a for st in stages loop, each stage awaited before the next), so shards declared as stages of ONE run would execute serially on one replica and MaxReplicas would parallelise nothing. Each shard is therefore its own run, pinned to the candidate version, and the runs spread across the version's replicas (§9);
    • the dependents' affected gates: MeshWeaver.Plugins' and each satellite's compile-check and gate stages (H7, BuildHosts.md §5), for the modules the platform delta affects, against the same candidate. Their verdict GATES the switch (§10, D1). Once the policy is in force, that is a register exception to dependents-verify-on-their-side, scoped to the module-facing latest switch only; until then that policy holds unexcepted.
  2. Switch latest. The verdict is COMBINED over the candidate's runs: every shard run, the doc-gate run and every affected dependents' gate run must be Succeeded, and one run that is red, missing or unfinished holds the switch. The P4 deploy workflow's build-host-test job owns that verdict (§8). When it is green, job build-host-switch sends build-host-switch-requested, naming EVERY run the verdict covered (or one parent node that lists them). Its InstanceAction BuildHostSwitch moves the alias latest to the candidate version in ONE record write, never backwards, and only when each named run is recorded green for every planned stage AND recorded the candidate version. The receiver re-reads every one of those run nodes; the event's claim alone is not trusted, and an event that names fewer runs than the plan is red.
  3. Available to modules. Whatever builds "against latest" (§4) resolves the alias at the moment it runs. So the switch is the publication of the set to modules. Nothing before it offers the candidate to any module build, and a red set is never offered.

🚨 latest is a pointer to a tested deployment, never a deployment that is re-rendered. A switch moves no image, stages nothing and restarts nothing: the candidate is already warm, because it was just tested. That is the property "first deploy, then test the deploy, then switch latest" buys. What runs after the switch is byte-for-byte what was tested.

3. What a build-server deployment is

Reuse, generalise, add nothing parallel:

piece today target
the set one token per CHANNEL in DeploymentContent.BuildHostSets["latest"] one token per VERSION in BuildHostSets["v/<version>"]. The token keeps its shape (BuildHosts.cs:90) and gains the mw-core-tests digest as its fifth part (D3)
the alias the channel name IS the host BuildHostSets["alias/latest"] = "<version>", one scalar leaf, so a stale switch conflicts as a whole, the same reason the set is one token (BuildHosts.md §4)
the authored block BuildHostsBlock { Enabled, Channels } (BuildHosts.cs:25-41) Enabled stays. A new init-only Retention (§5) and a per-version default MaxReplicas. Channels is kept for stable (Q4). Init-only only: the binary-break rule (BuildHosts.cs:2-4)
the Deployment build-host-<channel> build-<v>, where <v> is the version with dots as dashes (build-3-1-10412), so it is a valid DNS label, with labels meshweaver.io/build-version=<version> and meshweaver.io/build-alias=latest on the one the alias names
the ScaledObject one per channel, demand from /queue?channel=<c> one per version, demand from /queue?version=<v>. Demand is counted by each attempt's RECORDED version, never by the alias: the alias is consulted once, when a latest run is admitted (next row), so an attempt queued on V before a switch to V+1 still scales V
the claim {channel, platformVersion, …} {version, alias?, …}. A run queued for latest is resolved to a version WHEN IT IS QUEUED, and the run node records the version (StandingBuildHosts §1: "a run records a set")
the receiver build-host-update-available → BuildHostSets[channel] (BuildHostUpdates.cs:105-136) three events → three InstanceActions: BuildHostDeploy, BuildHostSwitch, BuildHostTeardown. Each one is authorized as the caller (the signed CD event) and executed as System (policy authorize-as-caller-execute-as-system)

GitSync keeps stripping buildHostSets on export (BuildHosts.md §4), so git still never names a build. The record stays under the estate's NO PINS rule (BuildHosts.md §4): the versions live only in the mesh-owned member.

4. Resolution for Plugins and satellites

Rule: a node repository builds and tests against the deployment latest names. A package whose authored upper cap (§5) forbids latest builds against the highest retained deployment the cap allows.

5. Caps — authored, validated, owed

The field. maxMeshVersion, next to minMeshVersion in the package's index.json content (Package.cs:236-248). It is optional. Absent means "builds against latest", which is the normal case. A cap carries two companion fields, both required when the cap is present: capReason (one line) and capTracking (the migration item's node path).

Validation (core's node-repo-validate.yml, so every satellite inherits it):

Runtime. This design gives the cap BUILD-and-TEST meaning only: it selects the deployment a package's modules compile and test against. What an instance that runs above the cap does with that package is Q2. Policy package-min-mesh-version says a floor never holds a platform roll, and a cap must not either.

The cap is debt, not a resting place. A capped package owes a migration to latest ("or migrate to latest"). A daily job on the control instance reads every package that declares a cap in the registry catalog. For each one it files, or refreshes, ONE triage item: a Feedback/Feedback node whose message names the package, the cap, latest, the distance in platform builds, the age of the cap and capTracking. The item is keyed by the package's stable identity alone, never by its cap: a second day updates the same item, and a cap that is RAISED but still forbids latest updates the cap named in that same item in place, so neither ever files a duplicate. Removing the cap closes it. The job runs as System after the platform-action check (policy authorize-as-caller-execute-as-system), and triage decides the rest, as for every finding (Incidental Findings).

6. Retention

Keep exactly:

  1. the deployment latest names;
  2. the candidate under test, if any (at most one; a newer candidate supersedes an older one, which is torn down);
  3. the PREVIOUS latest, the deployment the alias named before its last switch (§10, D4);
  4. every version a queued or running attempt recorded (§3), until that attempt ends;
  5. for every package with a cap, the highest version inside its floor-to-cap range (§4), including one that a cap admission (§5) is deploying. Several packages with the same range share one deployment.

Rollback is an instant switch. Because rule 3 keeps the previous latest deployed and its digests locked, going back is BuildHostSwitch moving the alias to that version: one record write, no redeploy, no staging, nothing restarted. It is the one switch allowed to move the alias BACKWARDS, it names the version it leaves in its reason, and it reads the record back like every switch (§7). The version it left is then the previous latest and is retained under rule 3 until the next forward switch.

Tear down everything else with BuildHostTeardown: the record entry goes, so the Deployment and the ScaledObject leave on the next reconcile. The build-cache claim is never touched by a teardown. Its content-addressed entries for a torn-down set age out under the cache's own sweep. A teardown is refused for any version that rule 1, 2, 3, 4 or 5 still holds. The rule is computed from the record, the queue and the catalog, all live, never from a list the CD run carries. Admission and teardown cannot race: both are writes to the control record through its owning hub, which serialises them. A teardown re-checks rule 4 inside its own write, and an attempt's admission re-checks, inside ITS write, that the version is still in the record; an attempt that finds its version gone is re-resolved, or red, never queued for a deployment that is leaving.

Digests. Every digest of a retained deployment (tester, portal, runner, core tests) is a PINNED digest in the sense of Pinned Image Retention: lock-pinned-digests.py reads the record's buildHostSets and locks them, so the ACR purge, which counts newer builds, never deletes an image a capped package still compiles against. A teardown removes only THIS deployment's entry, and with it this deployment's claim on the pins; it unlocks nothing. The lane locks and never unlocks (Pinned Image Retention: "IT LOCKS. IT DOES NOT UNLOCK."), and a digest another retained deployment or a running portal still names must stay locked, so releasing a lock is left to that page's guarded, opt-in release arm, which requires a complete inventory. This closes the retention gap BuildHosts.md §6 lists for mw-build-host and the hosts' digests.

7. Failure semantics

8. Migration, as phased pull requests

phase repo change owner-gated?
P0 core this design, the owner's answers (§10) and the two register rows no
P1 core contract: init-only additions to BuildHostsBlock (Retention, the per-version default), the v/ and alias/ key forms, Package.MaxMeshVersion + CapReason + CapTracking, and the cap validator in node-repo-validate.yml no
P2 MeshWeaver.Deploy chart (it owns the build-host chart; core's is frozen, BuildHosts.md §3): render one Deployment and one ScaledObject per v/ entry (with minReplicaCount: 1 while the entry is warming, §2), plus the alias label, with test-build-hosts.sh fixtures for 0, 1 and 3 versions no
P3 MeshWeaver.Plugins the receiver and the operator: the three events → BuildHostDeploy / BuildHostSwitch / BuildHostTeardown, the version-keyed claim and queue, the stage-log endpoint answering by recorded version, GET /api/builds/hosts with each version's set token (§4), the retention computation. Follow-up (plugins-e6): the switch accepts a SET of runs. e6's switch receiver checks a single run today; it must take every run the event names (or a parent that lists them), re-read each, and refuse when any is not Succeeded or did not record the candidate version (§2 step 4) no
P4 core main-cd.yml: replace build-host-latest with build-host-deploy → build-host-test → build-host-switch + build-host-report, a core .github/build-stages.json, the follow lane's version input (§4), and the mw-core-tests image as the set's fifth digest (D3). build-host-test submits ONE pinned run per shard and owns the COMBINED verdict over the shard runs and the dependents' gate runs; the switch event names all of them or their parent (§2 steps 3–4) no
P5 core resolve-platform.py --from-build-hosts, including per-package resolution under a cap, IMPLEMENTED and self-tested but INERT: no consumer passes the flag, so Plugins and the satellites stay on today's newest-sealed path no
P6 MeshWeaver.Plugins + core the daily cap-debt job (control), the capped-run publish path (D2), the retention of the previous latest and the backwards rollback switch (D4), and the lock-pinned-digests.py reading of buildHostSets (core) no
P7 Memex the buildHosts block on mesh/Deployments/control.json, through the record's approval path yes
P8 core .github/build-hosts.json enabled: true, AFTER P7 (the order BuildHosts.md §4 fixes) yes
P9 control the roll that renders the first build-<v>, then the first green switch. Verify by reading GET /api/builds/hosts and the run nodes' recorded version, never the CD job's green yes
P10 MeshWeaver.Plugins + satellites adopt --from-build-hosts in Plugins ci.yml and the satellites, ONLY after P9 is verified, because §4 refuses a missing alias with no fallback and adopting earlier would red every consumer's CI no

deploy-test-switch-latest and package-mesh-version-cap are decided and stay proposed on their register rows, with P1 to P10 listed as owed; each moves to in force once its wiring has landed. When P9 is verified and P10 has landed, build-hosts-follow-latest is retired in the register.

9. Cost

10. Decided, and still open

Decided

Open

Build Hosts · The Build Server · Release Channels · Module Adoption Policy · Pinned Image Retention · One Promotion Gate · Module Build Architecture · Policy Not Prose.