AZ-266: schema-compliant JSON logging entrypoint, level normalisation, handler-topology guard, format-error fallback (log_record_schema v1.0.0). AZ-269: env > YAML > defaults config loader, frozen Config dataclass, missing-var fail-fast with pointer to .env.example, component-block registry. AZ-277: GTSAM-backed SE3Utils (matrix<->SE3 + exp/log/adjoint) with strict orthogonality, dtype, and bottom-row contract enforcement. AZ-280: atomicwrites-backed write_atomic + independent verify + order-deterministic aggregate_hash; sidecar format strictness. pyproject.toml pins gtsam>=4.2,<5.0 and atomicwrites>=1.4,<2.0 (named-backend deps per the AZ-277 / AZ-280 contracts). 139 unit tests pass (44 new). Review verdict: PASS_WITH_WARNINGS; findings are perf-NFR + journald deferrals, no blocking issues. Co-authored-by: Cursor <cursoragent@cursor.com>
5.3 KiB
Config Loader + Outer Config Container
Task: AZ-269_config_loader
Name: Config Loader
Description: Implement load_config(env, paths) -> Config and the outer frozen Config dataclass. Merges env vars + one or more YAML files + documented defaults with strict precedence (env > YAML > defaults), returning an immutable container that holds one nested dataclass field per component slug.
Complexity: 3 points
Dependencies: AZ-263_initial_structure
Component: shared.config (cross-cutting; epic AZ-246 / E-CC-CONF)
Tracker: AZ-269
Epic: AZ-246 (E-CC-CONF)
Problem
ADR-001 (runtime selection by config) and ADR-009 (composition root) both require a single source of truth for configuration. Without a shared loader with explicit precedence rules, components silently fall back to defaults, the composition root grows local config-parsing logic, and operators cannot reliably override settings via env in CI or by YAML in the field.
Outcome
load_config(env, paths)is the only function any onboard process uses to materialise itsConfigat startup.- Precedence is deterministic and observable: env > YAML > defaults; later YAML files win over earlier ones; missing keys fall to defaults.
- The returned
Configis frozen end-to-end (every nested component block is also frozen) so accidental mutation by component code is a TypeError.
Scope
Included
load_config(env: Mapping[str, str], paths: Sequence[Path]) -> Configper the composition_root_protocol contract.- Outer frozen
Configdataclass with one nested field per component slug. The OUTER container is owned by this task; the per-component nested dataclasses are owned by each component's epic and registered into the outer Config via a documented extension mechanism (a registry function called fromruntime_root.py). - Documented default values for cross-cutting blocks only (logging level, FDR queue size, etc.). Per-component defaults live in their own component epics.
- Friendly error messages when a required env var is missing (per AZ-263 AC-8): the error names the offending variable and points to
.env.example.
Excluded
compose_rootandcompose_operator— owned by the next PBI in this epic.- Per-component config blocks — owned by each component epic.
- The runtime self-check that strategies are linked — owned by the next PBI (StrategyNotLinkedError).
Acceptance Criteria
AC-1: Precedence env > YAML > defaults
Given env sets LOG_LEVEL=DEBUG and YAML sets log.level=INFO
When load_config(env, [yaml_path]) runs
Then config.log.level == "DEBUG"
AC-2: YAML > defaults when env is silent
Given env has no LOG_LEVEL and YAML sets log.level=INFO
When load_config(env, [yaml_path]) runs
Then config.log.level == "INFO"
AC-3: Defaults fill gaps
Given env has no LOG_LEVEL and YAML omits log.level
When load_config(env, [yaml_path]) runs
Then config.log.level equals the documented default
AC-4: Multi-file YAML merge order
Given two YAML paths where the second sets fdr.queue_size=8192 and the first sets it to 4096
When load_config(env, [first, second]) runs
Then config.fdr.queue_size == 8192 (later file wins)
AC-5: Frozen end-to-end
Given a loaded Config
When component code attempts config.log.level = "DEBUG"
Then a TypeError (or FrozenInstanceError) is raised
AC-6: Required-var missing fails fast with pointer
Given a required env var is unset and no YAML override or default exists
When load_config(env, paths) runs
Then it raises an error whose message names the missing var and points to .env.example
Non-Functional Requirements
Performance
- Cold-start
load_config≤ 250 ms on Tier-2 (allocates the budget for the rest of compose_root within 1 s).
Reliability
- Loader is pure: same env + same file contents always yields a deep-equal
Config. Verified by AC-relevant unit test.
Unit Tests
| AC Ref | What to Test | Required Outcome |
|---|---|---|
| AC-1 | env vs. YAML for log.level |
env value wins |
| AC-2 | YAML vs. default | YAML value wins |
| AC-3 | All-default for log.level |
documented default returned |
| AC-4 | Two YAML files, conflicting key | later file wins |
| AC-5 | Mutation attempt on loaded Config | TypeError / FrozenInstanceError |
| AC-6 | Missing required env var | error message names the var + points to .env.example |
| NFR-perf | Microbenchmark load_config over a representative config |
p99 ≤ 250 ms on Tier-2 |
| NFR-reliability | Call load_config twice with same args |
deep-equal Config instances |
Constraints
- Public surface frozen by
_docs/02_document/contracts/shared_config/composition_root_protocol.mdv1.0.0. - No new dependency beyond what AZ-263 / E-BOOT pinned (stdlib + the YAML library already in
pyproject.toml).
Risks & Mitigation
Risk 1: Per-component defaults drift across components
- Risk: Without a documented registration mechanism, two components may both claim a
log.leveldefault and conflict. - Mitigation: Defaults registry is keyed by component slug + key; collisions raise at registration time, not at load time.
Contract
This task produces (jointly with AZ-NN compose_root) the contract at _docs/02_document/contracts/shared_config/composition_root_protocol.md.
Consumers MUST read that file — not this task spec — to discover the interface.