Spec: Unified configuration and application lifecycle #918
DamianReeves
started this conversation in
Proposal
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Unified configuration and application lifecycle
Date: 2026-09-20
Status: Approved design, not yet implemented
Repo: finos/morphir (
crates/morphir) andecosystem/morphir-rust(morphir-config,morphir-devkit,morphir-common)Problem
Morphir has a capable layered configuration loader and a real application lifecycle. It uses
neither for the things they exist to do, so foundational behaviour is restated at every call site
and occasionally forgotten.
Configuration.
morphir-configand the devkit loader implement seven ordered layers withprovenance tracking, deep merge, secret references and legacy aliases:
The command line is not among them. Every flag is hand-wired where it is used, so each setting
re-implements precedence:
frontend_extension::resolve(flag, frontend, language),elm_modes::Mode::resolve(flag, frontend),selected_ir_version(cli, project), andOutOverrides { flag, env }— the last with its own environment variable,MORPHIR_OUT_DIR,read through
std::env::var_osrather than the layer.Worse than bespoke precedence is outright bypass.
logging.rsreadswhich is the environment layer's own key mapping retyped by hand, in the code that documents the
mapping. There are roughly fourteen direct
std::env::varsites in the CLI.Lifecycle.
MorphirSessionimplements starbase'sAppSessionbut onlyexecute.startup,analyzeandshutdownare left at their defaults. Configuration is therefore discovered andloaded several frames deep inside command bodies, and logging initialises before the session
exists at all.
These are one problem, not two. GH #887 — a standalone single-file compile silently ignoring
MORPHIR_FRONTEND__ELM__*— happened because "load the configuration" and "resolve a setting" areseparate ad-hoc steps, so one code path reached
resolvewithfrontend: Noneand no layer hadever been loaded. A missing configuration was an ordinary
Option::None, indistinguishable from"configuration says nothing about this". Nothing prevents the next instance.
Decisions taken
schematicWhy not
schematicstarbase(0.13.3, already a dependency) is an application framework —app.rs,session.rs,diagnostics/,exit_code.rs,tracing/— and provides no configuration layering. Its siblingschematic, by the same author, is "a layered serde configuration and schema library" supportingfiles, URLs, environment variables, partial configs, merge strategies, validation and JSON-schema
generation. It is not in our lockfile.
Adopting it would cost provenance tracking, secret references, legacy aliases and the seven-layer
model unless each were reimplemented, to gain a merge engine we already have. Decisively:
schematic does not treat command-line arguments as a layer either, so the actual gap would
remain. Worth revisiting if the JSON-schema generation becomes valuable on its own terms.
Precedent worth noting: .NET's Generic Host treats
AddCommandLineas an ordinary configurationprovider, and separates
ConfigureHostConfigurationfromConfigureAppConfiguration— the sametwo-stage split this design arrives at, reached independently.
Architecture
The command line becomes a layer
ConfigSourceKindgains one variant:Precedence stops being a rule anyone writes and becomes a consequence of the number. Provenance
reporting,
morphir configsource listings and the merge engine pick it up without change,because they already iterate
ConfigSourceKind.Two configuration stages
Not an exception, a named part of the lifecycle. Some callers genuinely run before a working
directory or file IO is available, and can emit diagnostics that want logging to already exist.
BootstrapConfig(host config)logging::init_from_env,MorphirHome::resolveEffectiveConfig(app config)The bootstrap stage fixes the hand-typing rather than the layering: callers ask for
logging.leveland the same mapping code derives
MORPHIR_LOGGING__LEVEL. Legacy aliases such asMORPHIR_LOG_LEVELbecome declarations instead of a secondstd::env::varcall.MORPHIR_HOMEandMORPHIR_LOG_DIRstay inRESERVED_ENV_VARS: they are operational, notconfiguration keys, and that remains a deliberate exception.
Phases
setup_miette(), host config, logging,MORPHIR_HOME--verboseValidating in
analyzerather than at point of use is .NET'sValidateOnStart.Session states
starbase's
run_with_session(&mut S)drives all four phases on oneS, each method taking&mut self. Classic consuming typestate (fn startup(self) -> Session<Analyzed>) thereforecannot be expressed without writing our own driver and giving up
AppRunOutcome,last_phase,exit-code handling and the shutdown-always-runs guarantee.
The pattern that does fit is a discriminated union at the boundary, holding phase-produced
data only:
There is deliberately no
ShutDownvariant. An earlier draft had one, and it was wrong twiceover. Shutdown is not stateless: it flushes logs and reports the log path, carries the operation id,
exit code and diagnostic, and reads the
EffectiveConfigto report provenance. Transitioning to aunit variant would discard exactly what shutdown needs. And since starbase runs shutdown even when
startup failed, it can be reached holding only
Bootstrapped— its work depends on how far the rungot, so it needs the state rather than its absence.
The deeper error was conflating two things in one union. Phase progress — which phase was
reached — is already owned by starbase, in
AppPhaseandAppRunOutcome.last_phase; duplicating ithere would create a second source of truth. Phase-produced data is ours, and is all the union
carries. So
shutdowndoes not transition state: it reads whatever state exists and releasesresources.The split also answers where long-lived handles go.
LogGuardoutlives every phase, so it belongsin
SessionResources, not in a variant. Today it lives inmainand is threaded intoreport_operation_outcomeat nine separate call sites — shutdown work done nine times, outside theshutdown phase.
TaskLockneeds no shutdown action at all: it has aDropimpl, so release is already RAII.SessionResourcesmust be shared rather than copied.LogGuardholds aWorkerGuardand anfs::File, so it is notClone— and cloning an RAII flush-on-drop handle would be wrong in anycase, since the flush must happen once. Because
AppSessionrequiresCloneandrun_executeclones the session, the guard sits behind
Arc<LogGuard>and drops when the last clone does.The union mirrors starbase's own
AppPhasein vocabulary, so our state andAppRunOutcome.last_phasestay aligned rather than becoming parallel ideas.
Arc<Ready>is load-bearing, not decoration:run_executedoeslet fg_session = session.clone(),so the session is genuinely cloned mid-run, which is why
AppSessionrequiresClone + Send + Sync.Where typestate pays, it is used. Command handlers take
&Ready, never the session. Allcommand code — where every one of these bugs has lived — is statically guaranteed a loaded
configuration. Illegal states are unrepresentable everywhere; illegal transitions are checked
in exactly one exhaustive
match, in one file, on a path every run exercises.This is what closes GH #887 structurally:
config: EffectiveConfigis always a fully layeredstack, and "no
morphir.tomlwas found" becomes a source status inside it (NotFound, which theloader already models) rather than the absence of the whole object. The environment layer can no
longer vanish along with the file.
Binding a flag to a key
The derive generates
fn cli_layer(&self) -> serde_json::Value, building the nested value andskipping
None. The loader consumes it as an ordinary source. A flag without#[config_key]issimply not a configuration setting (
--json,--no-cache), which keeps the annotation meaningful.Prerequisite. A derive needs a struct, and
Commandsis mostly inline variants — 16 of themagainst 6 that already use args structs.
Commands::Compile { … }becomesCompile(CompileArgs).Mechanical, shrinks
main.rs, and scoped to commands that carry config-bound flags.Reading a setting
Declared once, as a type:
Because the stack knows provenance, the error message currently hand-written per key is generated
once and gains what none of them have — the surface that actually set the value:
Today that message can only name
morphir.toml, even when the value came from the environment.The "a flag overrides a malformed value, so warn instead of failing" rule becomes one policy on
the resolver rather than something each key re-implements, and so applies to
extensionandpreludeautomatically.ConfigSettingis deliberately the data a future registry would consume, so this design is a downpayment on that rather than a detour.
starbase alignment
Used correctly today:
App::runwithAppRunOutcome::into_miette_result(), preserving real exitcodes instead of miette's default of always reporting 1; and
CliErrorasthiserrorplus#[diagnostic(code(…))].setup_miette()never called, and miette is not configured by us eitherset_hook,set_panic_hookorMietteHandlerOptsanywhere incrates/morphir/srcAppExitCodenever usedAppSession::initialize(&mut self, _exit_code: AppExitCode)not implementedtracingfeature off; logging hand-rolledfeatures = ["miette"];logging.rsis 524 linesCorrection, measured during Phase 0. An earlier draft of this spec claimed the miette gap
mattered because
with_cause_chainwould restore detail the default handler drops. That is false.Building the CLI with and without the setup, against a real malformed
morphir.toml, producedbyte-identical output: miette's
fancyfeature already prints the cause chain.What the setup actually changes is smaller:
set_panic_hook, so panics render as diagnostics, andthe
starbase_stylestheme, so error output matches the rest of the CLI. Both are worth having.Neither is the headline the earlier draft claimed.
Phase 0 also does not adopt
AppExitCode. Each phase already returnsResult<Option<u8>, E>and starbase acts on it, and commands use that path today, so the field would have no reader. It
waits for a caller that needs to set an exit code from inside a command.
On
AppExitCode: each phase already returnsAppResult<E>=Result<Option<u8>, E>and starbasecalls
handle_exit_codeon it, so returning a code from a phase is supported today.AppExitCode'svalue is letting code deep inside a command set the exit code without threading it up through
every return.
On tracing:
TracingOptionscovers default level, module filters, log file with rotation, NDJSON,span display, a configurable log-env variable name and chrome trace dump — real overlap with
logging.rs. But ours also does operation-id correlation (observability.rs, 257 lines) thatstarbase has no equivalent for, and those correlation spans are load-bearing for the CLI's own
diagnostics. "Replace
logging.rswith starbase tracing" is not a claim this design can make;it gets a timeboxed spike and its own decision record.
Migration
Five phases, each shippable alone.
Phase 0 — starbase alignment. miette configuration in bootstrap, installed once per process and
tolerant of a hook someone else already set. No configuration coupling.
AppExitCodedeferred forwant of a consumer.
Phase 1 — lifecycle skeleton. Populate
startup/analyze/shutdown; introduceSessionResources,SessionStateandReady; move configuration discovery out of command bodiesinto
startup, and theLogGuardplus outcome reporting out ofmainintoSessionResourcesandshutdown— collapsing ninereport_operation_outcomecall sites to one. Behaviour preserving, socharacterization tests pinning current resolution behaviour are written first.
Phase 2 — command line as a layer.
ConfigSourceKind::CommandLine; theConfigOverridesderive;
Commands::CompilebecomesCompile(CompileArgs).Phase 3 — resolver and settings migration.
ConfigSetting,config().get::<T>(), uniformprovenance-aware errors. Move the Elm modes,
extension,preludeandir_versionoff bespokeprecedence. GH #887 closes here.
Phase 4 — host configuration and remaining bypasses. Logging and home stop hand-typing
variable names, and legacy aliases become declarations.
The out root needs care rather than a mechanical fold-in.
workspace.out_dirand--out-dir/MORPHIR_OUT_DIRare not the same setting: the former is a path relative to theworkspace root that is validated to stay inside it (no absolute path, no
.., no backslashseparators), while the latter relocates the out root outright and is an absolute
PathBuf. Treatingthe flag as an alias of the key would quietly drop that validation. Phase 4 therefore decides
whether the relocation override gets its own key — and whether relocation should be expressible in
a file at all, given it is currently process-scoped by design. Until then
OutOverrideskeeps itsown resolution, but moves behind the resolver's provenance reporting so
morphir configcan stillexplain where the out root came from.
Deferred: the tracing spike, and the setting-registry approach that would generate clap args,
the typed model, the documentation table and the JSON schema from one declaration.
Testing
Test-driven throughout, per the repository's normal workflow.
refactor, since that phase must not change behaviour.
correct surface; the bootstrap stage seeing environment but not files; resolver error text,
including the malformed-value-overridden-by-flag rule.
[frontend.*]key, not only the two Elm modes.std::env::var("MORPHIR_*")call sites remainoutside the configuration crate, so the bypass cannot silently return. Phase 4 visits all of
them anyway, which is what makes this cheap rather than speculative.
Risks
Clone + Send + SynconAppSession.run_executeclones the session, so everything itholds must be cloneable.
Readyis behindArcto keep that cheap;LogGuardmust be behindArcbecause it is notCloneat all and must flush exactly once. Cheap to design in, expensiveto discover in Phase 1.
scope it to commands carrying config-bound flags rather than all 16 at once.
ConfigSettingis the seam that keeps the option open without paying for it.
Open questions
None blocking. Two to revisit after implementation:
declaration) earns its cost once
ConfigSettingexists for the migrated settings.schematicbecomes worth adopting for JSON-schema generation alone, given that ourlayering would still be ours.
--out-dir/MORPHIR_OUT_DIR) should be expressible in a file atall, or stay process-scoped as it is today. See Phase 4.
References
morphir-6cq2)ecosystem/morphir-rust/crates/morphir-config/src/env.rs— the environment mappingecosystem/morphir-rust/crates/morphir-devkit/src/config/{loader,sources}.rs— layers and provenancesrc/{app,session,exit_code,diagnostics}.rsAll reactions