Image Pair Skew

A portal image is two commits from two repositories, and continuous delivery pairs them at different moments. The core half is the commit the run was created for; the Plugins half is refs/heads/main of MeshWeaver.Plugins at the moment the run's gate job finally ran. When the run queues for hours β€” it did β€” the two halves can be hours apart. Each half was green in its own repository, against its own pin. Nobody ever ran the pair.

On 2026-09-03 that pair was core e7f1d699 (08:05Z) with Plugins 12500c9 (13:31Z), tagged memex-portal-ai:3.0.0-rc9.ci.7658, and it answered 503 to every signed-in request on memex.systemorph.com for twenty minutes. This page records the property, the timeline, why every guard missed it, the runtime fix that shipped, and the delivery gap deliberately left open.

🚨 The one sentence

Promote and Verify every image shipped green means the images EXIST; it says nothing about whether the pair inside them can serve a signed-in request. Only booting the pair proves that, and nothing in CD does. That remains true as this is written.

The property

the core half the Plugins half
chosen by the commit gate resolved (push or reconcile) refs/heads/main of MeshWeaver.Plugins, resolved ONCE in gate and threaded (#2622)
chosen when the run is created the gate job actually runs
recorded as <core short sha> in the image tag <core>-p<plugins> p<plugins short sha> in the same tag
tested in core CI, against core's own code Plugins CI, against the platform pin the Plugins repo carries

Threading the Plugins sha once (#2622) made a run internally consistent β€” the portal image and the module bundles come from one Plugins commit. It did not make the run externally consistent: a core commit that queued for five hours is paired with a Plugins main five hours younger than anything that core commit was ever tested with. The Plugins pin moves the other way too β€” the Plugins repo pins a platform sha, and on the same afternoon its own tests went red in both directions as that pin crossed the same boundary (Plugins #1281 asserts both arms by detecting the platform at runtime).

The class is: a promoted set whose two halves encode different contracts. Sign-in is where it surfaced, because sign-in is the first read every request makes.

🚨 The sharper form: what is TESTED and what is SHIPPED read different refs

Measured in MeshWeaver.Plugins on 2026-09-03, and this is the durable defect rather than the queue delay:

lane how it picks its core half what it was on 2026-09-03
ci.yml β€” the test lane vars.MW_PLATFORM_REF \|\| env.MW_PLATFORM_REF, and the env default is a literal pinned sha e7f1d699… β€” frozen
portal-ai-image.yml, portal-next-image.yml, log-watcher-image.yml β€” the image lane vars.MW_PLATFORM_REF \|\| 'main', and the variable is unset core's moving tip

So the Plugins repo tests against a frozen core and ships against a moving one. The two agree only while the pin happens to equal the tip. Every hour the pin lags, the tested pair and the shipped pair are different pairs β€” and no run anywhere reports the difference, because each lane is internally consistent and green. Core's own CD adds the mirror-image gap: it pins its core half at run creation and resolves the Plugins half when gate runs.

A pin is the right tool for a test lane: it makes a suite reproducible. The defect is that nothing compares the pin to what the image lane actually used, and nothing executes the pair the image lane produced. A stricter pin does not close it; only executing the pair the image lane built does.

The timeline (UTC, 2026-09-03)

time event
08:05 core e7f1d699 merges (#3177, apps home groups).
08:37 CD run #7658 is created by the reconcile schedule, target e7f1d699.
11:05 core #3206 merges: the sign-in reads are anchored (OnboardingMiddleware.LoadUserRoles asks three anchored questions instead of one unanchored nodeType:AccessAssignment content.accessObject:"<user>" scope:subtree).
11:45 Plugins #1263 merges: PostgreSqlPartitionedMeshQuery REFUSES an unanchored query unless its shape is on the shrink-only grace list unanchored-queries.allow β€” and the sign-in shape is not on it, because core had just anchored it.
13:31 Plugins 12500c9 (#1252) is the Plugins main when run #7658's gate resolves it. The image e7f1d699-p12500c9 = ci.7658 is built, promoted, verified.
13:32 memex's self-updater patches the deployment to ci.7658. DbVersionGate refuses β€” db_version=54 < expected 55 β€” and the new pods crash-loop while the old ci.7632 pods keep serving. Invisible from the front door: the URL answers 200 the whole time (see The Self-Update Schema Wall).
13:32–17:00 Every refused boot dies as an unhandled OperationCanceledException β†’ SIGABRT β†’ a 666 MB createdump; 45 of them exceed the 30 Gi memex-dumps emptyDir and evict a pod (Plugins #1290 turns that into a clean exit 1).
17:00 Another session runs the migration by hand (memex-migration-v55-manual, the helm Job template with the new tag). Database migration completed. Version: 55 at 17:06.
17:02 The 7658 pods pass the gate; the rollout scales the 7632 ReplicaSet to zero. From this second every signed-in request faults in LoadUserRoles with UnanchoredQueryException and the middleware answers 503 "This is a temporary problem on our side" (issue #637's designed answer for an infrastructure fault). The Store dies on nodeType:PluginCatalog; GitHub sync, instance sync, notifications, outbound mail, model-credit and free-text search fault the same way. Loki, over the six hours ending 18:34Z: 156 refusals on the two pods that served (100 + 56), plus 32 more on the pod evicted earlier β€” and 18 identity 503s attributable to this cause (4 + 14).
17:05 Reported: "memex.systemorph.com is completely down". Anonymous curl of /, /Doc, /healthz all answer 200 β€” the shell renders; only the signed-in read fails.
17:20 kubectl set image to ci.7693 (core e36f04c, which contains #3206, with Plugins 2d32a175). The surge pod stays Pending: every silos node is CPU-full, the pool is at its maximum, and the old 7632 pod is holding a node in Terminating at 3.5 cores. It is NOT wedged β€” it is draining its Blazor circuits under the chart's preStop hook, which blocks for up to 28 minutes and is why the pod still looks busy (see Recorded, not fixed). It is force-deleted, which cuts those sessions short deliberately; it was already out of the Service, and the surge pod schedules within seconds.
17:22:56 Rollout complete. Zero refusals, zero 503s on the new pods.

The roll cleared more than sign-in. On the sister portal, measured across the same change, the cross-schema fan-out storm went from 1,917 slow 201-schema unions per pod per 30 minutes to 0–1 per pod per 10 minutes β€” the anchored reads of #3206 removing the unions themselves, not merely the refusals of them.

ci.7693 is up but unsealed: its Plugins: bake + seal job failed on the #3175 one-producer guard (MeshWeaver.Markdown.Collaboration composed as a module and still shipped in /app), as did every promoted build since #7683. NodeTypes binding module types decline at adoption on it. The first sealable set is the first CD run after Plugins #1268 lands.

Why every guard missed it

The fix that shipped, and the one that did not

Runtime β€” a refusal is a CI invariant, never a production answer (MeshWeaver.Plugins #1300). PostgreSqlPartitionedMeshQuery gains an UnanchoredQueryPolicy. The default is Refuse (fail-closed: a host that never heard of the property keeps the CI invariant). The production host opts into ServeAndReport in its own committed appsettings.json, baked into the image so no chart or ConfigMap can forget it, and a contract test discovers the Postgres hosts from source so the rule cannot silently cover nothing. Under ServeAndReport an unlisted unanchored shape is served by the fan-out and logged at Error naming the offender, which the red-log ticketing turns into an incident. The trade this reverses: a hard refusal converted a performance hazard (a cross-schema UNION contending on the lock manager) into a total availability outage on the request path. After the change the allow-file governs two different things: in CI the Grace-versus-Refuse verdict, unchanged; in production the log level (listed β†’ Warning, unlisted β†’ Error). It may still only shrink.

So a repeat of THIS fault is caught at the layer where it fired. An image whose two halves disagree about anchoring no longer takes sign-in down; it serves the query and files an incident.

🚨 What is still open: nothing executes the pair before it is promoted

A CD gate that booted the run's own image pair, signed in through DevLogin and asserted a cookie-carrying GET / and GET /Store was written and proven against both controls β€” it passes the pair that shipped fixed and fails ci.7658 on the signed-in GET / β€” and was deliberately not adopted: a new required job in front of promote was judged not worth the standing cost and the first-run risk to the release lane. That decision is recorded here rather than argued: it is a real trade, and the residual it leaves is real too.

The residual, stated plainly. promote and Verify every image shipped still attest only that the images EXIST. No step boots the pair, and none signs in. The pair's two halves are still chosen at different moments from different refs, so a different disagreement β€” one the runtime policy above does not cover, because it is not about unanchored queries β€” would ship exactly as this one did, and the first thing to notice would again be production. Anyone reopening this should know the proof already exists as a standalone script with a two-control verification; it is the adoption that was declined, not the mechanism.

Reading the signals next time

kubectl logs <pod> -n memex -c memex-portal --since=20m | grep -c 'UNAVAILABLE for'
kubectl logs <pod> -n memex -c memex-portal --since=20m | grep -oE "no partition could be determined from '[^']{0,100}" | sort | uniq -c
az acr manifest list-metadata -r meshweaver -n memex-portal-ai --orderby time_desc --top 30 -o json   # tags read <core>-p<plugins>
git merge-base --is-ancestor <fix sha> <core half>                                                     # is the fix in this image's half?

Recorded, not fixed

See also

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.