CD reconciles the Plugins seal

🚨 Retired — this page is history, not the current design. Core commit 08d37d38ac (#6143, policy platform-module-deploy-separate, see Platform and Module Deploy) removed the plugins-modules, plugins-bake and report-plugins-seal jobs from main-cd.yml, together with gate's seal probe (plugins_seal_due) and the cd-plugins-seal ledger.

Today:

The rest of the page records the mechanism as it ran from #4539 to #6143, and why the platform seal is the trio alone — that part still holds.

A core CD run published two different things for one commit, and until MeshWeaver#4539 only one of them could heal.

Every satellite resolves the newest sealed set and then fetches plugins under that set's identity. So a set can seal while its plugins publication does not exist, and every satellite holds — correctly — on an upstream nobody is going to publish.

What it looked like

On 2026-09-16 core CD #8765 promoted, verified and baked 3.0.0-ci.8765. Then Plugins: pack the module bundles the bake composes / Warm the shared build environment died pulling the tester image:

Error response from daemon: Get "https://meshweaver.azurecr.io/v2/":
dial tcp 51.12.25.82:443: connect: connection refused

Plugins: bake + seal the publication for this identity was therefore skipped, and framework identity s5ec352bb102e5a2275e3831a08ac0c8d got no plugins publication. At 20:14:20Z four satellites resolved 8765 — the newest sealed set — and all four failed in the same second with

upstream 'plugins' answers 404 … no sealed publication for source 'plugins'
under framework identity 's5ec352bb…'

Nothing re-attempted it. MeshWeaver#2452 had given the platform bake a reconcile path (bake_only), but the three plugins-* jobs were still gated on publish == 'true', so every hourly tick re-asserted the platform bake and left the publication missing. What healed it was a coincidence: MeshWeaver.Plugins merged, which moved plugins HEAD, which made the image set read INCOMPLETE, which produced a full re-publish.

It is not a one-off

Sweeping the 200 core CD runs of 2026-09-14 → 09-17, nine sets sealed with their Plugins seal not success:

Run Why the seal did not happen Class
8765 ACR refused the tester-image pull infrastructure
8711 the seal's own push to the fleet registry failed infrastructure
8601 the promoted tester digest could not be resolved infrastructure
8599, 8647, 8663, 8666, 8670, 8674 Plugins' module tests / bundle build failed Plugins content

That split is what decides the design: three of nine are transient, six are a genuine Plugins red.

Why the set still seals — the shape that was rejected

The obvious alternative is to make the seal all-or-nothing: no platform set until its plugins publication exists. That removes the broken state rather than repairing it, and it was rejected on the measurement above.

So the platform seal is unchanged, and the reconciler learned to finish a set that is missing its publication.

How the reconciler decided (retired by #6143)

The decision lived in gate, which owns every other publish decision in main-cd.yml, and it runs only on the bake_only path — the reconcile tick that finds a complete image set.

  1. bake_version recovers the promoted set's release version off memex-portal-ai:<short-sha>'s tag set. (This read used to live in publish-bake's release step; it moved here because a second consumer appeared, and two independent resolutions of one fact is the shape gate.image_tag's own comment records getting wrong seven times.)
  2. seal runs check-release-availability.sh <version> plugins — the same script, against the same store, that publish-bake already uses for meshweaver-content and that release.yml already uses for both sources. CD asking about only half of what it ships was the whole gap.
  3. Its answer becomes gate.plugins_seal_due, and the three plugins-* jobs gain a second way in, exactly as publish-bake did in #2452.

The probe names the identity

A publication seal is per framework identity. An "is it sealed?" check that does not name one is answering confidently about something else.

The identity is the API-surface hash s<hash> of the portal's reference assemblies. It is a property of the binaries, not of the source (#1725), so it cannot be computed from a tag or a commit — resolving it means running mw-plugin-test framework-identity against the portal's /app. The one thing outside the image that knows it is the _releases/<version> marker that publish-bake-bundles.sh writes on every run, and check-release-availability.sh reads it. That is why the probe is keyed by release version and why the version had to be resolved first.

Three answers, and only one licenses a re-attempt

check-release-availability.sh deliberately keeps its refusals distinguishable, and the step keeps them distinguishable too:

Probe says plugins_seal_due Why
all N source(s) are published for identity s… false The control: nothing to do, and nothing is written to any ledger.
N of M source(s) are not available for framework identity s… true The only answer that is a statement about the publication.
CANNOT RESOLVE … has no marker at … (no _releases marker at all) false, said as NOT MEASURED The platform content bake has never published this release — which is what publish-bake is running to fix on this same tick. The next tick judges the seal. Self-clearing, with no timer and no retry.
CANNOT RESOLVE … is empty (a marker exists but records no identity) — the step goes RED The producer wrote a marker and recorded no identity. No later tick repairs that, so it is a defect, not a pending bake.
CANNOT DETERMINE … (the marker's existence or content, or a source, could not be read) — the step goes RED The gate could not ask its question. Cannot determine is not clear to proceed, and it is not nothing to do either. az's own reason (an expired login, throttling, a missing share) is carried in the message.

🚨 The benign row is matched by its OWN wording, never by the CANNOT RESOLVE prefix. The empty-marker refusal opens with the same prefix, so a step keyed on the prefix answers a producer defect with a green. And the script asks whether the marker exists before reading it: until the #4539 review fix, a failed read — auth, throttling, network — was reported as "has no marker" and landed in the benign row, so a storage outage read as a pending bake. .github/scripts/test-cd-steps.py pins all three rows, and asserts each phrase still exists in the real script so a stub cannot describe an outcome the script no longer produces.

An absence that printed no identity is also a red: the finding names no identity and cannot be acted on.

Why a re-attempt is repair and not a retry loop

.github/scripts/test-cd-steps.py pins all of the above, and each case was shown to fail against the defect it names: read-then-append, a rank read placed before the claim write, a seal step without -e, and a leg whose if: lost the preflight check.

And the reconcile is now judged

delivery-verdict's every-leg check was publish == 'true'-only, and its reconcile branch fires only on an incomplete set — while bake_only is by definition the complete-set branch. So a bake-only tick was judged by nothing. It now carries its own arm for publish-bake, always. The three plugins-* legs — on a publishing run and on a reconcile whose plugins_seal_due was true — are judged by report-plugins-seal, a job no platform job needs: a due leg that skipped or failed is red THERE, and recorded on the cd-plugins-seal issue, and never on the platform verdict (policy platform-backwards-compatibility; see Continuous Delivery Contract → The ladder).

The resolver waits for a seal that is still running — and has no opt-out

Since #6143 this wait is inert. resolve-platform.py still names the core job Plugins: bake + seal the publication for this identity (PLUGINS_SEAL_JOB), but no core CD run carries that job any more, so _group reads it as absent and nothing is ever held back for it. Likewise the resolver's plugins-run / plugins-sealed-at outputs can only name a core run from before #6143, not the publication MeshWeaver.Plugins seals today. The evaluation below is kept as written for that era; the plugins publication a consumer fetches is now Plugins' own.

The other side of the same seam is in resolve-platform.py. A set whose platform trio is sealed but whose own Plugins: bake + seal … job is still running is passed over (or waited for, on a release trigger with --wait-for-seal). A seal that failed, was skipped or was cancelled is terminal and holds nothing back; only the in-flight one does. The exception exists because taking the set in that window made every consumer red on upstream 'plugins' has no SEALED publication … for identity <id> (measured on MeshWeaver.Manufacturing, 2026-09-10).

The wait has a price. Measured on the core CD runs of 2026-09-27, the platform bake finished about 5 minutes after promote and the Plugins seal a further 13 to 23 minutes later (run 36299288833: promote 06:59–07:02, platform bake 07:02–07:06, Plugins seal 07:17–07:34). So a new core set becomes resolvable about 20 minutes later than its platform seal.

MeshWeaver#6041 asked whether a consumer that does not read the plugins publication could opt out of that wait. The answer is no second resolver mode, and the reason is who the consumers are. Read from each repository's default branch through the REST contents API on 2026-10-09, and re-read on 2026-10-11:

Consumer Calls the resolver Reads plugins under the resolved identity Could skip the wait
MeshWeaver.Education, .Reinsurance, .SocialMedia, .Manufacturing, .Crm, .FundReporting yes yes — upstream-seed / upstream-sources name plugins no: skipping it is the 2026-09-10 red
core node-repo-gate.yml, node-repo-publish-bake.yml yes, when the caller pins no digest the caller's own upstream-seed — these lanes serve the rows above no
Memex no — not applicable
MeshWeaver.Plugins yes no — it declares no upstream; it is the plugins publication the only candidate

MeshWeaver.Plugins is the only consumer the opt-out could serve, and for it the opt-out is not clearly safe:

  1. The same resolution feeds its publish-bake. One run resolves one set, and that set's image digest is what Plugins' own publish-bake uses to write prebuilt-bundles/<identity>/plugins/ — the same prefix core's Plugins: bake + seal job is writing in that window. Resolving before core's seal lands makes Plugins the second writer on an identity whose first writer is mid-publish. That two-publisher interleave is handled (publish-bake-bundles.sh, the two-writer postcondition, proven by test-publish-bake-overlap.py), but as a rare case. An opt-out would make it the ordinary one. Scoping the opt-out to the gate and not the bake would give one run two platform sets, which breaks the "one resolved set per run" rule the lanes assert.
  2. The gain is small. Plugins no longer rebuilds per platform build. It runs on its own pushes, on a manual dispatch and once a day, so the 20 minutes only matter to a push that lands between a core promote and that set's seal — and such a run is not wrong, it tests the previous sealed set and the next run takes the new one.

So one resolver mode stays, and it is the one every reader of the publication needs. If the wait is ever to shrink, shrink it at its source — the duration of core's Plugins: bake + seal job — which helps every consumer and adds no mode. What would change this evaluation: a consumer appearing that calls the resolver, reads no upstream, and publishes nothing under the resolved identity.

What this does not cover

Reading the result

The gate's step summary carries the verdict in one line, always naming the identity and the release:

🔁 re-attempt 1/3 — `plugins` is NOT sealed for `s5ec352bb…` (release `3.0.0-ci.8765`),
   so this reconcile bakes and seals it.

A satellite holding on a missing publication is the gate working. The bug was always the missing publication, never the hold.