# 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 its `Config` at startup. - Precedence is deterministic and observable: env > YAML > defaults; later YAML files win over earlier ones; missing keys fall to defaults. - The returned `Config` is 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]) -> Config` per the composition_root_protocol contract. - Outer frozen `Config` dataclass 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 from `runtime_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_root` and `compose_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.md` v1.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.level` default 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.