Repository navigation
Per-environment content composition: what each environment pre-installs, expressed as feature flags, with required parameters routed through service discovery #1735
Description
Maintainer-directed. Three asks, one mechanism:
- Configure what is pre-installed from ANY repo, per environment, in config.
- Bake it into feature flags, and expose the flags by environment.
- When a package needs additional parameters (a connection string, an endpoint), obtain them through something Aspire-like that can route to other services — rather than each package inventing its own config lookup.
What already exists (do not reinvent)
PluginCatalogOptions.InstallByDefault— a list inSource/Packagenotation (["Plugins/*"],["Plugins/Store","Plugins/Essentials"]). Already source-scoped, i.e. already "from any repo", and deliberately fails closed when a registry does not stamp the source. But it SEEDS ONCE, on an installation with no install records: it is not a policy that re-asserts, so it cannot express "this environment always has X".PluginCatalogOptions.InstallPreInstalledPackages— the platform baseline, reconciled on EVERY boot, cheap when up to date (content-identity gate → one catalog listing, no writes), and the only thing that heals a lost baseline (memex: platform Agents catalog is GONE — 'Agent' is a ghost partition root (exists to create, 404s to read, no version history); preInstalled is dead code #902).PluginCatalogOptions.Registries— which registries an instance consumes, each with its own token.Memex.Client.Services.FeatureFlags— device-persisted MAUIPreferencesflags. Client-only by design (noIConfigurationon-device). There is NO server-side feature-flag concept today.
So the gap is not "can we list packages per environment" — it is that the knob has seed-once semantics, there is no flag surface, and nothing declares or supplies a package's required parameters.
Design constraints
Environment dimension. The real environments are the AKS namespaces (memex, memex-cloud, client-i), Aspire/Container-Apps modes, and local dev. Config reaches them through appsettings + env vars via helm today. Whatever is designed must be settable per environment WITHOUT a rebuild, and must not require hand-editing a live portal.
Seed-once vs reconciled is a real semantic choice, not an oversight. InstallByDefault seeds so an operator can later uninstall something without it coming back. A per-environment composition policy wants the opposite: it should re-assert. Do not silently change the existing knob's meaning — introduce the reconciled shape explicitly and say how the two relate (the existing doc comments spell out the reasoning; keep them true).
Flags must be observable, not just readable. Everything in this platform is IObservable<T> end-to-end; a flag surface that only answers bool IsEnabled(...) synchronously will be wrong the moment config reloads. Bind it the way the rest of the platform binds state.
Parameters (ask 3) fail closed and name what is missing. This is the same discipline the CI preflight uses: a package that requires a connection string must DECLARE that requirement (manifest-level), and an environment that does not supply it must fail loudly at install/provision time, naming exactly what to provision — never install half-configured and never silently skip. A skipped install that reads as success is the exact shape AGENTS.md forbids in gates.
Routing to other services. Aspire already models this for the local/Container-Apps route (memex/aspire/): resources declare references and the AppHost wires connection strings. The design should let a package's declared parameter resolve from the environment's service graph rather than from a package-specific config key, so "route to another service" is expressible. Note a live example of why this matters: the Cosmos and Snowflake storage backends are referenced only by their own tests and ship in no publish lane, so the documented Graph:Storage:Type=Cosmos path cannot actually be turned on in a shipped image — a storage backend needing a connection string is precisely case 3.
Exposure. "Expose by environment" needs a decision on audience: an admin-visible surface in the portal (which environment has which flags, and why a package did or did not install) versus a machine-readable endpoint. Whatever is chosen, it is a layout area built from framework controls — no hand-built HTML, no RenderHtml-shaped helper — and every user-visible string is localized (en + de, mirrored into clients/react/src/i18n/).
Deliverable
A short design note in the issue first (shapes + names + how the three asks compose), then the implementation. Not a big-bang: the composition policy and the flag surface are useful before parameter routing exists.
Related: PluginCatalogOptions, InstanceAutoRegistrationService (the default-install path), Doc/Architecture/Modules, Doc/Architecture/PluginPackaging, #1660 (module distribution), #902 (why the baseline reconciles).
Activity
rbuergi commentedon Aug 17, 2026
Ask 3 context, from actually standing a portal up on Cosmos — the case this issue cites. Full findings in Systemorph/MeshWeaver.Plugins#1140; the parts that bear on parameter routing:
One premise here is now stale. "The Cosmos and Snowflake storage backends … ship in no publish lane" was fixed by 447c1b8: both joined the CLOSURE lane in
MeshModulesPublish.targets, so the DLLs reach the image. What remains true, and is the better framing for this issue, is that no deployment lists them inModules:Assemblies— the bits ship, activation is unexercised everywhere.The parameter surface, measured. Cosmos needs four things, of which exactly one is reachable from configuration:
Graph:Storage:ConnectionStringGraph:Storage:DatabaseNameexists inGraphStorageConfigand is silently ignored — the factory readsCosmosStorageOptions.DatabaseName, pinned to"MeshWeaver"CosmosStorageOptionsis bound to no configuration section — there is no.BindConfiguration(...)anywhere, and its only registration site passesconfigure: null, soIOptions<CosmosStorageOptions>.Valueis always all-defaults. Its doc comment namesConnectionStrings:memexcosmosas the intended source; nothing implements that. Snowflake documents the same intent and likewise never performs it.This is the failure shape the issue says to avoid. An operator setting
Graph:Storage:DatabaseNamegets no error — they get the default database, silently. That is worse than the "skipped install that reads as success" case, because there is no install step to inspect. It argues for the declare-and-fail-loudly requirement being enforced at the options-binding level, not only at install/provision time: a backend should declare its parameters, and an unbound-but-supplied key should be a hard error rather than a default.Service-graph routing has nothing to bind to yet. The Aspire AppHost models no Cosmos resource (
grep -i cosmos memex/aspire/→ nothing). PostgreSQL is wired asAddPostgres(...).AddDatabase("memex")→ConnectionStrings__memex. So "route a package's declared parameter from the environment's service graph" would, for the first real case, require adding the resource to the AppHost as well as the declaration mechanism — worth scoping in, since Cosmos is the example this issue leads with.One nuance for the design: the Cosmos factory already prefers Aspire-injected keyed
Containerservices over any connection string, and falls back only if they are absent. That is closer to "resolve from the service graph" than a config key is, and it is the branch I used to get a portal booting. A parameter-routing design that can express "this package wants a resource handle, not a string" would fit what the code already does.I have not implemented anything from this issue — Systemorph/MeshWeaver.Plugins#1140 carries only the fixes proven by a test (a startup-throwing cast, and a frozen change feed).
rbuergi commentedon Aug 17, 2026
Design note, part 2 — the acceptance criterion, and the one shape change it forced
The allow-list I sketched in part 1 cannot say that without re-typing 17 package names, so the flag
gained a direction, decided by
Enabled:signal — the platform's own
preInstalledbaseline included.That makes the maintainer's sentence literal, and — the part that matters operationally — it makes
the per-environment delta one line on top of one shared declaration:
memex-cloud(= "memex") adds nothing — both flags are declared, so both are on. RolePlay is avalues decision: put it in
games, in its own flag, or leave it alone. No package name appearsanywhere in platform code, and the exclusion is source-scoped through the same
PluginGrantEntrymatcher as everything else, so
Plugins/ChessandEducation/Chessare different packages.Allow-list vs exclusion — the tradeoff, stated
They fail differently and neither is universally right:
I did not pick one; the flag expresses whichever the operator means, and I removed the silence
instead:
exclusion direction called out specifically, because that one fails open (the packages it
meant to keep out get installed).
For this case the wildcard-plus-exclusion reading is also the safer one:
Plugins/*is already whatpluginCatalog.installByDefaultsays today, so a new plugin appearing is the status quo, whereas a17-entry allow-list would silently withhold every future plugin from both portals.
Why this is a new lane and not the old knob
Confirmed as stated:
InstallByDefaultis ledger-gated and re-asserts never, so on either liveportal — both long-populated — setting it is a no-op. Its seed-once meaning is deliberate (an admin
who uninstalls something must not be fought by the next restart) and is left exactly as is,
comments included. The flag lane is separate and reconciled on every boot; a reconciled candidate
is exempt from the seed ledger, which is precisely the difference between a policy and a seed. There
is a test that populates an instance, then re-runs the production decision and requires the same
composition to be re-asserted with zero writes.
Delivery path
Everything is settable through the values path the private repo already uses. The chart's ConfigMap
enumerates keys by hand — an un-templated key is silently dropped — so this PR adds the
rangeblockthat renders
Features__Flags__{name}__{Enabled,Description,Packages__N}from afeatures:map, andfeatures: {}plus a worked example todeploy/helm/values.yaml. Verified withhelm template.While there:
PluginCatalog__InstallPreInstalledPackageswas itself never templated, so thedocumented opt-out could not be exercised per environment at all. It is now rendered when set.
I have not touched
Systemorph/Memex. Landing the maintainer's case needs thefeatures:blockabove added to
deployments/aks/{memex,memex-cloud}/values.*.yaml(and the secret-free publicmirror the chart-drift gate renders against) — happy to hand over the exact snippet, but that repo
is yours to change.
One correction to part 1
The Cosmos/Snowflake example is stale:
447c1b879put both on theMeshModuleClosurepublish lane,so they do ship in the image now. The point survives in a sharper form — their only actual
connection-string channel is
Graph:Storage:ConnectionString, whileCosmosStorageOptionsdocumentsa
ConnectionStrings:memexcosmosconvention that nothing reads. That is exactly thepackage-invents-its-own-lookup shape ask 3 replaces, with one of the two inventions dead on arrival.
rbuergi commentedon Aug 17, 2026
Naming hazard for this work, found while auditing all 43 Store packages (#1749).
If per-environment composition is going to name packages in config, two collisions need a decision first:
DataModellingDataModelingSSTSwissSolvencyTestDataModeling/DataModellingis the dangerous one: byte-identical display name, and paths differing by a singlel. A config typo silently selects a 900 CHF course instead of a free pre-installed tool, or vice versa — and both are legitimate live packages, so nothing would error.They are not duplicates and nothing should be deleted. Suggested resolution is display-name disambiguation only (e.g.
DataModelling→ "Data Model Browser",DataModeling→ "Data Modelling (Course)"), keeping both paths — renaming paths would break installs and cross-links.Flagging rather than acting; it needs a maintainer call.
rbuergi commentedon Oct 11, 2026
(re-posted 2026-10-11; originally by @rbuergi on 2026-08-17)
Design note — per-environment composition as feature flags, with service-referenced parameters
Survey first, then shapes. Two premises in the issue turned out to be wrong; I say so rather than
building on them, because both change the design.
0. Two falsified premises
(a) "There is NO server-side feature-flag concept today." There is — and it is already the
deploy-time one.
memex/Memex.Portal.Shared/MemexFeatureOptions.csbinds theFeaturessection and is documented as Feature Flags:
Features:Ai:Providers:OpenAI,Features:Onboarding:InvitationOnly,Features:StaticRepoSync:Partitions,Features:Orleans:Clustering,Features:SignalR. It binds inMemexConfiguration.ConfigureMemexServicesand is read throughIOptions<T>; the Helm chartalready renders
Features__StaticRepoSync__Partitions__{i}per environment.So the gap is narrower and more precise than "no flags":
platform does not already know about, which is exactly what "pre-install from any repo,
per environment" needs;
IOptions<T>is a startup snapshot, andPluginCatalogOptionsis aneven flatter one (
configuration.GetSection(…).Get<PluginCatalogOptions>()registered as a plainsingleton), so nothing reacts to a reload even though
MemexConfigurationopens its JSON withreloadOnChange: true;(b) "Cosmos and Snowflake … ship in no publish lane." Not any more —
447c1b879put both on theMeshModuleClosurelane inmemex/MeshModulesPublish.targets, so the DLLs are in the image today.The case still supports ask 3, but for a sharper reason: their only connection-string channel is
Graph:Storage:ConnectionString.CosmosStorageOptionsdocuments aConnectionStrings:memexcosmosconvention that nothing reads, and neither
CosmosStorageOptionsnorSnowflakeStorageOptionsis ever bound from configuration. That is precisely "each package inventing its own config lookup",
and one of the two inventions is dead on arrival.
Two more survey facts that shape the design:
deploy/setsASPNETCORE_ENVIRONMENT, somemex,memex-cloudandclient-iall run asProductionand load byte-identical appsettings.Per-environment differentiation is env vars only, from a values file that is gitignored and
lives in the private
Systemorph/Memexrepo (deploy/aks/envs/.gitignore).deploy/helm/templates/memex-portal/config.yaml) — anun-templated key is silently dropped, a trap the chart's own comments record three times. Today
PluginCatalog__InstallPreInstalledPackagesis one of the dropped ones: the existing opt-outcannot be set per environment at all.
The MAUI
FeatureFlagsis a third vocabulary (device-localPreferences, compile-time defaults,bool IsEnabled) and is deliberately left alone — it has noIConfigurationhost and its concernsare per-device, not per-environment. The server side extends
Features; it does not mint a fourthvocabulary.
1. Asks 1 + 2 — the flag IS the composition unit:
Features:Flags:{name}Environment-variable form — which is what actually reaches AKS and ACA:
Packagesreuses the existingSource/Packagenotation and the existingPluginGrantEntrymatcher against
PackageManifest.Source— so it is already "from any repo" and already failsclosed when a registry does not stamp the source. No second matching rule, no second parser.
Enabled: falseturns a flag off without deleting the declaration, whichis what makes "shared base values file + per-environment override" work under helm's last-wins
layering. Undeclared ⇒ disabled.
Packagesis a plain named boolean — the extension point the fixed record cannotoffer.
Features:Environments:{name}map, deliberately: the portal cannot tell which environmentit is (see above), so a name-keyed map would silently select the wrong branch. Each environment
sets its own flags. That is what "per environment" can mean here.
Reconciled — and NOT the same knob as
InstallByDefault. Three lanes, three different questions,all feeding the one ordered install in
InstanceAutoRegistrationService.InstallDefaults:preInstalledon the manifestFeatures:Flags:{name}:Packages(new)PluginCatalog:InstallByDefaultInstallByDefault's semantics, ledger and doc comments are untouched; its XML doc gains oneparagraph pointing at the reconciled sibling so the contrast is stated where someone reads it. The
new lane joins the selection beside the
preInstalledpredicate inCandidates(…), inheritingthe whole existing machinery for free: source-scoped matching, the dependency closure, the
commercial-package refusal, the tolerant dependency order, sequential install, and the
content-identity gate that keeps a reconciled boot at one catalog listing and zero writes.
Disabling a flag does not uninstall. Turning
storeoff stops the environment asserting it; itdoes not delete a partition full of user content, and an unattended uninstall racing per-node hubs on
boot is not a thing to do by default. The admin surface shows "declared, not asserted", so the state
is visible rather than silent. A later explicit
Remove: truecan add the destructive half whensomebody actually wants it.
2. How a flag is observed —
IObservable<T>, notbool IsEnabledsrc/MeshWeaver.Hosting/FeatureFlags/:ConfigurationFeatureFlagsis a mesh-scoped singleton (dies with the mesh — no static cache, noClear()), holding aBehaviorSubjecton an instance field, re-read byChangeToken.OnChange(config.GetReloadToken, …). That is a push from the configuration provider —no timer, no poller, no watchdog.
IsEnabledisAll.Select(…).DistinctUntilChanged(), so a liveview binds it directly. Nothing async, no
Task<T>, noSemaphoreSlim.3. Ask 3 — declared parameters, resolved through Aspire-style service references
Declare, on the manifest (
PackageManifest.Parameters), authored exactly the waypreInstalled/
publicSegments/modulealready are — in the node-repo root'scontent, or in apackage.json:Optionaldefaults tofalse— required is the default, and the CLR-defaultfalseround-tripsloss-free under the default-suppressing serializer (the declared-
truebool trap already diagnosedon this codebase).
Resolve from the environment's SERVICE GRAPH, not a package-specific key. The resolver reads
exactly the keys the two routes already inject:
ConnectionStringConnectionStrings:{name}WithReference(db)→ConnectionStrings__memex; AKS: chart secret / Key Vault CSIEndpointServices:{name}:{https|http|default}:0WithReference(project)→services__{name}__https__0— theMicrosoft.Extensions.ServiceDiscoveryshape, and that package is already referenced andAddServiceDiscovery()already called inMemex.Portal.ServiceDefaultsValueParameters:{name}AddParameter/ plain envSo a package says "I need the
warehouseconnection" and the environment decides whether thatis an Aspire resource reference, a Helm value, or a Key Vault secret. That is the "route to other
services" the issue asks for, expressed once instead of per package.
It fails CLOSED, at the one funnel, naming exactly what to provision.
CatalogLayoutAreas.InstallOrUpdateis the single orchestrator every lane goes through (boot defaultinstall, the Store's Provision click, the auto-update reconciler) and already carries one fail-closed
gate —
PackageEntitlement.Authorize, which faults the observable with a speaking exception. Theparameter gate sits immediately beside it and faults with
PackageParameterException:Never a half-configured install, never a silent skip: the unattended lane counts it
Failedinthe
DefaultInstallSummary(logged at Error, surfaced), the same discipline as the CI preflight — agate that reports green on no evidence is worse than a red one. It also closes the asymmetry the
survey found: today
ConnectionStrings:orleansmissing gets a purpose-written throw naming what toset, while
ConnectionStrings:memexmissing surfaces only as an Aspire-internal exception at firstresolution.
4. Exposure — audience: the platform ADMIN, in the portal
A global-admin settings tab (Administration → Composition), the same shape as the existing
InstanceGrantAdminSettingsTab:Controls.DataGridover a plain row record withPropertyColumnControls — flag, enabled, packages, and the outcome (installed / up to date /declared-not-asserted / refused:
ConnectionStrings__warehousemissing). No hand-built HTML, noRenderHtml-shaped helper. Every string is a key instrings.{en,de}.json, mirrored intoclients/react/src/i18n/.A machine-readable endpoint is deliberately not added: "why did this package install here" is a
human question, the tab answers it, and the registry API already exists for anything a machine needs.
5. Slicing
One PR: flag surface + reconciled composition lane + parameter declaration/resolution/gate + admin
tab + chart templating + docs + tests. The parameter half is small precisely because it rides the
existing funnel and copies the existing entitlement-gate shape; splitting it would land a manifest
field nobody reads — the dead-metadata defect class this repo has already hit three times
(
preInstalled,publicSegments,contactEmail).The chart change is mandatory, not cosmetic: an un-templated key is silently dropped, so
Features__Flags__*gets arangeblock — and while there,PluginCatalog__InstallPreInstalledPackagesgets templated too, since today the documented opt-out cannot be set per environment at all.
6. Decisions worth confirming
Features:Flagsrather than a new top-level section — one operator vocabulary, at the cost ofthe
Featuressection having two binders (the typedMemexFeatureOptionsrecord, which ignoresunknown keys, and the dynamic flag reader).
Enabledrequired) is more explicit but doubles the lengthof the common per-environment line.
InstallByDefault, which is untouched. Migrating the AKS values frompluginCatalog.installByDefault: ["Plugins/*"]to apluginsflag is an operator decision in theprivate repo, not part of this change.