# Contract: composition_root_protocol

**Component**: shared_config (cross-cutting concern owned by E-CC-CONF / AZ-246)
**Producer tasks**: AZ-269 (config loader + outer Config) and AZ-270 (compose_root + compose_operator + StrategyNotLinkedError)
**Consumer tasks**: every component task that takes a config block; `runtime_root.py` and `operator_tool/__main__.py` (the two composition-root entrypoints)
**Version**: 1.3.0
**Status**: draft
**Last Updated**: 2026-05-11

## Purpose

Frozen public surface for the configuration loader and the two composition-root functions. Components depend on these signatures (and the precedence rule) to know how their per-component config arrives at construction time and how they will be wired against their declared interfaces.

## Shape

### Function signatures (pythonic; binding is stdlib `dataclasses` / `attrs`-style)

```python
@frozen
class Config:
    """Outer config object. Populated by union of every component's config block.
    Each component contributes one immutable nested dataclass field named after its slug
    (e.g. config.c2_vpr, config.c5_state). Components MUST NOT read other components' blocks
    — the composition root is the only consumer of the full Config."""

def load_config(env: Mapping[str, str], paths: Sequence[Path]) -> Config: ...

def compose_root(config: Config) -> RuntimeRoot: ...
def compose_operator(config: Config) -> OperatorRoot: ...

class StrategyNotLinkedError(RuntimeError):
    """Raised by compose_root / compose_operator when the config selects a strategy whose
    BUILD_<NAME> flag was OFF in the linked binary (ADR-002 enforcement gate #3, after
    SBOM diff and runtime self-check)."""
    strategy_name: str          # the strategy class identifier the config requested
    component_slug: str         # owning component (e.g. "c1_vio")
    available_strategies: list[str]  # strategies actually linked into this binary
```

| Symbol | Required | Description | Constraints |
|--------|----------|-------------|-------------|
| `Config` | yes | Outer frozen dataclass | One nested field per component slug; nested fields are immutable |
| `load_config` | yes | Builds `Config` from env + YAML files | Precedence: env > YAML > documented defaults |
| `compose_root` | yes | Wires the airborne `RuntimeRoot` | Constructs every component instance, injects dependencies, returns root |
| `compose_operator` | yes | Wires the operator-side `OperatorRoot` | Same contract, different component subset |
| `StrategyNotLinkedError` | yes | Raised on strategy/build-flag mismatch | Carries `strategy_name`, `component_slug`, `available_strategies` |

## Invariants

- `load_config` is pure with respect to its inputs: same `env` + same file contents always yields the same `Config`.
- Precedence is **env > YAML > defaults** for every key. Two YAML files merge with later paths winning over earlier ones.
- `compose_root` and `compose_operator` MUST NOT mutate the passed `Config`.
- `StrategyNotLinkedError` is the only error type these functions raise on a strategy/build-flag mismatch — never `ValueError`, `KeyError`, or a generic `RuntimeError`.
- Cold-start `load_config` + `compose_root` ≤ 1 s on Tier-2 (counts toward AC-NEW-1's 30 s startup budget).

## Non-Goals

- This contract does NOT define the Config dataclass field set — each component owns its own block (defined in its component epic). The contract only fixes the OUTER container's composition rule (one nested field per component slug, frozen).
- This contract does NOT define the YAML schema — that follows from the per-component config blocks.
- This contract does NOT define `RuntimeRoot` / `OperatorRoot` internal structure — only that they are returned from these functions.

## Versioning Rules

- **Breaking changes** (function rename, new required positional arg, exception class rename, precedence change) require a new major version + a deprecation pass through every component config block.
- **Non-breaking additions** (new keyword-only arg with default, new optional method on `RuntimeRoot`) require a minor version bump.

## Test Cases

| Case | Input | Expected | Notes |
|------|-------|----------|-------|
| precedence-env-wins | env sets `LOG_LEVEL=DEBUG`; YAML sets `log.level=INFO` | `config.log.level == "DEBUG"` | env > YAML |
| precedence-yaml-wins | YAML sets `log.level=INFO`; no env entry | `config.log.level == "INFO"` | YAML > defaults |
| precedence-defaults | neither env nor YAML set `log.level` | `config.log.level == <documented default>` | defaults baseline |
| compose-root-default-binary | valid Config with default strategies | returns `RuntimeRoot` whose component count matches the airborne profile | reachability proof |
| compose-root-strategy-missing | config selects `vins_mono`; binary built with `BUILD_VINS_MONO=OFF` | raises `StrategyNotLinkedError` with `strategy_name="vins_mono"`, `component_slug="c1_vio"`, `available_strategies=["okvis2", "klt_ransac"]` | ADR-002 enforcement |
| compose-operator-no-airborne | operator-side config | returns `OperatorRoot` containing only operator-tier components (e.g. C11, C12) | wrong-tier components excluded |
| load-config-purity | call `load_config(env, paths)` twice with same inputs | identical `Config` objects (or deep-equal) | reproducibility |

## Takeoff Sequence (AZ-296 / E-C13 / AC-NEW-3)

The airborne entrypoint MUST execute the takeoff sequence in strict order:

1. Construct `FileFdrWriter`.
2. Call `writer.start()`.
3. Call `writer.open_flight(header)`.
4. **Only if step 3 succeeded**, construct the C8 FC adapter and call its
   `open()`. The FC adapter MUST NOT be constructed before `open_flight`
   returns; this is the AC-NEW-3 every-payload-class-from-t=0 gate.
5. Construct + start every other component.

If `open_flight` raises `FdrOpenError`:

- The composition root MUST log ONE ERROR record via the shared logger
  (`kind="composition_root.takeoff_aborted"`, `level="ERROR"`,
  `kv.reason="fdr_open_error"`, `kv.flight_root=<configured path>`,
  `kv.underlying=<str(exc)>`).
- It MUST call `writer.stop()` to release the filelock + segment file.
- It MUST print exactly one line to stderr:
  `FATAL: cannot open FDR at <flight_root>: <underlying message>; aborting takeoff (exit 2)`.
- It MUST exit the process with `sys.exit(EXIT_FDR_OPEN_FAILURE)`; if
  intercepted, fall back to `os._exit(EXIT_FDR_OPEN_FAILURE)`.

The abort path MUST complete in ≤ 500 ms (NFR-perf-abort).

### Exit codes

| Constant | Value | Meaning |
|----------|-------|---------|
| `EXIT_GENERIC_FAILURE` | 1 | Generic startup / runtime failure (uncaught exception, missing env vars, unresolved strategy) |
| `EXIT_FDR_OPEN_FAILURE` | 2 | `FileFdrWriter.open_flight()` raised `FdrOpenError`; takeoff aborted before FC adapter wired |

No other override flag (e.g. `--ignore-fdr-failure`) is permitted; adding
one is a major-version bump on this contract AND a security-review-required
change (AC-NEW-3 / RESTRICT-UAV-4).

## Change Log

| Version | Date | Change | Author |
|---------|------|--------|--------|
| 1.0.0 | 2026-05-10 | Initial contract derived from E-CC-CONF epic (AZ-246) | autodev decompose Step 2 |
| 1.1.0 | 2026-05-11 | Add takeoff sequence section + `EXIT_FDR_OPEN_FAILURE` (AZ-296) | autodev batch 7 |
| 1.2.0 | 2026-05-11 | Add cross-cutting `fc` (`FcConfig`) and `gcs` (`GcsConfig`) blocks + `build_fc_adapter` / `build_gcs_adapter` factories + outbound-thread single-writer binding (AZ-390) | autodev batch 8 |
| 1.3.0 | 2026-05-11 | Add `state` (`C5StateConfig`) block + `build_state_estimator(config, *, imu_preintegrator, se3_utils, wgs_converter, fdr_client) -> (StateEstimator, ISam2GraphHandle)` factory + state-ingest-thread single-writer binding (`bind_state_ingest_thread`); state factory MUST be invoked BEFORE C4 `build_pose_estimator` so the returned `ISam2GraphHandle` can be injected into C4 (AZ-381) | autodev batch 12 |
