# C13 FdrOpenError → Takeoff Abort Path

**Task**: AZ-296_c13_open_error_takeoff_abort
**Name**: C13 Takeoff Abort on FdrOpenError
**Description**: Wire the composition root's takeoff sequence so that `FdrOpenError` raised by `FileFdrWriter.open_flight()` (AZ-292) aborts takeoff BEFORE the C8 FC adapter is opened. This is the AC-NEW-3 every-payload-class-from-t=0 enforcement gate: if the FDR cannot persist records starting at t=0, the system MUST NOT emit external positions to the flight controller, because the audit trail proving "we made every safety-critical decision at t=0" would be missing. The abort is a HARD failure (the companion process exits with a non-zero status code so systemd / the Jetson init system surfaces it to the operator); it does NOT silently degrade.
**Complexity**: 2 points
**Dependencies**: AZ-291_c13_writer_thread, AZ-292_c13_flight_header_footer, AZ-263_initial_structure, AZ-266_log_module
**Component**: composition_root + c13_fdr (epic AZ-248 / E-C13)
**Tracker**: AZ-296
**Epic**: AZ-248 (E-C13)

### Document Dependencies

- `_docs/02_document/contracts/shared_config/composition_root_protocol.md` — defines the takeoff-sequence contract that this task amends with the FDR-first ordering invariant.
- `_docs/02_document/contracts/shared_logging/log_record_schema.md` — operational ERROR log shape this task uses for the abort message.

## Problem

The takeoff sequence in the composition root currently has no enforced ordering between FDR open and FC adapter open. Without this task:

- A composition root could open C8 (FC adapter — pymavlink, MSP) BEFORE C13 (FDR), so external positions start streaming to the flight controller before `FileFdrWriter.open_flight()` has confirmed the segment file is writable. The companion would silently emit positions for which there is no audit record at t=0.
- A misconfigured `flight_root` (read-only mount, missing parent directory, full filesystem) would surface only AFTER takeoff has begun — too late for the operator to fix the configuration on the ground.
- C13-IT-06 ("refuse takeoff if `open_flight` fails") would fail because there is no take-off-abort path; the test would observe the FC adapter wired despite the FDR failing to open.

## Outcome

- The composition root's takeoff sequence is strictly ordered: (1) construct `FileFdrWriter`, (2) call `start()`, (3) call `open_flight(header)`, (4) ONLY IF (3) succeeded, construct + open the C8 FC adapter, (5) start every other component.
- If step (3) raises `FdrOpenError`, the composition root catches the exception, logs an ERROR via the shared logger (`kind="composition_root.takeoff_aborted"; reason="fdr_open_error"; underlying=<str(exc)>`), tears down any partially-constructed components (the writer's `start()` is rolled back via its `stop()` so the filelock is released), and exits the process with a non-zero status code (specifically `2` — distinct from `1` which the project reserves for generic startup failures).
- The exit message printed to stderr names the offending `flight_root` path so the operator can immediately see "the FDR root I configured is wrong" — no log-diving required.
- The abort path is exercised end-to-end by an integration-style test that constructs a composition root with a read-only `flight_root`, runs it, and asserts (a) the FC adapter was NOT instantiated, (b) the process exits with status 2, (c) the stderr message names the path.
- C13-IT-06 (per `_docs/02_document/components/14_c13_fdr/tests.md`) is fully satisfied by this task in combination with AZ-292.

## Scope

### Included

- Modification to the composition root's takeoff sequence to enforce the strict ordering above. The composition root is `src/gps_denied_onboard/runtime_root.py` per AZ-263 / module-layout.md; the change is localised to the takeoff section.
- A `try/except FdrOpenError` block around `open_flight(header)` that:
  1. Logs ONE ERROR record via the shared logger (`kind="composition_root.takeoff_aborted"`, `level="ERROR"`, `kv={"reason": "fdr_open_error", "underlying": str(exc), "flight_root": str(config.fdr_writer.flight_root)}`).
  2. Calls `writer.stop()` to release the filelock + close any open segment file (no-op if `start()` failed before any segment was opened).
  3. Prints a single line to stderr: `FATAL: cannot open FDR at <flight_root>: <underlying message>; aborting takeoff (exit 2)`.
  4. Calls `sys.exit(2)`.
- The exit status is exactly `2` for FDR-open failures; the constants `EXIT_GENERIC_FAILURE=1` and `EXIT_FDR_OPEN_FAILURE=2` are documented in the composition_root_protocol contract (this task adds the new constant and the contract entry).
- An integration-style test fixture under `tests/integration/composition_root/` that constructs a composition root with a controlled `flight_root` path that fails to open (read-only directory) and asserts the documented behaviour.
- Update to `_docs/02_document/contracts/shared_config/composition_root_protocol.md` to document the strict takeoff ordering and the `EXIT_FDR_OPEN_FAILURE=2` constant. The contract update is in scope (this task touches the contract that other consumers read).
- Validation that the C8 FC adapter constructor / `open()` call sites are NOT reached on the FdrOpenError path. This is verified by the integration test (`assert fc_adapter_constructor.call_count == 0`) and by a code-review Phase 2 (Spec Compliance) check that walks the takeoff sequence statically.

### Excluded

- The actual implementation of `open_flight` and `FdrOpenError` — owned by AZ-292.
- The writer's `start()` / `stop()` lifecycle — owned by AZ-291.
- Recovery from `FdrOpenError` (e.g. retrying with a fallback `flight_root`) — explicitly NOT in scope. AC-NEW-3 says every payload class must be present from t=0; a fallback would violate the spirit of the AC by accepting a degraded FDR. The operator must fix the config and restart.
- Other takeoff-abort triggers (e.g. C7 inference engine load failure, C8 FC handshake failure) — those have their own composition-root abort paths owned by the respective component epics and the composition_root contract.
- GCS alert on takeoff abort — the companion is on the ground, not yet emitting to GCS; the abort surfaces via stderr + exit code, NOT GCS STATUSTEXT (which requires the FC adapter, which we are NOT opening). Documented as a constraint.
- Runtime FDR failure (`OSError` mid-flight) — that is owned by AZ-291's degraded-mode path with its own GCS alert.

## Acceptance Criteria

**AC-1: FdrOpenError raised → process exits with status 2**
Given a composition root configured with `flight_root=/read-only/path` (where `open_flight()` will raise `FdrOpenError`)
When the composition root's takeoff sequence runs
Then the process exits with status code exactly 2; no other component (especially C8 FC adapter) is constructed; the writer's filelock is released

**AC-2: Stderr message names the flight_root path**
Given AC-1's setup
When the test captures stderr
Then stderr contains exactly one line matching `^FATAL: cannot open FDR at /read-only/path: .*; aborting takeoff \(exit 2\)$`; no other FATAL lines are printed

**AC-3: ERROR log record includes underlying exception message**
Given AC-1's setup
When the test parses the structured log records
Then exactly one record exists with `kind="composition_root.takeoff_aborted"`, `level="ERROR"`, `kv.reason=="fdr_open_error"`, `kv.flight_root=="/read-only/path"`, `kv.underlying` containing the underlying `FdrOpenError`'s message

**AC-4: C8 FC adapter is NOT constructed on the abort path**
Given AC-1's setup AND a test double for the C8 FC adapter that records constructor invocations
When the takeoff sequence aborts
Then the C8 FC adapter test double's constructor was called 0 times; no MAVLink / MSP socket is opened

**AC-5: Successful open_flight proceeds to FC adapter**
Given a writable `flight_root` and a normal Config
When the takeoff sequence runs
Then `open_flight()` returns; the C8 FC adapter IS constructed AFTER `open_flight()` returns; the process does NOT exit with status 2

**AC-6: writer.stop() is called on the abort path**
Given AC-1's setup AND a writer test double that records `start` / `stop` calls
When the takeoff aborts
Then `writer.stop()` was called exactly once after the FdrOpenError; the filelock is released (a subsequent process can construct a new writer for the same `flight_root` without error)

**AC-7: Non-FdrOpenError exceptions are NOT caught by this handler**
Given a writer that raises `RuntimeError("boom")` from `open_flight` (NOT FdrOpenError)
When the takeoff sequence runs
Then the `RuntimeError` propagates UP (it is not swallowed by the FdrOpenError handler); the process exits with status 1 (generic failure path) — NOT status 2

**AC-8: Strict ordering — FdrWriter constructed and started before FC adapter constructor is called**
Given a composition-root unit test that records the order of constructor calls
When the takeoff sequence runs (success path)
Then the order is: `FileFdrWriter.__init__` → `writer.start()` → `writer.open_flight(header)` → `<C8 adapter constructor>` → `<C8 adapter open()>`; any other order fails the test

## Non-Functional Requirements

**Performance**
- The takeoff abort path completes within 500 ms of the FdrOpenError being raised (writer.stop() + log + stderr write + exit). Operators must see the abort signal immediately, not after a long teardown.

**Reliability**
- The abort path MUST NOT itself raise into the caller. Any exception inside the abort handler (e.g. `writer.stop()` itself raising) is swallowed with a SECOND ERROR log; the process still exits with status 2 (with `os._exit(2)` if `sys.exit(2)` is intercepted somehow).

## Unit Tests

| AC Ref | What to Test | Required Outcome |
|--------|-------------|-----------------|
| AC-1 | composition_root with read-only flight_root | exit status 2; no other component constructed; filelock released |
| AC-2 | Capture stderr | Exactly one matching FATAL line naming the flight_root path |
| AC-3 | Parse log records | Exactly one ERROR record with the documented kind + kv |
| AC-4 | Mock C8 adapter; trigger abort | C8 constructor `call_count == 0` |
| AC-5 | Writable flight_root | open_flight succeeds; C8 IS constructed after; no exit 2 |
| AC-6 | Mock writer; trigger abort | writer.stop() called exactly once |
| AC-7 | Writer raises RuntimeError from open_flight | RuntimeError propagates; exit status 1, not 2 |
| AC-8 | Spy on constructor / method invocation order | Strict order: writer init → start → open_flight → C8 init → C8 open |
| NFR-perf-abort | Time abort path from FdrOpenError to exit | ≤ 500 ms |
| NFR-reliability-abort-resilience | writer.stop() raises during abort | Second ERROR logged; process still exits with 2 |

## Constraints

- The takeoff abort exit code is FIXED at `2`; changing it is a breaking change to the composition_root contract and operator runbooks. The constant `EXIT_FDR_OPEN_FAILURE=2` lives in the composition root and is documented in `composition_root_protocol.md`.
- The abort path uses `sys.exit(2)` first (so `atexit` handlers run and structured logs flush); only if `sys.exit` does not actually exit (e.g. caught somewhere up the stack — this should not happen but the abort handler is defensive) does it fall back to `os._exit(2)`.
- The stderr message format is fixed (matches AC-2's regex). Operator runbooks grep for this exact pattern to surface FDR misconfigs in the field.
- This task does NOT introduce new dependencies. `sys`, `os`, and the existing logger are sufficient.

## Risks & Mitigation

**Risk 1: A future refactor moves the C8 FC adapter constructor BEFORE the FDR open**
- *Risk*: An optimization that "opens the FC adapter early to warm the link" silently breaks AC-NEW-3.
- *Mitigation*: AC-8's strict-ordering test runs in CI on every change to `runtime_root.py`. Code-review Phase 2 (Spec Compliance) explicitly checks that the FdrWriter open precedes the FC adapter constructor.

**Risk 2: `sys.exit(2)` interferes with pytest test runners**
- *Risk*: The test asserts on exit status 2 but pytest catches the SystemExit and reports it as a test pass.
- *Mitigation*: The integration test runs the composition root in a subprocess (`subprocess.run([...])`) and asserts on `proc.returncode == 2`. Documented in the test fixture; pytest's in-process exit interception is sidestepped.

**Risk 3: The abort handler swallows the FdrOpenError stack trace, making field debugging hard**
- *Risk*: The operator sees `FATAL: cannot open FDR at /path: <one-line message>` but the underlying cause (e.g. ENOSPC vs. EACCES vs. ENOENT) is hidden.
- *Mitigation*: AC-3's `kv.underlying` field carries the full `str(exc)` from the FdrOpenError; the structured log record preserves the full causal chain. The stderr line is the operator-facing summary; the log is the debug trail.

**Risk 4: Operators might want a "continue without FDR" override flag**
- *Risk*: Field debugging pressure leads to a `--ignore-fdr-failure` CLI flag that violates AC-NEW-3.
- *Mitigation*: This task EXPLICITLY excludes such an override (per the Excluded section). The contract update documents that no such override is permitted; adding one is a major-version bump on `composition_root_protocol` AND a security-review-required change. Documented as a constraint.

## Runtime Completeness

- **Named capability**: AC-NEW-3 every-payload-class-from-t=0 takeoff gate (architecture / E-C13 / AC-NEW-3 / C13-IT-06 / RESTRICT-UAV-4).
- **Production code that must exist**: real composition-root takeoff-sequence ordering, real `try/except FdrOpenError` handler, real `sys.exit(2)` (with `os._exit(2)` fallback), real `writer.stop()` rollback, real ERROR log + stderr message.
- **Allowed external stubs**: tests MAY use a subprocess + temp-directory `flight_root`; production wiring uses the real composition root.
- **Unacceptable substitutes**: a "warning, not abort" path ("the operator can decide"), exit code 1 ("we don't need a separate FDR-failure code"), opening the FC adapter before the FDR ("optimisation; we'll close it if FDR fails") — all break C13-IT-06 and the AC-NEW-3 invariant.
