CD reconciles the Plugins seal

A core CD run publishes 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 decides

The decision lives 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: publish-bake always, and the three plugins-* legs when plugins_seal_due was true. A leg that skipped is red there, on the path that now performs repairs.

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.

Reconnecting…
The connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.