# C11 Flight-State Gate — ON_GROUND Defence-in-Depth for Upload

**Task**: AZ-317_c11_flight_state_gate
**Name**: C11 Flight-State Gate
**Description**: Implement the `flight_state == ON_GROUND` precondition check that `TileUploader.upload_pending_tiles` calls before any network egress. Defines a thin C11-internal `FlightStateSource` Protocol with one method `current_flight_state() -> FlightStateSignal`; the concrete impl is supplied by E-C8 later (subscribes to the FC adapter's flight-state stream). The gate raises `FlightStateNotOnGroundError` if the current state is anything other than `ON_GROUND` (`IN_FLIGHT`, `UNKNOWN`, `TAKING_OFF`, `LANDING` all block). Logs an ERROR with the observed state and refuses to proceed; this is defence-in-depth atop ADR-004's process-level isolation, NOT the primary control.
**Complexity**: 2 points
**Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module
**Component**: c11_tilemanager (epic AZ-251 / E-C11)
**Tracker**: AZ-317
**Epic**: AZ-251 (E-C11)

### Document Dependencies

- `_docs/02_document/components/12_c11_tilemanager/description.md` — § 2 `confirm_flight_state` method, § 5 `FlightStateNotOnGroundError`, § 7 ADR-004 process isolation as the primary control.
- `_docs/02_document/contracts/shared_logging/log_record_schema.md` — ERROR log shape on refusal.

## Problem

Without an ON_GROUND gate at the upload entry point:

- AC-8.4 collapses partially: ADR-004 process isolation alone protects the airborne process from importing C11, but if the operator workstation accidentally triggers `upload_pending_tiles` while the FC reports `IN_FLIGHT` (e.g. operator started the upload during a pre-landing approach window), the upload would proceed — which is the exact scenario the safety case forbids.
- `RESTRICT-SAT-1` (no in-flight Service calls) loses one of its enforcement points; the operator workflow assumes uploads only happen when wheels are on the ground.
- `TileUploader` has no place to reach for "what is the FC saying right now?" without coupling tightly to E-C8's full FC adapter surface.
- The Risk-7 mitigation in description.md ("The FC believes it's airborne") becomes a documentation-only claim with no test surface.

This task delivers the gate as a thin pre-call hook. It does NOT implement the FC subscription itself (that's E-C8's job); it consumes whatever C8 ships via the `FlightStateSource` Protocol declared here.

## Outcome

- A `FlightStateSource` Protocol at `src/gps_denied_onboard/components/c11_tilemanager/interface.py` (re-exported from `__init__.py`):
  ```python
  @runtime_checkable
  class FlightStateSource(Protocol):
      def current_flight_state(self) -> FlightStateSignal: ...
  ```
- `FlightStateSignal` enum at `src/gps_denied_onboard/components/c11_tilemanager/_types.py`:
  ```python
  class FlightStateSignal(StrEnum):
      ON_GROUND = "on_ground"
      TAKING_OFF = "taking_off"
      IN_FLIGHT = "in_flight"
      LANDING = "landing"
      UNKNOWN = "unknown"
  ```
- A `FlightStateGate` class at `src/gps_denied_onboard/components/c11_tilemanager/flight_state_gate.py`:
  - Constructor: `__init__(self, *, source: FlightStateSource, logger: Logger)`.
  - One public method: `confirm_on_ground() -> FlightStateSignal`. Returns `FlightStateSignal.ON_GROUND` on pass; raises `FlightStateNotOnGroundError(observed: FlightStateSignal, observed_at: datetime)` on fail.
  - Emits an ERROR log on every refusal with `kind="c11.upload.refused.flight_state"` carrying `{observed, observed_at_iso}`.
  - Emits an INFO log on pass with `kind="c11.upload.flight_state_confirmed"`.
- `FlightStateNotOnGroundError` defined at `src/gps_denied_onboard/components/c11_tilemanager/errors.py`. Subclasses `TileManagerError` (the C11 error family parent declared in AZ-316).
- The gate is integrated by the TileUploader task (separate task; called once per `upload_pending_tiles` invocation BEFORE any C6 read or network setup).
- Composition root constructs `FlightStateGate` with a `FlightStateSource` impl supplied by the C8 adapter wiring (when E-C8 ships). For now, a fake-source pattern is documented in this task's tests; the production wiring is a one-line factory swap.
- A `Clock` injection is NOT needed here — the gate reads "now" via `datetime.utcnow()` at the call site for the error's `observed_at` timestamp, which is purely diagnostic and not a control surface.

## Scope

### Included

- `FlightStateSource` Protocol (single method `current_flight_state`).
- `FlightStateSignal` enum (5 states).
- `FlightStateGate` class with `confirm_on_ground()` method.
- `FlightStateNotOnGroundError` definition.
- ERROR log on refusal; INFO log on pass.
- Composition-root entry for the gate (factory `build_flight_state_gate(source) -> FlightStateGate`).
- Conformance test for `FlightStateSource` Protocol against a fake.

### Excluded

- The actual FC subscription (subscribing to MAVLink heartbeat or equivalent) — owned by E-C8.
- The TileUploader integration of this gate — owned by the TileUploader task in this epic.
- ADR-004 build-time exclusion enforcement — owned by E-BOOT.
- Sector boundary or any geographic awareness — gate is state-only.
- Mid-upload re-checks (the gate fires once at start; in-progress uploads are NOT torn down if the FC transitions mid-upload, per the operator workflow which expects atomic batches).

## Acceptance Criteria

**AC-1: ON_GROUND passes**
Given a `FlightStateSource` returning `ON_GROUND`
When `confirm_on_ground()` is called
Then the call returns `FlightStateSignal.ON_GROUND`; no exception is raised; ONE INFO log `kind="c11.upload.flight_state_confirmed"` is emitted

**AC-2: IN_FLIGHT raises**
Given a `FlightStateSource` returning `IN_FLIGHT`
When `confirm_on_ground()` is called
Then `FlightStateNotOnGroundError` is raised with `observed = IN_FLIGHT`; ONE ERROR log `kind="c11.upload.refused.flight_state"` is emitted; the exception message names the observed state

**AC-3: UNKNOWN raises (fail-closed)**
Given a `FlightStateSource` returning `UNKNOWN`
When `confirm_on_ground()` is called
Then `FlightStateNotOnGroundError` is raised; the gate is fail-closed by design (UNKNOWN is treated as "not safe to upload")

**AC-4: TAKING_OFF and LANDING raise**
Given a `FlightStateSource` returning `TAKING_OFF` or `LANDING`
When `confirm_on_ground()` is called
Then `FlightStateNotOnGroundError` is raised in both cases; transition states are NOT treated as ON_GROUND

**AC-5: Source exception propagates with context**
Given a `FlightStateSource` whose `current_flight_state()` raises `RuntimeError("FC disconnected")`
When `confirm_on_ground()` is called
Then `FlightStateNotOnGroundError` is raised with `observed = UNKNOWN` (the gate maps source failure to UNKNOWN, not to the raw exception); the original `RuntimeError` is set as `__cause__` on the new exception; ONE ERROR log carries the original exception's message

**AC-6: FlightStateSource Protocol is conformance-checkable**
Given a class implementing `current_flight_state` returning `FlightStateSignal`
When `isinstance(impl, FlightStateSource)` is evaluated under `runtime_checkable`
Then the result is `True`; for a class missing the method, the result is `False`

**AC-7: Error carries diagnostic fields**
Given a refusal
When the test inspects the raised `FlightStateNotOnGroundError`
Then `exc.observed`, `exc.observed_at` (datetime, UTC, second-precision) are populated; the message starts with `"Upload refused: flight state is "` followed by the observed state name

**AC-8: Gate does not retry**
Given the source returns `IN_FLIGHT` then `ON_GROUND` on a hypothetical second call
When `confirm_on_ground()` is called once
Then `current_flight_state()` is called EXACTLY once (verifiable via spy); the gate does NOT poll-and-retry

## Non-Functional Requirements

**Performance**
- `confirm_on_ground` p99 ≤ 1 ms when the source returns synchronously (the gate is a thin wrapper; its cost is dominated by the source's own implementation, which this task does not constrain).

**Compatibility**
- `FlightStateSignal` is a stdlib `StrEnum` (Python 3.11+); no `pydantic` or `attrs`.
- No new third-party dependencies.

**Reliability**
- The gate is fail-closed: UNKNOWN, transition states, and source-failures all block the upload. The cost of a false-block (skipped upload) is small; the cost of a false-pass (upload during flight) is unbounded per the safety case.
- The gate does NOT cache state across calls; each `confirm_on_ground()` invocation re-queries the source.

## Unit Tests

| AC Ref | What to Test | Required Outcome |
|--------|-------------|-----------------|
| AC-1 | Fake source returns ON_GROUND | `confirm_on_ground` returns `ON_GROUND`; INFO log emitted |
| AC-2 | Fake source returns IN_FLIGHT | `FlightStateNotOnGroundError`; ERROR log; observed=IN_FLIGHT |
| AC-3 | Fake source returns UNKNOWN | `FlightStateNotOnGroundError` |
| AC-4 | Fake source returns TAKING_OFF; then LANDING | Both raise |
| AC-5 | Fake source raises `RuntimeError` | `FlightStateNotOnGroundError` with `observed=UNKNOWN`; `__cause__` set; ERROR log carries original message |
| AC-6 | `isinstance` check on conforming + non-conforming fakes | True / False |
| AC-7 | Inspect raised exception fields | `observed`, `observed_at` populated; message format correct |
| AC-8 | Spy on `current_flight_state` call count | Exactly 1 call per `confirm_on_ground` |
| NFR-perf | Microbench gate × 100k with synchronous fake | p99 ≤ 1 ms |
| NFR-reliability-fail-closed | Each non-ON_GROUND state | All raise; coverage matrix complete |

## Constraints

- The gate is fail-closed for UNKNOWN and any source exception. Documented; opening this default would require a Choose A/B/C/D coordination with the safety reviewer.
- Transition states (`TAKING_OFF`, `LANDING`) are NOT treated as ON_GROUND — operators must wait until the FC reports `ON_GROUND`. This is intentional and documented; the operator workflow's typical pause between landing and upload-trigger covers it.
- The gate calls `current_flight_state()` exactly once per `confirm_on_ground` — no polling, no retries. Documented behaviour; the upper-layer TileUploader handles retries at the upload-batch level if the operator wants to retry after a fail.
- This task introduces no new third-party dependencies.

## Risks & Mitigation

**Risk 1: `FlightStateSource` Protocol surface diverges from C8's eventual impl**
- *Risk*: When E-C8 (AZ-261) ships its FC adapter, the natural public method might not be `current_flight_state` — could be `latest_heartbeat()` or `state_stream`.
- *Mitigation*: Document the Protocol as a thin C11-facing adapter; if C8's natural surface differs, an adapter class wraps it (`FlightStateSourceAdapter(c8_fc_adapter)` — owned by E-C8's wiring task). The Protocol's narrow surface (one method, one return type) makes adapting trivial.

**Risk 2: UNKNOWN state during FC link recovery is too aggressive**
- *Risk*: A transient FC connection blip causes UNKNOWN; operator sees a refusal during a perfectly-on-ground state.
- *Mitigation*: Documented as fail-closed; the operator workflow tolerates re-triggering the upload after the FC recovers (the upload journal preserves pending tiles between attempts). E-C8's FC adapter is responsible for state debouncing if the false-UNKNOWN rate is operationally too high; not C11's concern.

**Risk 3: Fail-closed during a real on-ground emergency upload**
- *Risk*: A safety officer urgently needs to trigger an upload but the FC is reporting UNKNOWN.
- *Mitigation*: Per architecture, no operational scenario exists where an upload MUST succeed during FC-disconnect. The pending-upload journal preserves data; the upload runs after the FC reconnects. No override flag is provided — adding one would weaken the safety case and require Choose A/B/C/D approval.

## Runtime Completeness

- **Named capability**: defence-in-depth ON_GROUND check at upload entry (description.md § 5; ADR-004; AC-8.4).
- **Production code that must exist**: real `FlightStateSource` Protocol declaration, real `FlightStateSignal` enum, real `FlightStateGate` class with logging, real `FlightStateNotOnGroundError` in the C11 error family.
- **Allowed external stubs**: tests MAY use a fake `FlightStateSource` impl (synchronous return value); production wiring uses the real C8 FC-adapter source via the composition root (when E-C8 ships).
- **Unacceptable substitutes**: a hardcoded "always ON_GROUND" source (defeats the entire point); polling the source N times to "average" the state (introduces TOCTOU windows where the FC transitions mid-poll); silently mapping `UNKNOWN` to `ON_GROUND` (defeats fail-closed); reading FC state from a static config file (the FC's actual telemetry IS the source of truth).
