Files
gps-denied-onboard/_docs/02_tasks/done/AZ-269_config_loader.md
T
Oleksandr Bezdieniezhnykh 8e71f6c002 [AZ-266] [AZ-269] [AZ-277] [AZ-280] Cross-cutting log/config + SE3/SHA256 helpers
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>
2026-05-11 01:33:42 +03:00

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