# 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.
