Deploying to AKS

This is one of two deploy routes for MeshWeaver. Use it for the shared portals on the AKS cluster <aks-cluster> (resource group <aks-resource-group>, region swedencentral) β€” the memex namespace, backed by the Postgres Flexible Server, with container images in ACR meshweaver.azurecr.io. For the Azure Container Apps route (Aspire test/prod modes via tools/deploy.sh), see DeploymentContainerApps.md. These are different routes to different targets, not old-vs-new β€” pick the one that matches where you're deploying.

🚨 This runbook is the bootstrap / break-glass form of a Roll. Since 2026-09-08 the rule is that operations go through the control instance's Hosting API: the instance is a Deployments/<name> record, its image pin is the roll, and a Roll (or Restart, Suspend, Audit, Reconcile) Hosting/InstanceAction is what the in-cluster operator executes β€” running exactly the commands below for you. Read them as what happens, not as what you type. Policy, and what the API does not answer yet: OperatingFromThePortal.

The cluster is private. kubectl is not reachable directly β€” where a break-glass command is unavoidable it runs through az aks command invoke -g <aks-resource-group> -n <aks-cluster> --command "…", which executes inside the cluster's API-server-side runner.

A code update is three steps: build the images, point the Deployments at the new tag, restart. It is not tools/deploy.sh and not aspire deploy β€” those are the Container Apps route.

Steady state is self-update, not this runbook. Once an environment runs, it rolls itself to new images per Admin/UpdatePolicy (default Stable β€” clean releases; Continuous + a pattern such as 3.0.0-ci* follows a line's continuous builds) β€” the portal patches its own Deployment from inside the pod. This manual runbook is the bootstrap / break-glass path (first install, or to force a specific tag). See ReleaseStrategy.md, which also covers the one-time RBAC + workload-identity (AcrPull) setup the in-pod updater needs.

1. Build + push the images

az acr login -n meshweaver

# Portal β€” needs the prebuilt custom base image. MULTI-ARCH: never pass `-r linux-x64`.
dotnet publish ../MeshWeaver.Plugins/src/Memex.Portal.Distributed/Memex.Portal.Distributed.csproj -c Release \
  --no-self-contained -t:PublishContainer -p:PublishProfile= \
  -p:ContainerRuntimeIdentifiers='"linux-x64;linux-arm64"' \
  -p:ContainerRegistry=meshweaver.azurecr.io \
  -p:ContainerRepository=memex-portal-ai -p:ContainerImageTag=<tag> \
  -p:ContainerBaseImage=meshweaver.azurecr.io/memex-portal-ai-base:latest

# Migration β€” this is what creates the schema, the partition_access table, AND the
# public.top_level_index materialized view. A schema/index change ships in THIS image:
dotnet publish <MeshWeaver.Plugins>/src/Memex.Database.Migration/Memex.Database.Migration.csproj -c Release -p:MeshWeaverRoot=<this checkout> \
  -t:PublishContainer -p:ContainerRegistry=meshweaver.azurecr.io \
  -p:ContainerRepository=memex-migration -p:ContainerImageTag=<tag>

🚨 The image must be MULTI-ARCH. memex-portal-ai-base:latest is built for linux/amd64 and linux/arm64 (x86 cloud nodes and Apple-silicon local k3s both pull it), so the app image layered on it must be too. Do not pass -r linux-x64 β€” a single RID produces a single-arch image, and a node of the other architecture gets an ImagePullBackOff that reads as "the deploy hung". Three details make the flags above non-obvious, all of them silent when wrong:

🚨 <tag> must be dotted SemVer (3.0.0, 3.0.0-ci.749) β€” a descriptive tag gets reverted. The in-pod self-updater only recognises tags matching ^\d+\.\d+\.\d+([-+].*)?$ (VersionSelect.PlatformVersionTag); a hand-picked bugfix-2026-06-05 is not a candidate, so the poller picks the newest CI tag instead and patches the Deployment straight back off your image β€” and it polls once immediately on pod start (StartWith(-1L)), so the revert lands within moments of the roll you just did. A hand-built image is therefore only usable with the updater paused (see "Hard pause" below); the normal way to ship code is a merged PR whose CI build produces a ci.<N> tag.

CI also builds images on push, but it lags β€” check az acr repository show-tags -n meshweaver --repository memex-portal-ai --orderby time_desc --top 5 before assuming your commit is built. If only portal code changed (no migration/schema change), you can reuse the live memex-migration tag and skip the migration build.

Business rules / scopes are NOT in the image β€” they ship as a plugin

The published image contains no MeshWeaver.BusinessRules assembly and no scope source generator. Business rules and scopes were removed from the platform (commit f7a8c086c); they ship as the MeshWeaver.Plugins/BusinessRules plugin, which carries the scope runtime as a shared-source library node (pulled into a consumer's compilation via shared=@BusinessRules/Scope/Source) and carries the ScopeCodeGenerator source for the generator-injection seam. The platform and the Doc partition start with zero business-rules dependency β€” stated in the comment blocks in MeshWeaver.Graph.csproj and Memex.Portal.Distributed.csproj, and asserted against the built image by PortalImageFacility.

Consequences for a deploy:

The dotnet publish -t:PublishContainer command above is self-contained β€” there is no pack step and no mesh-local feed. The former BakeMeshLocalFeed target and the CI "Pack mesh-local #r packages" step were both removed in #395; nothing resolves MeshWeaver.* from dist/packages during a deploy. nuget.config is still copied into the image (so a Code node's third-party #r "nuget:…" can resolve), and it still declares the mesh-local source plus a packageSourceMapping pinning MeshWeaver.*/Memex.* to it β€” the mapping is what stops a typo'd #r "nuget:MeshWeaver.X" pulling a same-named package from another publisher on nuget.org. The directory that source points at does not exist in the image.

2. Roll out (NS = memex)

Portal-only code update (no schema change):

az aks command invoke -g <aks-resource-group> -n <aks-cluster> --command "\
  kubectl -n <NS> set image deployment/memex-portal-deployment memex-portal=meshweaver.azurecr.io/memex-portal-ai:<tag>; \
  kubectl -n <NS> rollout restart deployment/memex-portal-deployment; \
  kubectl -n <NS> rollout status deployment/memex-portal-deployment --timeout=300s"

The portal container is memex-portal in Deployment memex-portal-deployment.

🚨 The migration is a run-once Job in the chart, NOT a Deployment β€” set image cannot roll it. Older copies of this runbook also passed deployment/memex-migration-deployment to set image and rollout restart; that step is removed above because it does not run a migration. Since 2026-09-03 the SELF-UPDATER also mints one (memex-migration-su-<tag>) before every automatic roll β€” see Database Migration Procedure for the routine, the one-time RBAC grant it needs, and the recovery when a pod refuses on db_version. deploy/helm/templates/memex-migration/job.yaml renders a batch/v1 Job named memex-migration-<Release.Revision> with restartPolicy: Never; a fresh one is created by helm upgrade, and ttlSecondsAfterFinished cleans it up. memex-migration-deployment is a legacy resource: per deploy/aks/SELF-UPDATE.md, live AKS clusters still carry it scaled to 0, so set image / rollout restart against it changes a pod spec that never runs, and on a chart-only environment the command simply 404s. Either way it is silent β€” the commands appear to succeed and no migration happens. A schema change therefore has to go out via helm upgrade (which mints a new Job), not via this set image roll-out. Confirm which of the two shapes the target namespace actually has before you rely on either (kubectl -n <NS> get deploy,job | grep migration).

3. Verify

The cluster runs what the chart describes β€” check it, don't assume it

A green rollout says the pods came up. It says nothing about whether they came up with the configuration the chart declares. An image-tag roll does not apply a chart change β€” only a helm upgrade does β€” so a fix committed to the chart can sit unapplied indefinitely, and a setting applied by hand (kubectl set env) runs until the next helm upgrade silently deletes it. Four such divergences surfaced on one day, none of them detected by anything:

what diverged how it went unnoticed
the drain preStop hook in the chart, never applied β€” every roll severed live circuits
a wget probe in the chart, contradicted by the image (curl present, wget absent)
the GitHub App identity hand-applied to one cluster, never in the chart β€” other envs never got it
a portal's PluginCatalog__* inline env: on the Deployment, not in the ConfigMap
deploy/aks/scripts/check-chart-drift.sh -n <NS> -r <release> \
  -f <values.yaml> [-f <values.env.yaml>] \
  --via aks-invoke -g <aks-resource-group> --aks <aks-cluster> \
  --expect-patch deploy/aks/envs/<env>/portal-patch.json

It renders the chart, reads the live memex-portal-config and memex-portal-deployment plus the namespace's PodDisruptionBudget and ScaledObject, and reads the last-deployed release manifest (helm get manifest), then classifies every difference into the five classes below β€” COLLIDES and SHADOWS (an inline env: entry that overrides envFrom, so the value the chart supplies is dead or resolved at random per pod start), then CLUSTER-ONLY (hand-applied, and in no committed source), CHART-ONLY (described but never applied β€” nobody is getting it), and DIFFERS.

🚨 CLUSTER-ONLY does NOT mean "the next helm upgrade deletes it". This page said that until 2026-09-03 and it is false β€” measured, and contradicted four paragraphs below by this same page. Helm's three-way merge removes only what Helm previously owned, so drift applied out of band survives every deploy. Ranking the backlog by "what a deploy would destroy" ranked it by a hazard that does not exist and buried the two classes that are wrong now.

Except for one narrow half of it, which the check now computes. There are three sides here, not two: D the chart as CI renders it, L the live objects, M the release manifest. A key that is in L and in M but no longer in D is a chart retirement β€” helm owned it, so the merge removes it, and the next helm upgrade genuinely does delete it. Such a finding stays in the CLUSTER-ONLY class and reads PENDING DELETION, with a count in the summary line, and it states whether the live value is empty (the removal is a no-op) or not (a real removal) β€” the value decides that, not the key name. The other half now says helm never owned it rather than asking you to take "a helm upgrade PRESERVES it" on trust. The manifest is a required input: if it cannot be read the check fails RED, because with M missing every live-only key would read as the surviving half and the report would answer "nothing is about to be deleted" at the moment it could not tell.

Secret values are never printed, and neither are inline env values β€” but both are compared: an entry present on both the chart and the pod with a different value is reported as DIFFERS without printing either side.

The availability shape β€” spec.replicas, the budget, the autoscaler β€” is compared because without it the check cannot see an outage. On 2026-08-14 memex-cloud served every request from ONE pod, and none of the three reasons was a ConfigMap key: a hand-applied minAvailable: 2 budget (the chart renders maxUnavailable: 1) sitting at disruptionsAllowed: 0, and a ScaledObject annotated autoscaling.keda.sh/paused-replicas: "1" β€” which pins the replica count, deletes the HPA, and silently reverts kubectl scale. A live disruptionsAllowed: 0 is now reported on its own terms, whether or not the chart agrees with it: agreeing would not make it survivable.

Two things to know before you trust a green run:

It fails RED when it cannot compare β€” an unreachable cluster, a failed render, a rendered ConfigMap with no keys, or an unreadable release manifest β€” rather than reporting "no drift" on no evidence.

Two callers, and neither may skip. The script's inputs are cluster credentials and the per-env values, so it cannot be a pull-request gate: a gate that skips when a credential is absent renders the same tick as one that passed, which would rebuild the very defect it catches. It runs from .github/workflows/chart-drift.yml on a daily schedule (plus workflow_dispatch) behind a preflight job that asserts every external input and fails red naming what to provision β€” never an if: that decides whether to run. Expect it red until the CI identity has AKS runCommand rights and each environment's SECRET-FREE values.<env>.public.yaml is committed to the private deploy repo; that red is the honest report that drift detection is not wired up yet.

Reading its report. The findings are ranked worst-first and the classes mean different things β€” and the two that matter most, COLLIDES and SHADOWS, are wrong on the cluster right now rather than at the next deploy. A helm upgrade neither deletes nor overwrites drift (measured; Helm's three-way merge removes only what Helm owns), so nothing on that list resolves itself. Chart Drift β€” what a deploy actually does has the measurement and the per-class triage.

Volume capacity is a record property β€” volumes[].size, applied by the operator

🚨 Never kubectl patch pvc … storage on a live portal. The size of every persistent volume is volumes[].size on the instance's Hosting/Deployment record, and the operator applies it:

you want you do what runs
a bigger share (/data full, content growing) edit volumes[].size on the record (128Gi), then { "requestedAction": "Reconcile", "confirmation": "<id>" } β€” or a Provision, which carries the same step hosting-pv-resize --namespace <ns> --claim <claimName> --size <size> once per declared claim, ordered FIRST in a Reconcile: a full /data blocks the rollout the re-apply then waits on
to know whether the cluster has caught up { "requestedAction": "Audit" } the report's volumeCapacityBelowRecord β€” the claim, the declared size and the live capacity

What the command does, and refuses, is the whole contract (deploy/aks/operator/bin/hosting-pv-resize):

Why this exists: on this fleet the portal's claims are NOT helm-managed (they were applied by hand once from portal-pvcs.yaml; helm upgrade never touches an object it does not own), so a bigger size on the record re-rendered a bigger number into the values file and changed nothing on the cluster. Measured 2026-09-08 13:51Z: memex-data in namespace memex was 16Gi with 3 MiB free while memex-cloud's ran 128Gi. The portal-pvcs.yaml captures in the config repo are descriptive; the record is what the operator applies.

The chart must also agree with ITSELF β€” check-chart-invariants.sh

Drift is only half of it. The memex-cloud outage above needed no cluster to detect: the chart in git described an impossibility. deploy/aks/values.aks.yaml asked for keda.minReplicas: 2, scaledobject.yaml rendered a floor of 2, pdb.yaml budgeted for two pods β€” and deployment.yaml hard-coded replicas: 1, so replicas.portal: 2 had sat in values consumed by nothing for a month. helm template emits that set happily, because helm validates syntax, not sense.

deploy/aks/scripts/check-chart-invariants.sh renders every values combination the repo ships and asserts the ones that matter: spec.replicas absent under KEDA (helm and the HPA must not both own it), a replica floor above 1 implying AdoNet clustering and ReadWriteMany on every portal claim, AdoNet implying a rendered ConnectionStrings__orleans, a budget using maxUnavailable rather than the not-scale-invariant minAvailable, a budget implying a floor above 1, and strategy.maxUnavailable: 0 under KEDA. Every input is in this repository, so it runs unconditionally on every pull request (.github/workflows/chart-gate.yml) β€” no secret to be absent means no condition under which it may decline to run.

Node drains β€” the disruption budget and where the replicas sit

An AKS node-pool operation (node image upgrade, OS patching, autoscaler consolidation) cordons a node and evicts its pods through the eviction API, one node after another. Two facts decide what the portal's users see while that happens:

🚨 Measured 2026-09-09 (MeshWeaver#3772): memex ran replicas.portal: 2 with no budget β€” pdb.yaml was gated on keda.enabled, and memex's helm-release lane renders the vault values plus values.memex.public.yaml, never deploy/aks/values.aks.yaml where KEDA is on. Both pods sat on one node; a drain at 01:05:01Z shut both down in the same second and the portal answered 503 until a replacement passed the startup gate (~90 s). Reading it needed the cluster-wide log β€” pods in three namespaces stopping together β€” because inside the memex namespace it looked like a rollout that changed nothing. check-chart-invariants.py now refuses a floor above one with no budget (invariant 14) or no spreading (invariant 15); before, it only asked whether a budget that exists has replicas under it.

Loki loses whatever its ingester had not flushed when loki-0 itself is drained (the 01:05Z lines were readable at 01:08Z and gone at 01:11Z) β€” read a cluster-wide incident promptly, and see the header of deploy/aks/scripts/values.observability.yaml for what the loki-stack chart can and cannot persist.

Self-update ops β€” pausing, pinning, and the rules that bite

Operational facts about the in-pod updater (learned the hard way β€” each cost a debugging session):

Portal self-update β€” Workload Identity for ACR polling

Steady state is self-update (see ReleaseStrategy.md): the portal polls ACR and patches its own Deployment to a newer image. The in-cluster PATCH uses the memex-portal-sa service-account token (RBAC ships in the Helm chart and works everywhere). Listing the ACR tags to discover a newer image needs an Azure credential β€” that is wired with AKS Workload Identity, mirroring the existing pgBackRest wiring (deploy/aks/infra/modules/storage.bicep).

What the Helm chart already does (no edits needed): when selfUpdate.azureClientId is set it annotates memex-portal-sa with azure.workload.identity/client-id, labels the pod azure.workload.identity/use: "true", and sets AZURE_CLIENT_ID. The self-updater (AcrTagLister) then uses ManagedIdentityCredential(AZURE_CLIENT_ID) β†’ AAD token β†’ ACR token.

What the Azure side provides (deploy/aks/infra/modules/portal-identity.bicep, wired from deploy/aks/infra/main.bicep): a single shared user-assigned managed identity (<namePrefix>-portal-mi) with one federated credential per portal namespace β€” subject system:serviceaccount:<ns>:memex-portal-sa, issuer = the cluster OIDC issuer, audience api://AzureADTokenExchange β€” for every namespace in the portalNamespaces param (memex, memex-cloud, and any customer portal namespaces). The UAMI gets AcrPull on meshweaver.azurecr.io (AcrPull includes the metadata_read the tag-list call needs). One UAMI β†’ one AcrPull grant β†’ the same portalIdentityClientId wired into selfUpdate.azureClientId for every namespace.

One-time setup

  1. Provision the UAMI + federated credentials β€” included in the infra deploy (deployPortalIdentity defaults true). Read the client id back:
    # --name is whatever the infra deploy was created with; deploy/aks/README.md uses memex-aks-infra.
    az deployment sub show --name memex-aks-infra-sc \
      --query "properties.outputs.{clientId:portalIdentityClientId.value, principalId:portalIdentityPrincipalId.value}" -o jsonc
    
  2. Grant AcrPull on the shared registry. The ACR (meshweaver.azurecr.io, RG meshweaver-shared) is cross-RG from <aks-resource-group>, so β€” exactly like the cluster kubelet's AcrPull β€” grant it out-of-band:
    PORTAL_MI_OID=$(az identity show -g <aks-resource-group> -n <portal-identity> --query principalId -o tsv)
    az role assignment create --assignee-object-id "$PORTAL_MI_OID" --assignee-principal-type ServicePrincipal \
      --role AcrPull --scope $(az acr show -n meshweaver --query id -o tsv)
    
    (IaC alternative: deploy with grantSharedAcrPull=true β€” authors this via infra/modules/acr-role-assignment.bicep in the registry's RG; needs User Access Administrator on meshweaver-shared. A per-deployment ACR instead of the shared one is granted in-bicep automatically.)
  3. Set selfUpdate.azureClientId to portalIdentityClientId for each environment (the in-pod patch works without it; this only authenticates the tag-list). Same value everywhere:
    • memex β†’ the git-ignored values.deploy.yaml in the staging dir (template: deploy/aks/scripts/values.deploy.example.yaml), or helm upgrade --set selfUpdate.azureClientId=<clientId>.
    • memex-cloud / customer portals β†’ the git-ignored deploy/aks/envs/<env>/values.<env>.yaml.

Adding a new portal namespace? It needs its own federated credential on the shared UAMI β€” add the namespace to portalNamespaces and re-run the infra deploy (idempotent), or az identity federated-credential create (see OnboardingNewEnvironment.md). The subject must be exactly system:serviceaccount:<ns>:memex-portal-sa.

Migration under self-update

When an install rolls itself to a new tag (per Admin/UpdatePolicy), the in-pod updater patches exactly one workload: memex-portal-deployment (container memex-portal). It does not touch the migration, and it says so in its own success line:

[SelfUpdate] patched memex-portal-deployment to <tag>. The database migration is a helm-run Job (memex-migration-<revision>) and is NOT rolled from here; DbVersionGate refuses to serve if this image is ahead of the schema.

This section used to say the updater patched two Deployments β€” the portal and memex-migration-deployment β€” and that the second 404'd and threw, poisoning the verdict of the portal roll that had already succeeded. That was true until #2797 and is not true now. The second PATCH is gone, and a guard fails the build if anyone re-adds one β€” SelfUpdatePatchesOnlyPatchableWorkloadsGuard, in MeshWeaver.Plugins (src/Memex.Hosts.Test/), which is also where the updater itself lives (src/MeshWeaver.SelfUpdate.Aks/KubernetesDeploymentUpdater.cs). Neither is in THIS repo, so git grep here finds only this page. Left recorded because the stale text sent at least one investigation looking for a 404 that no longer happens.

🚨 The gap is not that the migration leg is broken β€” it is that there is no migration leg at all. A self-update moves the image and nothing else, so a release that bumps the schema is, by construction, un-takeable by self-update. CD does build and push a correctly-tagged memex-migration image on every run (tag-for-tag with memex-portal-ai); nothing in the continuous path ever runs it. main-cd.yml says as much: "a migration that can never run surfaces only at the deploy that needs it." Only helm upgrade mints the Job.

This is a property of the fleet, not of one namespace, and the remedy is an OPEN decision. An install rolls itself forward until it meets its first schema-bumping release and stops there; an install that has not stopped has not arrived yet, not been configured differently (memex-cloud served ci.7621 healthily the same day for exactly that reason). Clearing one instance at one tag clears today and re-arms for the next bump. Why that matters for the control instance, what the three candidate remedies trade, and how to pick a target when an operator does carry an install across, are on The Self-Update Schema Wall.

What that looks like when it fires (memex, 2026-09-03 β€” MeshWeaver#3207): self-update rolled the portal to three successive builds needing db_version 55 against a database at 54. Each new pod hit DbVersionGate, logged Critical, and exited; the ReplicaSet never went Ready and the rollout recorded ProgressDeadlineExceeded. The cost was a pod crash-looping on a 5-minute back-off, each attempt writing a ~685 MB core dump.

🚨 Read the exit code carefully if you meet this. The container reports exitCode 139, which reads as SIGSEGV β€” but the dump records NT_SIGINFO … signo 6, i.e. SIGABRT. It is a managed fail-closed shutdown, not a native crash, and the OrleansProvisioningGate cancellation and Hosting failed to start that follow are the shutdown's own noise rather than the fault.

The service stayed up, and that is not luck. maxUnavailable: 0 with maxSurge: 1 kept the previous pods serving while the new ReplicaSet failed to become Ready, so a fail-closed boot became a stalled rollout instead of an outage. Verify that setting before any roll β€” it is the only reason this class of failure is recoverable rather than an incident.

So a portal stuck behind its schema is a SAFE state, and the fix is not a hand roll. Rolls go through CD; a hand-pinned tag is what the roll-via-CD directive exists to prevent. Where the gate must hold a roll, the decision belongs BEFORE the portal is patched β€” that is the shape that guard prescribes, and re-attempting a migration PATCH after the portal has already rolled is the defect #2797 removed.

🚨 And where a helm upgrade genuinely is the sanctioned path, choosing its target is a separate problem with its own trap. A tag whose three platform images are all promoted and all pullable can still have no plugin modules for its framework identity β€” Promote: tag the full set and Verify every image shipped both go green in that state, and the job that discriminates is Plugins: bake + seal the publication for this identity. Check the seal, the ancestry of the fix you need, and a memex-migration image at the SAME tag, before naming any build a target: The Self-Update Schema Wall β†’ "What makes a tag a safe target".

Startup ordering β€” what actually happens if the portal outruns the schema. Two mechanisms exist, and only the first is a wait:

So a portal that starts against an un-migrated database fails closed and exits β€” it does not hold and then go live. Kubernetes restarts it, and that pod recovers only once something else has bumped db_version.

Whether the NAMESPACE still serves depends entirely on the rollout strategy, and this page used to say flatly that it does not. On a rolling update with maxUnavailable: 0 and maxSurge: 1 β€” what the chart ships and what memex ran on 2026-09-03 β€” the previous ReplicaSet keeps serving because the new pod never becomes Ready, so the failure is a stalled rollout (ProgressDeadlineExceeded) and not an outage. Lose that setting, or hit this on a fresh install / a full restart where there is no healthy ReplicaSet to fall back on, and the namespace genuinely has no serving portal. The gate protects the database from a half-migrated portal; it does not make the ordering safe on its own, and the rollout strategy is what decides whether "unsafe ordering" costs you a stalled rollout or an outage. Treat "the migration ran" as a precondition you verify, not one the roll guarantees.

The migration Job IS the evidence β€” so it must outlive the observer

helm upgrade mints memex-migration-<revision>, and that Job object is the only durable record that this revision's migration ran. Memex's helm-release.yml reads it directly: its Observe the rollout step polls kubectl get deploy memex-portal-deployment and kubectl get job memex-migration-<revision>, and reports DONE only when every replica is updated and available at the new generation and the Job succeeded.

🚨 ttlSecondsAfterFinished reaps a FINISHED Job whether it Completed or FAILED, and at the chart's original 600 s that happened well inside the reader's own window β€” 25 minutes inside a deploy, 40 on an action: observe re-run, with the lane's error message explicitly telling the operator to "re-run with action=observe" later. Measured 2026-09-07 on the live cluster:

memex revision 35, run 34117537570 observe gave up at its 25-minute budget with migration-job=succeeded=0 failed=0 active=1
the same namespace at 16:20Z no Job, no pod, no events β€” kubectl get job -n memex returns only the assembly-cache-prune Jobs

An absent Job is indistinguishable from a Job that never existed, and a migration that failed reaps exactly the same way. The chart now sets ttlSecondsAfterFinished: 86400 so the artefact outlives every window that reads it. That is one half; the other is the verdict refusing to call an absent Job a success (Memex#188). Neither half alone is enough β€” a longer TTL without the verdict change only moves the cliff, and the verdict change without the longer TTL makes a legitimate observe re-run permanently red.

A memex-cloud migration is measured in HOURS, not minutes, and that is not a deadlock. memex-migration-28 ran 4 h 47 m on 2026-09-07 and was healthy throughout: the tail of its log is [EmbeddingBackfill] <schema>: N embedded, walking ~137 partition schemas alphabetically. The portal serves normally while it runs (2/2 Ready on 3.0.0-ci.8009), because DbVersionGate gates on the schema version, not on the backfill. So "the rollout finished" and "the Job finished" are two different clocks in that namespace, minutes apart from hours β€” do not read a still-running migration Job as a stuck deploy.

Secrets the chart renders that no in-cluster Postgres backs

postgres.enabled: false on every AKS namespace β€” the mesh lives on the Azure Postgres flexible server and is reached through ConnectionStrings__memex. There is no memex-postgres-statefulset and no memex-postgres-service in either production namespace (measured 2026-09-07: "No resources found" in both).

Two keys in memex-portal-secrets / memex-migration-secrets address that absent Service, and until 2026-09-07 they rendered there unconditionally (Memex#204). Read by base64 length only β€” never by value:

key memex memex-cloud
MEMEX_PASSWORD b64len 40 b64len 0
MEMEX_URI b64len 112 b64len 76 β†’ postgresql://postgres:@memex-postgres-service:5432/memex

🚨 The URI is the instructive half. Its template default fires on the empty password and derives a value, so the key is present, non-empty, plausible and wrong β€” it survives a keys[] audit and the base64-length audit that exists because keys[] is insufficient. The shape that catches it is neither: it is a per-key statement of whether empty is legal, plus an expected form.

Both are now gated on postgres.enabled (or on a value supplied explicitly, so nothing an operator sets is ever silently dropped). Nothing read either key β€” swept across MeshWeaver and MeshWeaver.Plugins, all *.cs: every match in either repository is a manifest that sets it (compose Γ—4, ACA bicep Γ—2, helm Γ—2). They still render for compose, local k3s and e2e, where an in-cluster Postgres genuinely exists and the same values are correct.

The general rule, which is the reusable part: a secret that reads as configured and is not fails at connect time with a credentials error rather than at startup with "not configured", and the second is the one an operator can act on at 3am. Prefer absent to empty, and never let a default manufacture a plausible value out of an unconfigured input.

Key Vault secrets are DECLARED in values β€” keyVaultSecrets

Since 2026-08-30 the chart renders the SecretProviderClass itself, from one block in the values file, together with the three halves the pod needs (the CSI volume, its mount, the envFrom on the synced Secret):

keyVaultSecrets:
  vaultName: "Systemorph"
  tenantId: "<entra tenant id>"
  identityClientId: "<the Key Vault Secrets Provider add-on's user-assigned identity client id>"
  name: "memexcloud-portal-ai-secrets"       # the SecretProviderClass; blank β‡’ memex-portal-keyvault
  syncedSecret: ""                            # the synced k8s Secret; blank β‡’ the same name
  secrets:
    - vaultSecret: memexcloud-Email-ClientSecret          # the vault OBJECT'S name β€” never a value
      key: Email__ClientSecret                            # the env key it lands as

Empty secrets renders nothing, so an environment that has not opted in is byte-identical to before. With any entry the vault, tenant and identity are required and a half-declared block fails helm template naming the key. Names only: no value is ever in values or in the render.

An instance that reads more than one vault-secret set β€” memex runs the hand-made memex-kv alongside the chart-owned memex-portal-keyvault β€” declares the rest under keyVaultSecretClasses, a list of the same shape. Order is precedence, and one vault object may serve several keys: that is how one credential lands under both PluginCatalog__RegistryToken and PluginCatalog__Registries__0__Token. Why a single slot was a data-loss bug, and the other two layers a record could not previously see, are in Deployment env layers.

🚨 Why it moved into the chart. Until then every SecretProviderClass in the fleet was a hand-made object β€” kubectl apply-ed once from a laptop, present in no repository, rendered by nothing β€” and the values file could only point at it by name (extraEnvFrom / extraVolumes, now the legacy escape hatch). Which vault objects a pod carried was knowable only from the cluster. On 2026-08-30 the memex install crashed at boot with EmailConfigurationGuard (Email:Enabled=true, Email:ClientId unset): its Email configuration existed TWICE on the live pod β€” the chart's ConfigMap rendering the defaults, and a hand-made Secret patched onto the live Deployment as explicit env entries in a different letter case β€” and .NET's case-insensitive environment provider let enumeration order pick the winner on every pod start. A coin toss per boot, invisible from any file.

The fleet's records drive this block: a Hosting/Deployment record's keyVaultSecrets section is projected onto it by the Hosting plugin's HelmValues, with the vault name derived from the key by the convention <keyVaultSecretPrefix><Section>-<Key> when the record leaves it blank. The operator's page for the whole path β€” record β†’ render β†’ chart β†’ ConfigMap / SecretProviderClass / synced Secret β†’ env β€” is Hosting/Configuration in MeshWeaver.Plugins.

Two things that do not change: a declared object the vault does not hold still fails the whole mount (the pod stays ContainerCreating, the old pod keeps serving, the rollout stalls) β€” create the vault secret before declaring it; and a new or changed vault value still needs the pod to restart (see "KeyVault CSI env timing" above).

First-time environment setup β‰  code update

deploy/aks/envs/<env>/deploy.sh provisions a new environment: helm upgrade --install of the chart, PVCs, the Key Vault SecretProviderClass (legacy hand-made shape β€” a new environment declares its secrets under keyVaultSecrets in values instead), ingress, and the connection-string patch. Do not run it for a code update β€” it re-applies the whole chart and can reset live ConfigMaps (e.g. the email config). Use it only when standing up a brand-new namespace.

Only the reference env deploy/aks/envs/example/ is in this repo; per-tenant env directories are git-ignored (.gitignore: "directory names are tenant identities and must not enter this public repo"), so the real ones live outside it. Copy example/ as the template.

Note the tension with Β§2: because the chart's migration is a Job created per Helm revision, helm upgrade is also the only in-repo path that runs a migration. A schema change consequently needs this script (or a bare helm upgrade) even though a plain code update must not use it.

Diagnostics β€” through the memex API first

🚨 Maintainer directive, 2026-09-08: operations and diagnostics go through the memex API, not through az/kubectl. The control instance's Hosting module answers the two everyday questions as nodes, with no cluster credential on the caller:

question Hosting/InstanceAction (content.deployment: "Deployments/<id>") answer
what is <id> running β€” per replica: image, ready, restarts, started, the pod's own /health and its detail { "requestedAction": "Sample" } Ops/Status/<id> β€” replicas[], warnings[] (what the sample could NOT see; unknown is never zero)
what did it log { "requestedAction": "Logs", "query": "<regex or \| pipeline>", "sinceMinutes": 60, "limit": 300, "pod": "<optional>" } logQl, entryCount, truncated on the run; Hosting/LogEntry nodes under Ops/Logs, the Deployment page's Logs area
what lives only on the cluster { "requestedAction": "Audit" } Ops/Audit/<id>
roll it pin pinnedImageTag on the record β†’ { "requestedAction": "Reconcile", "confirmation": "<id>" } the run's phases; then a Sample
grow a full share set volumes[].size on the record β†’ the same Reconcile the run's Ensure volume capacity: <volume> phase, pv_capacity= read back from the claim β€” see "Volume capacity is a record property" above

Both observations read the cluster's monitoring stack (kube-state-metrics via Prometheus, Loki) from inside the cluster, where it is credential-free; the roll runs as the in-cluster operator Job. The fleet guide (get @Hosting/Guide, "Roll, restart, observe") carries the full table. What follows is the break-glass form, for when the control plane itself is what is broken.

Diagnostics (private cluster β€” break glass)


For Azure AD app registration and secrets (shared across both routes), see Deployment.md.

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