AZ-270: composition root with strategy registry, tier-gated lookup, topo-order construction, all-or-nothing teardown, StrategyNotLinkedError payload. AZ-272: orjson-backed FdrRecord serialise/parse with forward-compat for unknown payload + top-level fields and canonical overrun-record shape. AZ-279: pyproj-backed WGS84/ECEF/ENU + OSM slippy-map tile math with WgsConversionError for shape/range/zoom guards. AZ-281: strict EngineFilenameSchema build/parse/matches_host with anchored regex + enum validation; round-trip identity by construction. AZ-283: dtype-preserving (fp16/fp32) single + batch L2 normaliser with zero-norm safety and descriptor_metric() source-of-truth. pyproject.toml pins pyproj>=3.6 and orjson>=3.9 (named-backend deps per the AZ-272 / AZ-279 contracts). New DTOs LatLonAlt + BoundingBox and EngineCacheKey + HostCapabilities land in _types/ to back the helper contracts. 203 unit tests pass (64 new). Review verdict: PASS_WITH_WARNINGS; findings are perf-NFR deferrals + dep amendment + minor docstring polish. Co-authored-by: Cursor <cursoragent@cursor.com>
6.7 KiB
Composition Root + StrategyNotLinkedError
Task: AZ-270_compose_root
Name: Composition Root
Description: Implement compose_root(config) -> RuntimeRoot for the airborne process and compose_operator(config) -> OperatorRoot for the operator-side tooling. Both functions construct every component instance, inject dependencies against component interfaces, and refuse to start when the config selects a strategy whose BUILD_<NAME> flag was OFF in the linked binary (raises StrategyNotLinkedError).
Complexity: 3 points
Dependencies: AZ-269_config_loader
Component: shared.config (cross-cutting; epic AZ-246 / E-CC-CONF)
Tracker: AZ-270
Epic: AZ-246 (E-CC-CONF)
Problem
Per ADR-009 (interface-first DI), only ONE place in the codebase may import concrete component implementations — the composition root. Without a single, tested composition function, components grow direct cross-imports and the build-time exclusion gate (ADR-002) loses its third enforcement point at runtime.
Outcome
- A single
compose_root(config)call returns a fully-wired airborneRuntimeRootwhose component graph matches theConfig-selected strategies. - Strategy/build-flag mismatch raises
StrategyNotLinkedErrorwith a clear message naming the missing strategy, the owning component, and the strategies actually linked into this binary. compose_operator(config)returns the operator-sideOperatorRootwith only operator-tier components (e.g. C11 TileManager, C12 operator tooling) — and refuses to wire C1–C5 / C7 / C13 (airborne-only) even if asked.runtime_root.pyexits with code 0 on a valid Config when no components do work (reachability proof per epic AC-4).
Scope
Included
compose_root(config: Config) -> RuntimeRootper the composition_root_protocol contract.compose_operator(config: Config) -> OperatorRootper the same contract.StrategyNotLinkedErrorexception withstrategy_name,component_slug,available_strategiespayload.- Strategy/build-flag consistency check that runs at the start of both compose functions; ADR-002 enforcement gate #3.
- Component construction order respects the dependency graph in
_docs/02_document/architecture.md(foundational components first). - Composition-root code is the ONLY allowed importer of concrete component classes; module-layout.md's Layout Rule 6 is enforced at code-review time.
Excluded
- The
RuntimeRootandOperatorRootinternal class definitions — owned by E-BOOT (AZ-263) for the skeleton; per-componentadd_to_rootregistration logic lives in each component epic. - Per-component config blocks — owned by each component epic.
- Per-component strategy registration — each component epic registers its strategies into a discovery map; this task only wires what's been registered.
Acceptance Criteria
AC-1: Default deployment composes
Given a default-deployment-binary Config and a binary built with the deployment BUILD_* flag set
When compose_root(config) runs
Then it returns a RuntimeRoot whose every component slot is populated by the strategy declared in Config
AC-2: Strategy/build-flag mismatch rejected
Given a Config selects vins_mono for c1_vio and the binary was built with BUILD_VINS_MONO=OFF
When compose_root(config) runs
Then it raises StrategyNotLinkedError with strategy_name="vins_mono", component_slug="c1_vio", available_strategies listing the strategies actually linked
AC-3: Operator-side excludes airborne
Given an operator Config accidentally references an airborne-only component (e.g. c1_vio)
When compose_operator(config) runs
Then it raises StrategyNotLinkedError (or a clearly-named subclass) noting the component is airborne-only
AC-4: Reachability proof
Given a valid Config with all components stubbed to do nothing
When runtime_root.py runs compose_root(config) and exits
Then exit code is 0 and no exception is raised
AC-5: Construction order respects dependencies
Given Config selects c5_state (depends on c1_vio, c4_pose)
When compose_root(config) constructs the graph
Then c1_vio and c4_pose instances exist before c5_state is constructed (verified by an order-tracing fake)
AC-6: Single import point enforced
Given the codebase
When the architecture lint check (added under code-review skill, Phase 7) runs
Then only compose_root and compose_operator import from components.<name>.<concrete> — every other module imports only from components.<name> (Public API)
Non-Functional Requirements
Performance
compose_root(config)≤ 750 ms on Tier-2 (combined with AZ-269's 250 ms loader budget for the 1 s total).
Reliability
- Composition is deterministic: same
Config→ same component graph (verified by structural equality on the fake recorder). - A failure mid-composition leaves no partially-constructed singletons (composition is all-or-nothing; on error, every constructed instance is closed).
Unit Tests
| AC Ref | What to Test | Required Outcome |
|---|---|---|
| AC-1 | Default Config + deployment-flag binary | Every component slot populated |
| AC-2 | Config selects unlinked strategy | StrategyNotLinkedError with full payload |
| AC-3 | Operator Config references airborne-only component | StrategyNotLinkedError (or subclass) noting tier mismatch |
| AC-4 | runtime_root.py smoke run with stubbed components |
exit code 0 |
| AC-5 | compose_root with construction-order recorder |
dependency order respected |
| AC-6 | Architecture lint over the codebase | Only compose_root / compose_operator import concrete strategies |
| NFR-perf | Microbench compose_root over a representative Config |
p99 ≤ 750 ms on Tier-2 |
| NFR-reliability | Force a mid-composition failure (one strategy raises in __init__) |
No partial state; every prior instance closed |
Constraints
- Public surface frozen by
_docs/02_document/contracts/shared_config/composition_root_protocol.mdv1.0.0. - Composition-root code is the ONLY place concrete strategy classes may be imported. Code-review Phase 7 emits an Architecture finding (High) on any other importer.
Risks & Mitigation
Risk 1: Component registration not fully discoverable at compose time
- Risk: A component epic forgets to register its strategies into the discovery map, leaving
compose_rootunable to construct it. - Mitigation: A startup self-check enumerates required components from the architecture spec and asserts every one has at least one registered strategy; missing → loud error at compose start.
Contract
This task produces (jointly with AZ-269 config loader) 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.