Story: Collapse NATS config passthrough to a single environment-mapper fallback tier
Table of Contents
This page documents a story in Sprint 25. It captures the goal, current status, acceptance criteria, and the tasks that compose it.
Goal
process_supervisor::build_args() forwards controller config to spawned
services via explicit CLI-arg template substitution, not environment
inheritance – and compass_services.py's own hand-built launch args for
the controller and Qt client have the identical problem in a second,
independent place. Fixing ORES_NATS_WIRE_FORMAT took two separate
one-line additions (one in C++'s default_args_template, one in Python's
controller launch args) because both layers manually re-list every NATS
config value instead of letting each service parse .env=/CLI/its own
environment the same way every service's own =boost::program_options
config layer already does. The services (and the controller/compass
launcher) should read .env variables directly wherever the value is a
pure passthrough with no controller-side transformation, instead of this
two-tier manual re-threading through explicit argv templates at both the
Python launcher and the C++ supervisor.
NATS is the first offender, but the fix must not be NATS-specific: the
mechanism is a generic shared-domain fallback tier in
environment_mapper_factory – any config module can register itself as
a shared domain (by prefix) once, and every option it owns then reaches
every service via .env alone. NATS registers as the first domain;
adding a second shared domain later (telemetry, logging, …) should
require no changes to environment_mapper_factory itself.
Status
| Field | Value |
|---|---|
| State | STARTED |
| Parent sprint | Sprint 25 |
| Now | Not yet started. |
| Waiting on | Nothing. |
| Next | Break the story into tasks. |
| Last touched | 2026-08-04 |
Acceptance
Tasks
| Task | State | Start | End | Description |
|---|---|---|---|---|
| Add a generic shared-domain fallback tier to environment_mapper_factory | DONE | 2026-08-04 | 2026-08-04 | Extend environment_mapper_factory with a generic shared-domain fallback tier (config modules register a domain prefix, e.g. NATS; matching is prefix-strip-and-kebab-case, no hardcoded variable names, no NATS-specific code in the mapper itself) so any option a registered shared domain owns reaches every service via .env alone, with zero argv threading. |
| Strip redundant CLI-arg threading for shared NATS knobs | ABANDONED | Abandoned and split into four separately-reviewable tasks below (merge_service_registry_deployment_model, wire_systemd_generate_to_codegen_model, decommission_legacy_controller_schema, rename_controller_named_env_vars) once implementation revealed the SQL-backed service-definitions table this task targeted is itself legacy infrastructure that needed decommissioning, not patching. | ||
| Document the single-source-of-truth NATS config flow and prove it with a regression test | DONE | 2026-08-07 | 2026-08-07 | Update doc/knowledge/architecture/nats_wire_format.org (and any other doc describing the old five-layer threading) to describe the new flow: shared NATS knobs set once in .env, picked up by every service via environment_mapper_factory's shared-prefix fallback tier, with no argv threading required. Add a regression test/test_scenario that adds a new pure-passthrough ORES_NATS_* knob and proves it reaches a running service by touching .env alone, with no code changes to process_supervisor, SQL args_template rows, or systemd_generate.py. |
| Consolidate duplicated common/logging/database/nats parser boilerplate across domain services | DONE | 2026-08-04 | 2026-08-04 | The ~17 domain-service config/parser.cpp files (iam, refdata, dq, assets, marketdata, ore, reporting, scheduler, synthetic, telemetry, trading, variability, workflow, analytics, compute.service, workspace, and similar) are one-time codegen scaffolds from cpp_service_config_parser.cpp.mustache that were then hand-maintained – they are ~95% identical, differing only in service name/namespace/app-specific options. Add a shared ores.service::config::standard_service_options helper that composes common_configuration + logging_configuration + database_configuration + nats_configuration into one options_description and does command-line + environment parsing + help/version handling in one call, returning the four already-read sub-options plus the raw variables_map for app-specific extras. Migrate the domain-service parsers to call it, and update cpp_service_config_parser.cpp.mustache so future scaffolds use it too. This also becomes the single call site for nats_configuration::register_shared_domain(), replacing the one-line-per-file calls added by the mapper fallback-tier task. |
| Merge service deployment config into service_registry.org as a new aspect | DONE | 2026-08-05 | 2026-08-05 | service_registry.org currently models only DB-access aspects per service (psql_var, env_key, iam_role, DML/select/execute grant prefixes), keyed by short names (iam, refdata, …). Extend it in place to also carry a deployment aspect per service (replicas, enabled, description, depends_on, and an optional Extra args list of genuinely per-service CLI flags), one heading per fleet process the systemd generator needs to know about. Rename headings to full binary names (ores.iam.service, not iam) since that is the join key compass systemd generate actually needs (TLS cert/key filenames, unit basenames). Add the 3 processes that have no DB role (ores.http.server, ores.wt.service, ores.compute.wrapper) as deployment-only entries (no psql_var/iam_role/etc.), and drop the stale controller entry (no binary exists for it anymore). Extend org_loader.py's load_org_service_registry_model to parse and return the new deployment fields alongside the existing DB-grant fields. This is a pure model+parser change – no consumer (systemd_generate.py, the SQL grant generator) is touched in this task, so it is safely reviewable in isolation as long as both aspects round-trip correctly. |
| Wire systemd generate to the codegen model and strip redundant NATS CLI threading | DONE | 2026-08-05 | 2026-08-05 | Once service_registry.org carries the deployment aspect, migrate systemd_generate.py's fetch_service_definitions/fetch_dependencies from live psql queries against ores_controller_service_definitions_tbl/ores_controller_service_dependencies_tbl to loading the org model directly (import codegen.org_loader, no DB round-trip). Then strip the now-redundant –nats-url/–nats-subject-prefix/–nats-wire-format CLI threading from DEFAULT_ARGS_TEMPLATE and every service's Extra args (services already read these from .env via the shared-domain fallback tier from the mapper fallback-tier task) – this was task 3's original goal. Also strip compass shell's flag_for CLI threading for the same three flags (shell's own parser already reads its ORES_SHELL_NATS_* mirror directly). Keep the whole nats-tls-* trio CLI-supplied, out of scope per the mapper task's Decisions. |
| Decommission the legacy controller SQL schema | DONE | 2026-08-07 | 2026-08-07 | Once systemd_generate.py no longer reads ores_controller_service_definitions_tbl/ores_controller_service_dependencies_tbl, delete the entire legacy controller schema: the 5 tables (service_definitions, service_dependencies, service_instances, service_events, rls_policies) and their create/drop/populate SQL files under projects/ores.sql/{create,drop,populate}/controller/, the controller_service IAM role/grants in the (now-merged) service registry model and iam_service_db_grants_create.sql, any controller RLS policy wiring in rls_create.sql/rls_drop.sql, and their includes from create.sql/drop.sql. Also check foundation_populate.sql for any controller-specific seeding. This is a from-scratch decommission with no backwards-compat shims: the controller binary/process_supervisor.cpp is already gone from the codebase, so nothing should reference it anymore. |
| Rename still-functionally-used ORES_CONTROLLER_SERVICE_* env vars | BACKLOG | ORES_CONTROLLER_SERVICE_WT_PORT and ORES_CONTROLLER_SERVICE_HTTP_PORT are still genuinely read by systemd_generate.py's {wt_port}/{http_port} substitution for ores.wt.service/ores.http.server, despite the controller process itself no longer existing – the names are pure legacy mislabeling now that decommissioning (prior task) has removed everything else controller-related. Rename them to something that describes what they actually are (e.g. ORES_WT_SERVICE_PORT, reusing the existing ORES_HTTP_PORT for the HTTP one if it is already redundant – check env_init.py for both) in env_init.py's generated .env, bump the .env-format version per the existing convention, and update systemd_generate.py's substitution to match. | ||
| Remove controller from db recreate required-vars check | DONE | 2026-08-05 | 2026-08-05 | compass db recreate fails on a fresh environment with 'missing required environment variables: ORES_CONTROLLER_SERVICE_DB_USER, ORES_CONTROLLER_SERVICE_DB_PASSWORD' because projects/ores.sql/service_vars.sh (generated) still lists controller in SERVICE_NAMES, so compass_db.py cmd_recreate demands DB credentials for it — but env_init.py no longer generates controller DB creds since the service was decommissioned. Fix: stop emitting controller from the SERVICE_NAMES source (the generator that writes service_vars.sh — likely the service registry codegen model) and/or exclude decommissioned services from the required-vars loop in cmd_recreate. Do not touch ORES_CONTROLLER_SERVICE_{HTTP,WT}_PORT (still read by systemd_generate.py — covered by existing task 'Rename still-functionally-used ORES_CONTROLLER_SERVICE_* env vars'). |
Decisions
- The fallback mechanism is a registry, not a hardcoded prefix list:
shared_domain_registry(new ores.utility component) exposesregister_domain(prefix)=/=domains();environment_mapper_factoryconsults it generically and never names any specific domain (NATS or otherwise). Registration is an explicit call from each owning config module (nats_configuration::register_shared_domain()), not a global static constructor, to avoid static-init-order fragility. - Per-service file paths (
nats-tls-cert=/=nats-tls-key) stay CLI-supplied – they are genuinely per-service, not shared values, so they are intentionally excluded from the shared-domain fallback. - The 20
register_shared_domain()call sites added to existing parser.cpp files are provisional: the "Consolidate duplicated common/logging/database/nats parser boilerplate" task collapses them into a single call site inside a new shared ores.service parsing helper. - Controller decommission is removed from the
db recreatepipeline strictly at the boundary the pipeline controls:service_vars.shwas regenerated from the service registry model (never hand-edited — it was a stale generated artefact), and only the SQL that referenced the now-unpassedcontroller_service_user/=_passwordpsql variables was removed. The legacy tables, RLS policies, and ControllerService IAM rows still run and remain for the dedicated decommission task; scope was not stolen. - The single-source-of-truth flow is documented as a dedicated "Config
flow" section in
nats_wire_format.orgrather than a new doc: the shared knobs are NATS config, and that doc already owned the wire format knob's lifecycle, so the two-edit contract for adding a new shared knob (env_init.py emission +register_shared_domain()suffix) sits next to the value the knob controls. - Regression proof lives at the real service-parser level
(
shared_nats_env_var_reaches_service_without_argvinores.iam.service'sconfig_parser_tests.cpp): the mechanism-level tests inconfig_nats_configuration_tests.cpppin the mapper/parse_environment contract, but only the actual parser path (standard_service_options::parse()with empty argv) proves the story's end goal — a knob reaching a service via .env alone. IAM is the reference service; every domain service shares the samestandard_service_optionspath. - The decommission is from-scratch with no backwards-compat shims: the
controller binary is already gone, so the 5 legacy tables, their
create/drop/populate files, the RLS includes, the
_controller_serviceDB role, the IAMControllerServicerole andcontroller::*permissions, thevalidation_ignore.txtentries, and the deadores_controller_prefix mapping inplantuml_er_parse_sql.pywere all removed together and the ER diagram regenerated from the SQL. - Remaining 'controller' strings in
projects/ores.sqlwere audited and kept only when unrelated to the service controller: Qt MVCSettingGatedActionControllercomments, Greek LEI legal-name data, and the syntheticcurve_feed_controllercomment. ORES_CONTROLLER_SERVICE_{WT,HTTP}_PORTstay live for the moment:env_init.pyemits andsystemd_generate.pysubstitutes them forores.wt.service=/=ores.http.server, so they were deliberately out of the decommission's scope and are renamed by the dedicated "Rename still-functionally-used ORES_CONTROLLER_SERVICE_* env vars" task.
Out of scope
Promoted from capture
Captured 2026-07-31 in the product backlog; promoted preserving the UUID.
What
process_supervisor::build_args() forwards controller config to spawned
services via explicit CLI-arg template substitution, not environment
inheritance – and compass_services.py's own hand-built launch args for
the controller and Qt client have the identical problem in a second,
independent place. Fixing ORES_NATS_WIRE_FORMAT took two separate
one-line additions (one in C++'s default_args_template, one in Python's
controller launch args) because both layers manually re-list every NATS
config value instead of letting each service parse .env=/CLI/its own
environment the same way every service's own =boost::program_options
config layer already does. The services (and the controller/compass
launcher) should read .env variables directly wherever the value is a
pure passthrough with no controller-side transformation, instead of this
two-tier manual re-threading through explicit argv templates at both the
Python launcher and the C++ supervisor.
Why
Every new pure-passthrough .env config knob currently needs manual
code changes in three separate places to actually reach every
controller-launched service, with no error if any is missed – it
silently falls back to the child's CLI default. Verifying msgpack
end-to-end for the nats-configurable-wire-format story hit all three,
one after another, each looking like "the fix", each still showing
format:json in the service log afterwards:
process_supervisor::default_args_template(C++) – the NATS-domain-services default template used when a service's DB row hasargs_template = NULL.compass_services.py's own hand-built controller-launch argv (Python) – a second, independent place that also manually re-lists--nats-url=/–nats-subject-prefix=/etc, for the controller process itself (whichprocess_supervisordoesn't spawn –compass services startlaunches it directly).controller_service_definitions_populate.sql's five customargs_templaterows (ores.telemetry.service,ores.ore.service,ores.http.server,ores.wt.service,ores.compute.wrapper) – each one overrides the default template entirely for its own reasons (custom ports, work dirs, Wt's--arg separator), so fixing (1) does nothing for any service listed here; each row needed the identical--nats-wire-format {nats_wire_format}addition individually.
This is a genuinely confusing failure mode to debug: the fix is
correct each time, the build succeeds each time, yet the symptom
(format:json in the log) is unchanged after restarting services,
because a different service (or the controller itself) is still
hitting one of the other two un-fixed layers. There is no single
place that lists "every place NATS config reaches a process" –
discovering the second and third layers required grep-hunting through
Python launcher code and DB seed SQL after the C++ fix visibly didn't
work. Investigate whether this class of bug recurs for other config
knobs, and whether it's worth hardening (e.g. a smoke test asserting
every .env-sourced nats_options field reaches every
controller-launched service across all three layers, or moving to
direct environment inheritance for pure-passthrough values while
keeping explicit args only for controller-computed values like TLS
cert paths and host ids – at minimum, a single doc enumerating all
three layers so the next person doesn't have to rediscover them one
failed restart at a time). Note: once the project migrates services
to podman/containers, ores.controller is planned to be
decommissioned – so this may not be worth deep investment beyond
documenting the risk, unless the migration is still some way off.
Current architecture in detail (why it's this complicated)
CORRECTION after further investigation: the paragraph originally here
speculated that Boost.Process v2 might not be inheriting the
controller's environment into spawned children, and that CLI-arg
threading was therefore redundant with environment inheritance. That
speculation was wrong, and the codebase already documents the real
design explicitly (.env's own generated comment, next to the
controller's mirrored NATS vars): "Only the controller needs this: it
launches every other service directly with these same values as CLI
args … so they never read NATS_ from the environment themselves."*
This is deliberate, not a bug in the spawning mechanism.
The actual mechanism: every service's config parser
(projects/ores.*/service/src/config/parser.cpp, one per service)
builds a boost::program_options description, parses argv, then
layers environment variables on top via
ores::utility::program_options::environment_mapper_factory::make_mapper("<APP_NAME>")
boost::program_options::parse_environment(od, name_mapper). Critically,
environment_mapper_factory::make_mapper() (projects/ores.utility/src/program_options/environment_mapper_factory.cpp)
only recognises env vars matching the exact prefix ORES_<APP_NAME>_* –
there is no generic fallback to a shared ORES_NATS_* namespace. So
ores.iam.service (app name IAM_SERVICE) only ever reads
ORES_IAM_SERVICE_NATS_URL-style vars from its environment, never the
plain ORES_NATS_URL that .env actually defines. Since .env does
not generate a per-service mirror of every NATS variable for every
domain service (only for the controller and ores.shell, which each
have their own ORES_CONTROLLER_SERVICE_NATS_*=/=ORES_SHELL_NATS_*
mirror blocks), the only way shared NATS config reaches a domain
service is via CLI flags the controller explicitly constructs and
passes at spawn time. Environment inheritance genuinely cannot work for
these processes under the current env-var-naming scheme, full stop –
this is not an open question, it's confirmed by reading
environment_mapper_factory.cpp directly.
Layers 1 and 5 below (controller, ores.shell) are different: they
each have their own ORES_<APP>_NATS_* mirror block in generated
.env, so for them specifically, environment inheritance genuinely
does work once the mirror var exists – which is exactly why fixing
ORES_SHELL_NATS_WIRE_FORMAT (Layer 5) meant adding a new mirrored
.env variable (bumping the .env-format version) rather than editing
an argv template. Layers 2/3 (process_supervisor, the custom SQL
templates) have no such per-service mirror and must use explicit CLI
flags, because that's the only channel that exists for those services.
The upstream complexity is therefore genuinely five independent code
paths, each with its own mechanism for getting a NATS value from
.env into a running process:
- Layer 1 – compass_services.py's controller launch (Python). "compass
services start" is the human/CI entry point: it parses
.envinto a dict (load_env()), builds aCtxwith derived fields (label, ports, URLs, tls flag), and launchesnats-serverthenores.controller.servicedirectly viasubprocess.Popen(..., env=ctx.child_env()).child_env()doesos.environ.copy()thene.update(self.env)– so the controller process's OS environment does contain every.envvariable, includingORES_NATS_WIRE_FORMAT. And yet the controller's own launch args (a hand-built Python list) also explicitly pass--nats-url=/–nats-subject-prefix=/etc as CLI flags – entirely redundant with what the controller would already resolve from its own environment via the exact same env-mapper mechanism every other service uses. This redundancy is presumably historical (or defensive), but it means every new NATS knob needs a matching line added here too, or it's simply missing from the controller's own resolved config – which is exactly what happened withORES_NATS_WIRE_FORMATuntil fixed. - Layer 2 – process_supervisor's spawning of every other service (C++,
projects/ores.controller.core/service/process_supervisor.cpp). The controller, once running, is itself responsible for spawning every domain service, http/wt server, and compute-wrapper replica, entirely via explicit CLI argv (default_args_template) – this is correct and necessary as designed, not a bug: these services have no per-serviceORES_<SERVICE>_NATS_*mirror in.env, so CLI args are the only channel.--nats-wire-format {nats_wire_format}needed adding here because the template simply never had that placeholder, same as every other NATS flag it does carry. - Layer 3 – controller_service_definitions_populate.sql's custom
args_templaterows. Five services (ores.telemetry.service,ores.ore.service,ores.http.server,ores.wt.service,ores.compute.wrapper) don't useprocess_supervisor'sdefault_args_templateat all – each has its own fully-custom template string seeded intoores_controller_service_definitions_tbl(for legitimate reasons: custom ports, work dirs, the Wt--arg separator). Fixing Layer 2's default template does nothing for any of these five; each one needed the identical--nats-wire-format {nats_wire_format}substring added to its own SQL row individually. This is the least discoverable layer: it lives in seed data, not application code, and only takes effect on the nextcompass db recreate(a running environment's DB rows from before the SQL change are untouched until reseeded). - Layer 4 (not yet audited) – ores.qt='s client launch. Unlike every
server-side service,
compass_services.py's Qt-client launch args (client_args, used by "compass client start") pass no--nats-url=/ =--nats-subject-prefix=/–nats-wire-format= flags at all. Given Layer 2's finding (no genericORES_NATS_*env fallback exists), this is suspicious – either the Qt client has its own per-appORES_QT_NATS_*mirror block in.env(not confirmed), or its auto-connect path resolves NATS config through a different, non-nats_configuration.cppmechanism entirely (it is Qt/ClientManagercode, not a service parser). Not yet audited whetherORES_NATS_WIRE_FORMATreaches the Qt client at all; likely needs the same per-app-mirror-variable treatment Layer 5 got if it doesn't already have one. - Layer 5 – compass shell's launch args (Python,
compass.py'scmd_shell). A fifth, independentflag_fordict mappingORES_SHELL_NATS_*(a per-shell mirror block.envalready generates, matching the controller's ownORES_CONTROLLER_SERVICE_NATS_*mirror) to CLI flags – missingORES_SHELL_NATS_WIRE_FORMATentirely, both the mirror variable inenv_init.py's generated.envand theflag_forentry mapping it to--nats-wire-format. Unlike Layers 2/3, this is an env-inheritance-eligible case (the mirror variable exists for every other NATS knob) – it was simply never extended to cover wire format, requiring both a new.env-format version bump and a newflag_forline.
Why this matters beyond one config knob
There is currently no single place that documents, let alone enforces,
"every value in ores::nats::config::nats_options (or any other shared
config struct) must reach every service through all five layers above."
Each layer was hand-written independently, at different times, uses a
different mechanism (per-app env mirror vs. bare CLI argv), and none of
them fail loudly when a value is missing – the service just silently
falls back to its CLI default. This is exactly the kind of gap that
will recur for the next new process-wide config knob, not just this
one, and the fix required touching Python launcher code, C++ supervisor
code, and seed SQL, in three different languages/files, to land one
config knob correctly everywhere.
Target end state
Per explicit direction: services should just read from the process
environment, full stop – eliminating the CLI-argv-templating layers
(2 and 3) entirely, not merely extending them with one more flag each
time a knob is added. Given environment_mapper_factory only maps
ORES_<APP_NAME>_*-prefixed vars (confirmed above – no generic
ORES_NATS_* fallback exists), reaching this end state requires an
actual code change to the mapping mechanism itself, not just relying on
child_env() already populating the process environment (it does, but
today's per-app-prefix scheme means that's not enough on its own). Two
ways to get there, in ascending order of intrusiveness:
- Extend
environment_mapper_factory(or each service parser's setup) with a second, fallback mapper: tryORES_<APP_NAME>_<OPTION>first (today's behaviour, still wins so per-service overrides keep working), then fall back to a sharedORES_<OPTION>(no app prefix) for options registered by shared modules likenats_configuration(ORES_NATS_URL,ORES_NATS_SUBJECT_PREFIX,ORES_NATS_WIRE_FORMAT,ORES_NATS_TLS_*).boost::program_optionssupports layering multipleparse_environmentcalls into the samevariables_map, so this is additive, not a rewrite. Once in place,.env's existing genericORES_NATS_*block (which every process already inherits viachild_env()=/the controller's own environment) becomes sufficient on its own for every domain service, collapsing process_supervisor's NATS-flag substitution and the five custom SQL =args_templaterows' NATS flags to nothing – CLI args would then be reserved only for genuinely per-process/replica, controller-computed values with no.envrepresentation at all (host_id, per-replicawork_dir=/=log_dir=/=replica_index, derived TLS cert/key paths). This removes Layers 2 and 3 as sources of this bug class entirely. - Alternatively, generate a full per-service
ORES_<SERVICE>_NATS_*mirror block for every domain service inenv_init.py(the pattern Layers 1/5 already use for the controller and shell) and haveprocess_supervisor=/the SQL templates stop passing NATS flags, relying on inheritance the same way Layer 5 now does. This avoids touching =environment_mapper_factorybut multiplies.env's size by the service count for values that are, by design, identical across every service (todays's whole reason shared NATS options exist as one un-prefixed block in the first place) – (1) is the cleaner fix.
This is doubly worth doing because the entire process-supervisor
architecture is transitional: once the project moves services to
podman/containers, ores.controller and its whole spawn-a-child-process
argv-templating design are planned to be decommissioned. A
container-based deployment would give each service its full environment
directly (an env file / secret mount / compose environment block) with
no argv-templating step in between at all – which is precisely option
(1)'s end state, arrived at for free by the container runtime rather
than by fixing process_supervisor. So the practical choice is: land
option (1) now (a small, contained change to the env-mapper layer) so
today's process-based deployment already behaves the container-native
way, or accept the current five-layer fragility as a known, documented,
bounded risk until the podman migration lands and makes the whole
problem moot. Given that context, deep investment in
process_supervisor's architecture itself (new tests, bigger refactors)
is probably not worthwhile – but option (1) is a small, high-value
change that is also directly useful groundwork for (and a preview of)
the target container-native shape, regardless of how soon the podman
migration actually lands.