Where every value lives
Every configuration value an instance runs with has exactly one home, and from that home it
travels one path to the pod. If you cannot name the home of a value you are looking at on a
live pod, it is drift, and the next helm upgrade will either delete it or — worse — leave it
racing the rendered copy. This page is the map: read the table, follow the path, and never edit a
stop along the way instead of the home.
The path
Hosting/Deployment record Systemorph/Memex mesh/Deployments/<instance>.json ← THE home (git, reviewed)
│ HelmValues.Render (the Hosting plugin; pure, tested in Deployment/Test)
▼
values.<release>.public.yaml Systemorph/Memex deployments/aks/<env>/ ← RENDERED, committed, never hand-edited
│ helm upgrade -f <vault half> -f values.<release>.public.yaml --set portal.image=<running>
▼
chart templates Systemorph/MeshWeaver deploy/helm/templates/memex-portal/
│
├─ config.yaml → ConfigMap memex-portal-config → envFrom → env (non-secret keys)
├─ secrets.yaml → Secret memex-portal-secrets → envFrom → env (the captured vault half)
├─ secretproviderclass.yaml → SecretProviderClass → CSI mount → synced Secret → envFrom → env (Key Vault secrets, by NAME)
├─ deployment.yaml → the pod: image, resources, volumes, the CSI mount, the envFrom order, probes, drain
└─ ingress.yaml / pdb / scaledobject / rbac / serviceaccount
Three files, three repos, one direction. The record is the only thing a human edits; the rendered
values file is a diff of intent (its header says so); the chart is code reviewed in the
platform repo. helm get values <release> on the cluster must equal the layering of the vault half
and the committed file — the drift gate (deploy-drift.yml in Systemorph/Memex) says when it does
not.
One home per kind of value
| Kind of value | Example | Home — the record field | Renders as | Chart → cluster object | Reaches the pod as |
|---|---|---|---|---|---|
| Instance identity | host, namespace, database, helm release, image repository | host, namespace, database, databaseServer, databaseUsername, helmRelease, imageRepository |
config.memex_portal.MEMEX_*, config.memex_migration.*, ingress.host, portal.image |
config.yaml → ConfigMap memex-portal-config; ingress.yaml |
MEMEX_HOST, MEMEX_DATABASENAME, … |
| The image | which build runs | pinnedImageTag — normally BLANK: the deploy resolves the RUNNING tag and pins that, so a deploy never rolls the build |
portal.image, migration.image |
deployment.yaml, job.yaml |
the container image |
| Where the image comes from | which registry the pods pull from, and the credential they present | the HOST of imageRepository (everything before its first /) plus imagePullSecret — the NAME of a dockerconfigjson Secret in the namespace, required whenever that host is not *.azurecr.io (a named problem otherwise); the provision's hosting-pull-secret step writes it from the instance's own registry key in the vault, before the release |
portal.imagePullSecret; selfUpdate.registry (rendered only for a non-ACR host, so the self-updater polls and rolls against the registry the pods pull from) |
deployment.yaml → imagePullSecrets; config.yaml → SelfUpdate__Registry |
the pod spec, plus one env key |
| A created volume claim | a brand-new instance's PVCs | volumes[].create: true beside claimName, size, storageClass (all three required when set; accessMode defaults to ReadWriteMany) — only on a record whose claims do not exist yet: every existing instance's claims are unmanaged by helm, and rendering one fails the next upgrade with resource already exists |
persistence.<name>.create: true |
pvc.yaml → a PersistentVolumeClaim annotated helm.sh/resource-policy: keep |
the pod's volume |
| The registry this instance HOSTS | cr.meshweaver.cloud — the public instance only |
registry (host, image, authImage, storageAccountName, keyVault.{name,certObject,keyObject}, publisherPasswordBcrypt required; issuer, storageContainer, publisherUsername, validationUrl, replicas defaulted; serviceAccount, notificationsUrl, keyVault.{httpSecretObject,notificationSecretObject} optional). NAMES and a bcrypt HASH only — never a certificate, a key or a password |
registry: block, enabled: true, every default resolved, the ingress on the portal's own ClusterIssuer |
the registry Deployment/Service/Ingress, its SecretProviderClass and the docker_auth service |
the registry pods, not the portal |
| Non-secret feature config | email sender, sign-in client ids, AI endpoints and models, GitHub App identity, telemetry, boot modules, storage layout, webhook targets | the typed sections: email, signIn, ai, gitHubApp, telemetry, requiredModules / requiredModuleSlots, storage, webhookInboxTargets, socialLinkedInClientId, autoRecycleOnStaleBuild, minRollInterval |
config.memex_portal.Section__Key |
config.yaml → ConfigMap memex-portal-config |
Section__Key env → Section:Key |
| Secrets | client secrets, API keys, private keys, the master key, the connection string | keyVaultSecrets.secrets[] — NAMES. The VALUE lives in Key Vault under that name and nowhere else |
keyVaultSecrets: block |
secretproviderclass.yaml → SecretProviderClass + deployment.yaml → CSI volume + mount + envFrom → the driver's synced Secret |
Section__Key env, fetched from the vault at pod start |
| Pod shape | RAM/CPU, replicas, autoscaling, volumes, ingress annotations, drain, startup probe | resources, replicas, autoscaling, volumes, ingress, drain, startupProbe |
resources.portal, replicas, keda, persistence, ingress, portal.drainSeconds, probes.startup |
deployment.yaml, scaledobject.yaml, pdb.yaml, ingress.yaml |
the pod spec, not env |
| The React front end | whether /next is deployed, and which image it runs |
portalNext (enabled, image, replicas, portalOrigin) — enabled with no image is a named problem: the chart's fallback is the LOCAL developer tag, which no cluster node can pull |
portalNext: block |
portal-next.yaml → its own Deployment + Service; ingress.yaml → the /next path; config.yaml → Portal__ReactAppUrl |
the pod spec, plus one env key on the portal |
| Clustering | one silo or many | derived from replicas / autoscaling (state orleansClustering only to override) |
config.memex_portal.Deployment__Orleans__Clustering |
config.yaml; secrets.yaml derives ConnectionStrings__orleans |
env |
| The lifecycle operator | control instance only | operator |
hostingOperator: block + Hosting__Operator__Enabled |
config.yaml, the job-runner token volume in deployment.yaml |
env + /var/run/secrets/hosting-operator/token |
| Plugin catalog | which repos it mounts, what a fresh boot installs | pluginRepos, preInstall |
pluginCatalog: block |
config.yaml |
PluginCatalog__* env |
| The advanced rung | a portal key the typed surface does not have yet | extraPortalConfig (Section__Key: value) — a key naming a typed field is a named problem |
config.memex_portal.<key> verbatim |
must be rendered by config.yaml, or the coverage gate fails the Memex PR |
env |
| Chart globals | the inbound-mail webhook guard, the HTTP port, probe defaults | not the record — deploy/helm/values.yaml in the platform repo (Email__SubscriptionClientState is deliberately the same across the fleet) |
— | config.yaml |
env |
| The captured vault half | parameters:, pgbackrest: (a storage account KEY), secrets: |
not the record — Key Vault Systemorph, secret helm-values-<release>, written by helm-release capture from the live release |
layered FIRST at deploy; the committed file wins on every key it declares | secrets.yaml → Secret memex-portal-secrets |
env |
| Legacy: a hand-made SecretProviderClass | the mount of a class the record can only point at | secretMounts[] — the escape hatch, not a home: the class's contents are on the cluster only |
extraEnvFrom, extraVolumes, extraVolumeMounts |
deployment.yaml verbatim |
env — but WHICH keys is unknowable from any file |
Two rules of the renderer that decide what a blank means:
- Null renders nothing; a stated empty string renders
"". An unstated property writes no key, so the vault half (or the chart default) decides;""is how a record turns a sign-in scheme or a provider OFF. A bool always renders, so afalsenever falls back to somebody else'strue. - A
keyVaultSecretsblock with nosecretsrenders nothing, and so does the chart for it — an environment that has not declared its secrets is byte-identical to before. - Keys are one key in ANY letter case. .NET folds environment keys case-insensitively — which
is exactly what made
EMAIL__ENABLEDandEmail__Enabledrace on 2026-08-30 — so the renderer treatsEMAIL__CLIENTID,Email__ClientIdand a typed field of that name as the same key: a declared secret that the ConfigMap also renders, two extras differing only by case, or an extra naming a typed field in another case are all named problems, never a second source. - A record with a named problem does not render.
HelmValues.Problemsis the preflight andHelmValues.Renderrefuses on it, listing every problem — andRenderis the one call the launch path makes, so an InstanceAction fails at Launch operator job naming what to fix before anything reaches helm. The setup dialog's review shows the same list.
Log levels: the one value whose home is the IMAGE, and Information is an ALLOW-LIST
Every other row above travels record → overlay → ConfigMap → env. Log levels do not. The
deployed portal's verbosity is set entirely by src/Memex.Portal.Distributed/appsettings.json in
MeshWeaver.Plugins — the image's own file. Neither mesh/Deployments/*.json nor
deployments/aks/*/values.*.public.yaml carries a Logging__* key on any instance, and
memex-portal-config on both memex and memex-cloud holds none either (measured 2026-09-07).
That is deliberate: what an operator must be able to read is the same on every deployment, so
putting it in the overlay would mean every new instance silently starts blind.
And the shape of that file is not "a default plus a few caps" — it is the reverse. Default,
MeshWeaver, Memex, Microsoft, Orleans, Azure, Npgsql and System are all Warning, and
a short, explicit list of categories is raised back to Information. So the Information channel is
an allow-list, and a category that is not on it is invisible — not quiet, invisible, with
nothing to grep and no error anywhere saying so.
Measured on one memex-cloud replica's complete retained log, 2026-09-07, counting <level>: <category> headers:
| what the count showed | reading |
|---|---|
every info: category was one of the allow-listed ones — MeshWeaver.Blazor.Circuit (1352), MeshWeaver.Mesh.CreateNode (961), MeshWeaver.Hosting.DynamicTypePreWarmerHostedService (492), Memex.Portal.Shared.SelfUpdate.* (215), Microsoft.Hosting.Lifetime (6) |
the allow-list IS the Information channel; nothing else reaches stdout at that level |
warn: PlatformBuildInboxWatcher — 871 |
an un-dotted category reaches stdout perfectly well; it just inherits Default and nothing else |
warn: MeshWeaver.GitSync.GitHubSyncService — 6822 |
the MeshWeaver.GitSync namespace reaches stdout too; only the LEVEL was filtering |
Two rules follow, and both were learned the expensive way (Memex#233, whose two silent categories
were StoreManifestSource and MeshWeaver.GitSync.GitHubAppTokenService):
- An un-dotted category inherits
DefaultONLY.ILoggerFactory.CreateLogger("Foo")gets a name with no namespace, so no prefix entry can ever reach it — it isWarninguntil it is named literally. The prefix rule is longest-match, so naming the TYPE (MeshWeaver.GitSync.GitHubAppTokenService) raises exactly that one logger and leaves its chatty siblings where they are; raising the namespace would take all of them. - A
LogInformationan operator is expected to read is not finished until its category is on the list, in the SAME change. The call and the filter are two halves of one decision, and the half that is missing is the one nobody can see. Memex#165 spent four days on three wrong root causes, and every discriminator that finally settled it was a count on a category like these — the Error half was reaching stdout to be counted against, and the Information half was not.
🚨 The class is bigger than the two categories that prompted this. Measured 2026-09-07 over
this repo: 43 distinct un-dotted logger categories are created with
ILoggerFactory.CreateLogger("Name") — BatchBake*, PreWarm*, every *ControlPlane,
PlatformBuildInboxWatcher, Migration, TornSnapshot, StoreManifestSource — and none of
them appeared in the image's Information allow-list. Each inherits Default and nothing else, so
every Information line any of them writes is invisible on a deployed portal today. That is not a
reason to raise all 43: each one owes the volume argument the entries above make, and a blanket
raise is the cost model changing by accident in the other direction. It is a reason to know the
number, and to check yours before assuming a channel exists:
grep -rhon 'CreateLogger("[^".]*")' --include='*.cs' . | sed 's/.*CreateLogger("//; s/")//' | sort -u
An obvious next step, not built: a gate that fails when an un-dotted category with a
LogInformation call is neither on the list nor in a reasoned allow file — the ratchet shape this
repo already uses for record-overlay.allow and friends.
To turn verbosity up for a debugging session, edit the appsettings.json in the test's or host's
bin/ output (reloadOnChange: true flips it mid-run). The committed file is the production cost
model — Information ships to Loki — so a change there is a permanent decision that states its
volume, which is what every comment in that file does.
Secrets: the Key Vault naming rule
The pod reads a secret as the env key Section__Key. Its vault object is named by the fleet rule
<keyVaultSecretPrefix><Section>-<Key> every "__" becomes "-", behind the record's prefix
Email__ClientSecret ↔ memexcloud-Email-ClientSecret (memex-cloud, prefix memexcloud-)
Authentication__Microsoft__ClientSecret ↔ memexsystemorph-Authentication-Microsoft-ClientSecret (memex, prefix memexsystemorph-)
So a record following the rule names each secret once, by key:
"keyVaultSecrets": {
"tenantId": "3a01d7ac-3330-444d-942d-975eb491b5d6",
"identityClientId": "<the Key Vault Secrets Provider add-on's user-assigned identity client id>",
"secrets": [
{ "key": "Email__ClientSecret" },
{ "key": "Authentication__Microsoft__ClientSecret" },
{ "key": "GitHub__App__PrivateKey", "vaultSecret": "github-app-privatekey" }
]
}
vaultName falls back to the record's keyVault; name (the SecretProviderClass) falls back to
memex-portal-keyvault, syncedSecret to that name, volumeName to kv-secrets, mountPath to
/mnt/secrets-store. An object that does not follow the rule — memex's unprefixed legacy
names such as github-app-privatekey or ai-keyprotection-masterkey — states vaultSecret
explicitly. HelmValues.VaultSecretName is the derivation; the rendered file always shows the
resolved name, so "which vault object does this pod read for X" is answered by reading the file.
identityClientId is the identity that reads the vault — the AKS Key Vault Secrets Provider
add-on's user-assigned identity (az aks show -g <rg> -n <cluster> --query addonProfiles.azureKeyvaultSecretsProvider.identity.clientId -o tsv), which needs Key Vault
Secrets User on the vault. It is an identifier, committed on purpose.
Three facts that do not change because the declaration moved into the chart:
- A declared object the vault does not hold fails the whole mount. The new pod stays
ContainerCreating, the old pod keeps serving, the rollout stalls and nobody is paged. Put the value in the vault before the record names it:az keyvault secret set --vault-name Systemorph --name memexsystemorph-Email-ClientSecret --value '…'. - A new or changed vault value reaches env only on a pod restart, and the first restart may read the old synced Secret — the mount of pod A populates the Secret pod B's envFrom reads (DeploymentAKS → "KeyVault CSI env timing"). Verify in the NEWEST pod, by hash, never by value.
- Never
kubectl exec … printenvto check a secret. It prints values into your terminal and into CI logs. Compare NAMES: the record, the renderedkeyVaultSecrets, the SecretProviderClass'ssecretObjects, the Deployment'senvFrom.
Adopting a hand-made SecretProviderClass (memex-cloud's memexcloud-portal-ai-secrets,
memex's memex-kv → memex-kv-secrets): state its live name and syncedSecret in
keyVaultSecrets, list every object it maps, delete the matching secretMounts entry in the same
change (a mount naming the same volume, class or Secret is a named problem), re-render, run
helm-release adopt so helm stamps ownership on the existing objects, then deploy. The pod keeps
reading the same Secret name throughout, so the switch is a metadata change followed by one
ordinary roll.
Why NOTHING may live only on the cluster — 2026-08-30
The memex install (memex.systemorph.com, namespace memex) crashed at boot with
EmailConfigurationGuard: Email:Enabled=true but Email:ClientId unset. Its Email configuration
existed twice on the live pod:
| Copy | Where it came from | What it said |
|---|---|---|
ConfigMap memex-portal-config |
the chart, rendering the chart defaults — the record had no email section |
Email__Enabled: "false", Email__ClientId: "", … |
explicit env on the live Deployment |
a hand-made Secret memex-email-secret, patched onto the pod spec by hand, present in no repo, absent from helm get manifest |
EMAIL__ENABLED, EMAIL__CLIENTID, EMAIL__CLIENTSECRET, … |
.NET's environment configuration provider is case-insensitive, so Email__Enabled and
EMAIL__ENABLED are one key, and which copy won was decided by enumeration order — per process
start. Some boots saw the hand-made Enabled=true and the chart's empty ClientId; the guard
refused, correctly. Nothing in any repository could see either half, because the SecretProviderClass
objects were hand-made too.
This was the third time the same shape bit, not the first:
- 2026-08-23 — the memex-cloud CSI volume, mount and
envFromwere livekubectlpatches; ahelm upgradewould have dropped them and detached every AI provider key (henceextraEnvFrom). - 2026-08-25, MeshWeaver#2210 — the GitHub App identity committed one section off; every deploy
rendered the keys
""and helm applied the blanking as a deletion, fleet-wide. - MeshWeaver#2235 — a hand-set
env:on the live Deployment permanently shadows whatever the ConfigMap renders for the same name, which is whywebhookInboxTargetsstates both slots.
So the rule, with no exceptions and no "just this once":
No
kubectl set env. Nokubectl patchof the Deployment. No hand-made Secret, ConfigMap or SecretProviderClass. A value the pod needs is a record field (or, for a value, a vault object under the rule's name). If the record has no field for it,extraPortalConfigis the temporary home and a typed field is the follow-up — never the cluster.
When someone breaks glass anyway, the Break glass section of Guide.md applies: the half-done operation gets a same-session reconcile — record repo-first, an action node as the audit, the status sample corrected. A live-only change is reverted by the next roll; a record that contradicts the cluster is a lie the fleet board repeats.
How to add a new key
A non-secret key (Section__Key), in dependency order — platform first:
- Chart (Systemorph/MeshWeaver): add the default under
config.memex_portalindeploy/helm/values.yamlAND the line intemplates/memex-portal/config.yaml. Two gates hold this pair together:check-values-are-read.shfails a values key no template reads, andcheck-config-key-coverage.py(run by the Memex repo's Config key coverage workflow) fails an overlay key the chart renders nowhere. - Record (this plugin): a property on the right section in
Deployment/Source/InstanceShape.cs(orDeploymentContent.cs), with[Description]and[Translation("de", …)]— every field is a form label. - Renderer:
HelmValues.PortalConfigemits it; add it toHelmValues.TypedPortalKeysso an extra cannot shadow it; pin it inDeployment/Test/HelmValuesTests.csand register the case inDeploymentTestsArea. Merge the Plugins PR; the registry serves it. - Record change (Systemorph/Memex): set the field on
mesh/Deployments/<instance>.json, re-rendervalues.<release>.public.yaml, PR. The blank-guard inhelm-release deployrefuses a render that would EMPTY a key that is non-empty live — if that fires, the key is mis-nested, not the guard wrong. - Deploy —
requestedAction: HelmRelease,helmAction: deployon the record (or the workflow by hand). Merged is not rolled: values reach a container only when the release rolls.
A secret:
- Put the value in the vault under the rule's name (
az keyvault secret set …). Before step 2, always — a declared object the vault lacks stalls the rollout. - Add
{ "key": "Section__Key" }tokeyVaultSecrets.secretson the record (withvaultSecretonly when the name breaks the rule). Re-render, PR, deploy. - Expect the value in env after the roll; if the first pod read a stale synced Secret, one more restart. Verify by name and by hash in the newest pod.
Until the record has a typed field for a secret's use (e.g. email.enabled for
Email__ClientSecret), declaring the secret alone renders a key nothing reads — harmless, but
turn the feature on in the same change so the pair is reviewed together.
capture, adopt, deploy — what each is for
All three are helm-release.yml in Systemorph/Memex, dispatched from the record
(requestedAction: HelmRelease + helmAction) or by hand; confirm must repeat the release name
for the two that mutate.
| Action | Reads | Writes | Run it when |
|---|---|---|---|
| capture | helm get values <release> on the cluster |
Key Vault Systemorph/helm-values-<release> — the vault half (parameters:, pgbackrest:, secrets:). Never echoed; only its size is logged |
before the FIRST deploy of an environment, and after any out-of-band change to the secret sections. It is a snapshot: a cluster-only patch it captures becomes "intent" until someone removes it from the vault half — which is why the record must render everything the vault half does not own |
| adopt | the render of vault half + committed file | two annotations + one label per rendered resource that already exists live (meta.helm.sh/release-*, app.kubernetes.io/managed-by=Helm); changes no spec, restarts nothing |
before the first deploy over a namespace with hand-made resources, and after the chart starts rendering an object that was hand-made (a SecretProviderClass now declared in keyVaultSecrets). It also lists every rendered resource that does NOT exist live — read that list: a name drift means a twin would coexist, not replace |
| deploy | vault half + committed file + the RUNNING image | helm upgrade --install --wait, then the tag deployed/<ns> on the commit |
to make a merged record change real. Refuses (blank-guard) a render that empties a live ConfigMap key; refuses to report success when helm's exit code cannot be read |
The image is a no-op of a deploy by design: the in-portal self-updater owns that field the rest of
the time (pinnedImageTag blank), and inputs.image is the explicit opt-in to roll.
Where the operator runs
- The control instance is
memex(memex.systemorph.com, namespacememex, helm releasememex, vaultSystemorph, prefixmemexsystemorph-). TheDeploymentsSpace there holds the records, two-way GitSynced toSystemorph/Memex→mesh/Deployments. - Instance actions (
Hosting/InstanceAction: provision, suspend, back up, tear down) run as short-lived Kubernetes Jobs inmemex-opsunder thehosting-operatoridentity, launched by the control instance's portal with the job-runner token the chart mounts when the record'soperator.enabledis true. Off everywhere else, as a security property. The executed runbook and the probe that proves it is live: OperatorEnablement.md. HelmReleaseactions need no operator — they dispatch the config repo's workflow under the fleet's GitHub App credential, which is how the operator itself was enabled.- The names are crossed — the namespace called
memexis systemorph.com and the vault calledSystemorphserves meshweaver.cloud. Confirm against the RECORD (get @Deployments/<name>), never against a name. README.md carries the full table.
What the two records carry today
memex-cloud (mesh/Deployments/memex-cloud.json) already has the email section and a
secretMounts entry pointing at the hand-made memexcloud-portal-ai-secrets. To finish: move the
class into a declaration and drop the mount —
"keyVaultSecrets": {
"tenantId": "3a01d7ac-3330-444d-942d-975eb491b5d6",
"identityClientId": "<csi add-on identity client id>",
"name": "memexcloud-portal-ai-secrets",
"secrets": [ { "key": "Email__ClientSecret" }, "…every object the live class maps, by key…" ]
},
"secretMounts": []
memex (mesh/Deployments/memex.json) had no email section at all — the whole section lived
in the hand-made Secret. It needs the section AND the declaration; the vault object
memexsystemorph-Email-ClientSecret must exist first:
"email": {
"enabled": true,
"clientId": "<the Graph sender app's client id>",
"tenantId": "3a01d7ac-3330-444d-942d-975eb491b5d6",
"mailboxAddress": "<the shared mailbox>",
"useManagedIdentity": false,
"inboundEnabled": true,
"webhookBaseUrl": "https://memex.systemorph.com"
},
"keyVaultSecrets": {
"tenantId": "3a01d7ac-3330-444d-942d-975eb491b5d6",
"identityClientId": "<csi add-on identity client id>",
"name": "memex-kv",
"syncedSecret": "memex-kv-secrets",
"volumeName": "kv-secrets",
"mountPath": "/mnt/kv-secrets",
"secrets": [
{ "key": "Email__ClientSecret" },
{ "key": "ConnectionStrings__memex", "vaultSecret": "memex-connectionstring" },
"…every object the live memex-kv class maps; the unprefixed legacy names state vaultSecret…"
]
},
"secretMounts": []
and, on the cluster, the hand-made memex-email-secret and the explicit EMAIL__* env on the live
Deployment are deleted — the next deploy renders the pod spec from the record, and a kubectl
patch that survives it is the coin toss again. helm-release adopt first (the class memex-kv
becomes helm-owned), then deploy.