Environment Composition

Two portals run the same image and must not carry the same content. memex.meshweaver.cloud lists all of Plugins; memex.systemorph.com lists the same, without the games and fun stuff. This page is how that is said — once, in each environment's own values file, with no rebuild and no hand-patching of a live cluster.

The flag IS the composition unit

A feature flag is a named switch an environment turns on or off, optionally carrying the packages that environment ALWAYS has. It is declared under Features:Flags:{name} — the same Features section an operator already edits per environment (see Feature Flags for the fixed capability toggles that live beside it).

"Features": {
  "Flags": {
    "plugins":  { "Packages": ["Plugins/*"], "Description": "The whole platform plugin repo." },
    "games":    { "Packages": ["Plugins/Chess", "Plugins/ThreeBody"] },
    "betaChat": true
  }
}

Environment-variable form, which is what actually reaches a pod:

Features__Flags__plugins__Packages__0=Plugins/*
Features__Flags__games__Enabled=false
Features__Flags__betaChat=true
Rule
Declared ⇒ on Declaring the flag in an environment's values IS the opt-in. Enabled: false switches it off without deleting the declaration, which is what makes a shared base file plus a one-line per-environment override work under helm's last-wins layering.
Undeclared ⇒ off IsEnabled on a flag nobody declared is false.
An ENABLED flag installs its packages Reconciled on every boot.
A declared-but-DISABLED flag EXCLUDES its packages And the exclusion wins over every other selection signal, the platform's own preInstalled baseline included.
A non-boolean Enabled is not consent yes / 1 / on read as OFF and are named at Warning. These flags install content.
Flag names are matched case-insensitively Configuration keys are, and env-var delivery mangles casing.

Packages are named in the Source/Package notation the plugin grants and PluginCatalog:InstallByDefault already use (Plugins/*, Reinsurance/UWDeepfield), matched by the same source-scoped PluginGrantEntry. There is no second matching rule — and no package name appears anywhere in platform code.

The two portals, in full

One shared declaration; the environment that does not want the games flips a single key. 🚨 The Kubernetes namespace names invert the host names — check the row, not your memory:

namespace host called its values file adds
memex-cloud memex.meshweaver.cloud memex (nothing — both flags declared, both on)
memex memex.systemorph.com systemorph features.games.enabled: false
# shared
features:
  plugins:
    description: "The whole platform plugin repo."
    packages: ["Plugins/*"]
  games:
    description: "Games and demos."
    packages: ["Plugins/Chess", "Plugins/DoublePendulum", "Plugins/FractalStars", "Plugins/ThreeBody"]

# values.<systemorph>.yaml — the ONE line that differs
features:
  games:
    enabled: false

Whether a borderline package (RolePlay — arguably serious training) belongs to games is a values decision, made where the flag is declared. Nothing in the platform knows any package's name.

Allow-list or exclusion? Both, and neither is silent

They fail differently, and the difference matters:

So a flag expresses whichever the operator means, and the ambiguity is removed by making both visible: the Composition admin tab names exactly which flag decided each package, the boot log names the composition it installed, and an exclusion naming a source this installation does not have is reported at Error — that one fails open (the packages it meant to keep out are installed), which is the more dangerous direction.

Three lanes, and why they are different knobs

The boot pass (InstanceAutoRegistrationService) folds three selection signals into one ordered install:

lane declared by when why
preInstalled on the package manifest the package author every boot the platform's own baseline (the Agents and Skills libraries, Essentials). Suppressed by PluginCatalog:InstallPreInstalledPackages=false. It is what heals an instance whose baseline partition was lost.
Features:Flags:{name}:Packages the environment every boot "this environment always has X".
PluginCatalog:InstallByDefault the operator once, ledger-gated seeds a fresh deployment.

🚨 InstallByDefault cannot express a per-environment policy, and that is by design, not an oversight. It seeds — the ledger records what it has delivered and it never re-asserts — so an admin who later uninstalls a package is not fought by the next restart. The consequence is that on an already-populated portal (both of ours) setting it changes nothing at all. A composition policy wants the opposite, so it is a separate lane with reconciled semantics; the seed's meaning is untouched, and the two coexist.

Reconciling costs an up-to-date instance nothing: the content-identity gate in CatalogLayoutAreas.InstallOrUpdate turns the pass into one catalog listing and no writes.

Disabling a flag does not uninstall. It stops the environment asserting the packages, and (via the exclusion rule) keeps them out of every future selection. It does not delete a partition full of user content — an unattended uninstall racing per-node hubs on boot is not a default anybody wants. Removing content that is already there stays a deliberate act on the catalog surface.

An exclusion is applied last, after the dependency closure, so it also removes a package the closure pulled back in as somebody's requirement — the operator's explicit statement outranks an inferred edge. That case is named at Warning, because whatever required it will now fail at use.

Reading a flag: IFeatureFlags, reactive

var flags = hub.ServiceProvider.GetRequiredService<IFeatureFlags>();

flags.IsEnabled("betaChat").Subscribe(on => …);   // re-emits when configuration reloads
flags.All.Select(all => …);                       // every declared flag, for a view to bind
flags.Composition.Subscribe(c => …);              // what this environment includes / excludes

🚨 There is deliberately no synchronous bool IsEnabled(string). Configuration is layered and reloadable (MemexConfiguration opens its JSON with reloadOnChange: true), so a sampled value is stale the moment a provider reloads — and a synchronous reader is indistinguishable from a correct one at the moment it is first called. ConfigurationFeatureFlags is a mesh-scoped singleton holding its state on an instance field, re-read by a ChangeToken.OnChange push from the configuration provider: no timer, no poller, no watchdog.

Package parameters — routed from the environment's service graph

A package declares what it needs; the environment decides where it comes from. That is the difference between a package naming a service and a package inventing a config key — the live counter-example being the Cosmos storage backend, which documents a ConnectionStrings:memexcosmos convention that nothing reads, leaving Graph:Storage:ConnectionString as its only real channel.

Declared on the package root's own content, beside preInstalled and module:

"parameters": [
  { "name": "warehouse", "kind": "ConnectionString",
    "description": "The Snowflake warehouse this pack reads." },
  { "name": "crm",    "kind": "Endpoint" },
  { "name": "apiKey", "kind": "Value", "optional": true }
]
kind resolves from who injects it
ConnectionString ConnectionStrings:{name} Aspire WithReference(db)ConnectionStrings__memex; on AKS the chart secret or the Key Vault CSI mount
Endpoint Services:{name}:{https\|http\|default}:0 Aspire WithReference(project)services__{name}__https__0 — the Microsoft.Extensions.ServiceDiscovery shape, already registered in Memex.Portal.ServiceDefaults
Value Parameters:{name} an Aspire AddParameter, or a plain env var

Service overrides Name when a package's own vocabulary differs from the deployment's. Optional defaults to false — required, so the gate is closed for anything an author did not deliberately open (and the CLR-default false round-trips loss-free under the default-suppressing serializer).

It fails closed, and it names what to provision

The gate sits on CatalogLayoutAreas.InstallOrUpdate, the single orchestrator every install lane funnels through — the boot default install, the Store's Provision click, the auto-update reconciler — beside the entitlement gate. A missing required parameter faults the install:

Package 'finance-pack' requires 1 parameter(s) this environment does not supply:
  warehouse (ConnectionString) — The Snowflake warehouse this pack reads.
    provision: ConnectionStrings__warehouse
Nothing was installed.

The unattended lane counts it Failed in the DefaultInstallSummary and logs at Error. It is never installed half-configured (content that errors at first use with nothing pointing back at the missing key) and never silently skipped — a skip that reads as success makes "the gate never ran" and "the gate passed" indistinguishable, which is the trapdoor shape AGENTS.md forbids in gates. A blank value is not a supplied one: the chart renders empty strings for unset keys.

Where the configuration actually comes from

Per-environment configuration reaches an AKS pod as environment variables only: nothing sets ASPNETCORE_ENVIRONMENT, so all three portals run as Production and load byte-identical appsettings. The values files live outside this repo (deploy/aks/envs/.gitignore), in the private deployment repo.

🚨 The chart's ConfigMap enumerates every key by hand — an un-templated key is silently dropped. deploy/helm/templates/memex-portal/config.yaml renders Features__Flags__* from a features: map in values, and hand-patching a live deployment does not stick: the next helm upgrade reverts it. Composition is therefore expressed in the values file, and the Composition admin tab is deliberately read-only.

Seeing it: the Composition tab

Settings → Administration → Composition (platform admins only) shows two tables:

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