# C12 Post-Landing Upload — `trigger_post_landing_upload` + FDR ON_GROUND Confirmation

**Task**: AZ-329_c12_post_landing_upload
**Name**: C12 Post-Landing Upload
**Description**: Implement `PostLandingUploadOrchestrator`, the C12 post-flight (F10) workflow that gates `C11.TileUploader.upload_pending_tiles` (AZ-319) on a confirmed-ON_GROUND signal from the post-flight FDR. `trigger_post_landing_upload(request: PostLandingUploadRequest) -> UploadBatchReport` does the following: (1) locate the FDR segments for the given `flight_id` under `config.c12.fdr_root` (segment layout: `<fdr_root>/<flight_id>/segment_<NNN>.fdr` per the C13 conventions); (2) iterate the segments from newest to oldest, parsing records via AZ-272's `FdrRecord.parse(...)`; (3) collect all `state.tick` records carrying a `flight_state` payload field (or a dedicated `flight_state.tick` kind if the schema names it that way — defer to AZ-272's contract); (4) walking the collected records backwards from the most recent (chronologically), count contiguous `ON_GROUND` records and compute the contiguous ON_GROUND duration as `(latest_record.ts − first_consecutive_on_ground_record.ts)` seconds; (5) compare against `config.c12.upload_min_on_ground_s` (default 30 s per description.md C12-IT-03); (6) on confirmed ≥ threshold → construct a `FlightStateSignal(state=ON_GROUND, since_ts=<first consecutive ts>)` and call `tile_uploader.upload_pending_tiles(flight_state=...)`; (7) on any refusal mode → raise `FlightStateNotConfirmedError(not_confirmed_reason=...)` with one of the four documented reason strings (`"never_landed"`, `"insufficient_duration: <X>s < <threshold>s"`, `"flight_id_not_found"`, `"fdr_unreadable: <repr>"`). Owns AC-8.4's defense-in-depth check on the operator-tooling side — the airborne C11 ALSO blocks via `UploadGateBlockedError` per AZ-319; this task is the operator-side gate that prevents the upload command from even being issued. Returns C11's `UploadBatchReport` unchanged on success. Logs every decision (INFO on confirmed; ERROR on each refusal mode) including the inferred contiguous ON_GROUND duration in seconds.
**Complexity**: 3 points
**Dependencies**: AZ-326_c12_cli_app, AZ-319_c11_tile_uploader, AZ-272_fdr_record_schema, AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module
**Component**: c12_operator_tooling (epic AZ-253 / E-C12)
**Tracker**: AZ-329
**Epic**: AZ-253 (E-C12)

### Document Dependencies

- `_docs/02_document/contracts/c11_tilemanager/tile_uploader.md` — consumed: `upload_pending_tiles` API + `UploadBatchReport` shape + `FlightStateSignal` DTO.
- `_docs/02_document/contracts/shared_fdr_client/fdr_record_schema.md` — consumed: `parse(buf: bytes) -> FdrRecord` + the `state.tick` / `flight_state.tick` kind shape (defer to the contract for the exact `kind` name and `flight_state` field).
- `_docs/02_document/components/13_c12_operator_tooling/description.md` — § 2 (`trigger_post_landing_upload` interface, `FlightStateNotConfirmedError`).
- `_docs/02_document/components/13_c12_operator_tooling/tests.md` — C12-IT-03 specifies the 30-s ON_GROUND threshold.
- `_docs/02_document/components/14_c13_fdr/description.md` — § 1 segment file layout (informational).

## Problem

Without a real `PostLandingUploadOrchestrator`:

- F10 has no head — operators cannot trigger post-landing tile upload; AC-8.4 (mid-flight tile upload trigger, post-landing) collapses; the pending-upload journal in C6 grows unboundedly across flights.
- The operator-side ON_GROUND gate (defense-in-depth on top of C11's airborne gate) does not exist — operators can manually invoke `C11.TileUploader.upload_pending_tiles` with a fabricated `FlightStateSignal`, defeating the AC-NEW-7 / AC-8.4 architectural intent that mid-flight tiles only upload when the aircraft has landed.
- C12-IT-03 (`trigger_post_landing_upload` requires ≥ 30 s confirmed ON_GROUND in FDR) has no implementation.
- `FlightStateNotConfirmedError` is concept-only in description.md § 5 with no producer.
- The CLI's `upload-pending` subcommand has nothing to delegate to.
- An incomplete flight log (FDR ends with `IN_FLIGHT` because the aircraft crashed or never landed) silently passes through to C11 if there's no operator-side gate; the airborne gate is the last line of defense and may itself be unavailable on the operator workstation.

This task delivers the operator-side gate. It does NOT own the actual upload (AZ-319), the FDR record schema (AZ-272), or the FDR write side (AZ-291..296) — it composes them.

## Outcome

- A `PostLandingUploadOrchestrator` class at `src/operator_tool/post_landing_upload.py`:
  - Constructor: `__init__(self, *, tile_uploader: TileUploader, fdr_segment_reader: FdrSegmentReader, logger: Logger, clock: Clock, config: C12PostLandingConfig)`.
  - `C12PostLandingConfig` (`@dataclass(frozen=True)`): `fdr_root: Path`, `upload_min_on_ground_s: float = 30.0`, `flight_state_record_kind: str = "state.tick"`, `flight_state_payload_field: str = "flight_state"`.
  - Public method: `trigger_post_landing_upload(request: PostLandingUploadRequest) -> UploadBatchReport`.
- DTOs at `src/operator_tool/_types.py`:
  - `PostLandingUploadRequest` (`@dataclass(frozen=True)`): `flight_id: str`.
  - Reuses C11's `UploadBatchReport`.
- Errors at `src/operator_tool/errors.py`:
  - `FlightStateNotConfirmedError(Exception)`: attributes `flight_id: str`, `not_confirmed_reason: str` (one of the four documented strings), `inferred_on_ground_duration_s: float | None` (populated when the reason is `insufficient_duration`), `remediation: str` (per-reason hint, e.g. for `flight_id_not_found`: "Verify the flight ID matches the FDR directory name; check `<fdr_root>/<flight_id>/`.").
- An `FdrSegmentReader` Protocol + `LocalFdrSegmentReader` concrete at `src/operator_tool/fdr_segment_reader.py`:
  - `Protocol`: `iter_records_for_flight(flight_id: str, *, kind_filter: str | None = None) -> Iterator[FdrRecord]` — yields records ordered by `ts` ASCENDING; the orchestrator reverses on its own. `kind_filter` if non-None restricts to that record kind for efficiency.
  - `LocalFdrSegmentReader.iter_records_for_flight(...)` — opens `<fdr_root>/<flight_id>/segment_*.fdr` files in numerical order, reads each as a stream of length-prefixed `FdrRecord` blobs (per AZ-272's serialisation), parses via `FdrRecord.parse(...)`, optionally filters by `kind`, yields one record at a time. Files are mmap'd or buffered-iterated so the operator workstation does not load multi-GB segments fully into memory.
  - On any I/O or parse error → raises `FdrUnreadableError(reason: str)` (a sibling helper exception caught by the orchestrator and rewrapped as `FlightStateNotConfirmedError("fdr_unreadable: ...")`).
- Method flow for `trigger_post_landing_upload`:
  1. `flight_dir = config.fdr_root / request.flight_id`. If `not flight_dir.exists()` → raise `FlightStateNotConfirmedError(flight_id, "flight_id_not_found", remediation="Verify <fdr_root>/<flight_id>/ exists; check `config.c12.fdr_root`.")`.
  2. Collect all `flight_state` records: `records = list(fdr_segment_reader.iter_records_for_flight(request.flight_id, kind_filter=config.flight_state_record_kind))`. Catch `FdrUnreadableError` → raise `FlightStateNotConfirmedError(flight_id, f"fdr_unreadable: {e!r}", ...)`.
  3. If `not records` → raise `FlightStateNotConfirmedError(flight_id, "never_landed", remediation="No flight state records in FDR for this flight; check the flight produced state.tick records.")` (treat absence of any state record as never-landed since we have no positive ON_GROUND signal).
  4. Walk `records` backward from the last (most recent `ts`):
     - `latest = records[-1]`.
     - If `latest.payload[config.flight_state_payload_field] != "ON_GROUND"` → raise `FlightStateNotConfirmedError(flight_id, "never_landed", remediation="Most recent flight_state in FDR is not ON_GROUND; the flight may have ended in IN_FLIGHT (e.g. crash, log truncation).")`.
     - Walk backward through `records[:-1]` while `record.payload[...] == "ON_GROUND"`; the first non-`ON_GROUND` (or the start of the list) bounds the contiguous ON_GROUND run.
     - `since = first_contiguous_on_ground_record.ts`; `duration_s = (parse_iso(latest.ts) - parse_iso(since)).total_seconds()`.
  5. If `duration_s < config.upload_min_on_ground_s` → raise `FlightStateNotConfirmedError(flight_id, f"insufficient_duration: {duration_s:.1f}s < {config.upload_min_on_ground_s:.1f}s", inferred_on_ground_duration_s=duration_s, remediation="Wait for the aircraft to be confirmed ON_GROUND for the required duration, then re-run.")`.
  6. INFO log `kind="c12.upload.confirmed_on_ground"` with `flight_id`, `inferred_on_ground_duration_s`.
  7. Construct `flight_state = FlightStateSignal(state=ON_GROUND, since_ts=since)` (the DTO comes from C11 per AZ-319's contract).
  8. Call `report = tile_uploader.upload_pending_tiles(flight_state=flight_state)`. Propagate `UploadGateBlockedError` (defense-in-depth on the airborne side; this should never happen if step 6 confirmed; if it does, log ERROR and re-raise as-is).
  9. INFO log `kind="c12.upload.complete"` with `tiles_acked`, `tiles_rejected` from `report`.
  10. Return `report` unchanged.
- Composition-root factory at `src/gps_denied_onboard/runtime_root/c12_factory.py` extends T1's `OperatorToolServices` dataclass with `post_landing_upload_orchestrator: PostLandingUploadOrchestrator`. The factory `build_post_landing_upload_orchestrator(config, services) -> PostLandingUploadOrchestrator` constructs the `LocalFdrSegmentReader` over `config.c12.fdr_root` and pulls C11's `tile_uploader` from the wider service registry.
- T1's `cli.py` `upload-pending` subcommand resolves `services.post_landing_upload_orchestrator` and calls `.trigger_post_landing_upload(...)`. Maps `FlightStateNotConfirmedError → exit 30`; `UploadGateBlockedError → exit 31`.

## Scope

### Included

- `PostLandingUploadOrchestrator` class with the single public method.
- `PostLandingUploadRequest` DTO.
- `FlightStateNotConfirmedError` with the four documented `not_confirmed_reason` strings + per-reason `remediation`.
- `FdrSegmentReader` Protocol.
- `LocalFdrSegmentReader` concrete reading on-disk FDR segments.
- `FdrUnreadableError` helper exception (caught and rewrapped at the orchestrator boundary).
- Composition-root factory.
- Wiring of T1's `upload-pending` subcommand to this service.
- Conformance unit tests using a fake `FdrSegmentReader` returning scripted record sequences for all 7 acceptance criteria.
- Two end-to-end integration tests using real FDR segment fixtures (one ending with confirmed ON_GROUND for 60 s, one ending with IN_FLIGHT) — these are the C12-IT-03 fixtures.

### Excluded

- The actual upload HTTP machinery (AZ-319).
- The FDR record schema or serialiser (AZ-272).
- The FDR write side / segment rotation (AZ-291..296).
- A "force-upload" override flag to bypass the gate — explicitly NOT supported (defeats the operator-side gate's purpose).
- Reading mid-flight tile snapshots from FDR — the upload itself reads tiles from C6 per AZ-319.
- Cross-flight aggregation — one `flight_id` per call.

## Acceptance Criteria

**AC-1: ≥ 30 s confirmed ON_GROUND → upload invoked**
Given a fake `FdrSegmentReader` returning 60 records, the last 60 of them with `flight_state=ON_GROUND` spanning 60 s of timestamps
When `trigger_post_landing_upload(request)` is called
Then `tile_uploader.upload_pending_tiles` is called exactly once with `flight_state.state=ON_GROUND` and `flight_state.since_ts` equal to the first contiguous ON_GROUND record's ts; the returned `UploadBatchReport` is the one C11 produced; ONE INFO log `kind="c12.upload.confirmed_on_ground"` with `inferred_on_ground_duration_s ≈ 60.0`; ONE INFO log `kind="c12.upload.complete"`

**AC-2: Insufficient duration → `FlightStateNotConfirmedError("insufficient_duration: ...")`**
Given the FDR ends with 15 s contiguous ON_GROUND records (less than the 30 s threshold)
When `trigger_post_landing_upload(request)` is called
Then `FlightStateNotConfirmedError(not_confirmed_reason="insufficient_duration: 15.0s < 30.0s", inferred_on_ground_duration_s≈15.0)` is raised; `tile_uploader.upload_pending_tiles` is NEVER called; ONE ERROR log `kind="c12.upload.refused.insufficient_duration"`

**AC-3: Never-landed (last record is IN_FLIGHT) → `FlightStateNotConfirmedError("never_landed")`**
Given the FDR's most recent `state.tick` record has `flight_state=IN_FLIGHT`
When `trigger_post_landing_upload(request)` is called
Then `FlightStateNotConfirmedError(not_confirmed_reason="never_landed", inferred_on_ground_duration_s=None)` is raised; uploader NOT called; ONE ERROR log `kind="c12.upload.refused.never_landed"`

**AC-4: `flight_id` not found in FDR → `FlightStateNotConfirmedError("flight_id_not_found")`**
Given `<fdr_root>/<flight_id>/` does not exist
When `trigger_post_landing_upload(request)` is called
Then `FlightStateNotConfirmedError(not_confirmed_reason="flight_id_not_found")` is raised; uploader NOT called; ONE ERROR log `kind="c12.upload.refused.flight_id_not_found"`

**AC-5: FDR unreadable → `FlightStateNotConfirmedError("fdr_unreadable: <repr>")`**
Given the FDR segments exist but parsing raises `OSError("input/output error")` mid-stream
When `trigger_post_landing_upload(request)` is called
Then `FlightStateNotConfirmedError(not_confirmed_reason=re.compile(r"^fdr_unreadable: .*OSError.*"))` is raised; uploader NOT called; ONE ERROR log `kind="c12.upload.refused.fdr_unreadable"` including the inner repr

**AC-6: Threshold is configurable**
Given `config.c12.upload_min_on_ground_s = 5.0` (override) and the FDR ends with 6 s contiguous ON_GROUND records
When `trigger_post_landing_upload(request)` is called
Then the call succeeds (uploader invoked); the threshold is read from config, NOT a hardcoded literal

**AC-7: Returns C11's `UploadBatchReport` unchanged**
Given a successful upload returning `UploadBatchReport(tiles_acked=42, tiles_rejected=3, ...)`
When the caller inspects the return value of `trigger_post_landing_upload`
Then it is byte-for-byte the `UploadBatchReport` C11 returned (same dataclass instance via passthrough); no field is added, removed, or renamed

**AC-8: Contiguous ON_GROUND counting starts from the most recent record only**
Given the FDR contains a sequence `IN_FLIGHT, ON_GROUND, IN_FLIGHT, ON_GROUND × 60s` (an aborted go-around landing)
When `trigger_post_landing_upload(request)` is called
Then the contiguous ON_GROUND block counted is the LAST one (60 s), not the earlier ON_GROUND record; the upload is invoked since 60 s ≥ 30 s

**AC-9: Empty `flight_state` records → `never_landed`**
Given `iter_records_for_flight(...)` yields zero records (no `state.tick` records ever emitted)
When `trigger_post_landing_upload(request)` is called
Then `FlightStateNotConfirmedError(not_confirmed_reason="never_landed")` is raised (treated as "we have no positive ON_GROUND signal")

**AC-10: Real FDR fixture C12-IT-03(a) (60 s confirmed) → upload invoked**
Given the C12-IT-03 fixture FDR with confirmed ON_GROUND for 60 s
When `trigger_post_landing_upload(request)` is called against the LocalFdrSegmentReader on the fixture
Then the upload is invoked; the returned `UploadBatchReport` matches the fixture's expected counts

**AC-11: Real FDR fixture C12-IT-03(b) (IN_FLIGHT, incomplete log) → refused**
Given the C12-IT-03 fixture FDR ending with IN_FLIGHT (truncated)
When `trigger_post_landing_upload(request)` is called against the LocalFdrSegmentReader on the fixture
Then `FlightStateNotConfirmedError(not_confirmed_reason="never_landed")` is raised; the upload is NOT invoked

## Non-Functional Requirements

**Performance**
- For an 8-hour flight (≤ 64 GB FDR per AC-NEW-3) the orchestrator's read of `state.tick` records completes in ≤ 30 s wall-clock on a developer laptop with NVMe (the records are sparse — `state.tick` is one of many record kinds; the `kind_filter` argument lets the reader skip non-state records cheaply).
- Memory peak ≤ 200 MB even with multi-GB FDR segments — `LocalFdrSegmentReader` is a streaming generator, NOT a list-in-memory.

**Compatibility**
- AZ-272's `FdrRecord.parse` API is the only parser path; this task does NOT re-implement record parsing.
- C11's `FlightStateSignal` DTO is consumed unchanged; this task does NOT redefine it.

**Reliability**
- Catches and rewraps the four refusal modes deterministically — operators can script against the four documented `not_confirmed_reason` prefix strings.
- Streaming I/O on FDR segments — multi-GB segments do not blow memory.
- The threshold default (30.0 s) matches description.md C12-IT-03 exactly.

## Unit Tests

| AC Ref | What to Test | Required Outcome |
|--------|-------------|-----------------|
| AC-1 | Fake reader with 60 ON_GROUND records spanning 60 s | Uploader called once, INFO logs, returns `UploadBatchReport` |
| AC-2 | Fake reader with 15 s ON_GROUND tail | `FlightStateNotConfirmedError("insufficient_duration: 15.0s < 30.0s")` |
| AC-3 | Fake reader whose last record is IN_FLIGHT | `FlightStateNotConfirmedError("never_landed")` |
| AC-4 | Path doesn't exist | `FlightStateNotConfirmedError("flight_id_not_found")` |
| AC-5 | Fake reader raises `FdrUnreadableError("OSError(...)")` | `FlightStateNotConfirmedError(re.match("^fdr_unreadable: .*"))` |
| AC-6 | Override `upload_min_on_ground_s=5.0` + 6 s ON_GROUND | Upload invoked |
| AC-7 | Successful upload, inspect return | Same `UploadBatchReport` instance/fields |
| AC-8 | Sequence with go-around (IN_FLIGHT in middle) | Contiguous count is the LAST run only |
| AC-9 | Empty `iter_records_for_flight` | `FlightStateNotConfirmedError("never_landed")` |
| AC-10 | C12-IT-03(a) fixture | Upload invoked |
| AC-11 | C12-IT-03(b) fixture | `FlightStateNotConfirmedError("never_landed")` |
| NFR-perf-streaming | Microbench `LocalFdrSegmentReader` over 1 GB synthetic segment | Memory peak ≤ 200 MB; parse rate ≥ 100 MB/s |

## Constraints

- The four `not_confirmed_reason` strings (`"never_landed"`, `"insufficient_duration: ..."`, `"flight_id_not_found"`, `"fdr_unreadable: ..."`) are a closed contract — adding a new value requires Plan-cycle approval (operators script against these prefixes).
- The threshold default 30.0 s matches description.md C12-IT-03 EXACTLY; changing it requires a spec amendment, not just a config change.
- The "contiguous ON_GROUND from most recent only" semantic (AC-8) is non-negotiable — counting the union of all ON_GROUND windows would defeat the gate by allowing an aborted-go-around aircraft to qualify based on the brief earlier landing.
- A "force-upload" override is explicitly NOT supported — operators who legitimately need to upload after a non-conforming flight must use a separate forensic path (out of scope this cycle).
- `LocalFdrSegmentReader` MUST stream; loading a multi-GB segment fully into memory is a NFR violation (NFR-perf-streaming).
- C11's `FlightStateSignal` DTO is the source of truth for the gate signal — this task does NOT define a parallel C12-internal `FlightStateSignal`.
- The threshold is a `float`; comparison uses `>=` (so exactly 30.0 s qualifies).

## Risks & Mitigation

**Risk 1: AZ-272's record schema names the field something other than `flight_state`**
- *Risk*: AZ-272's contract may use `state` or `flight.state` instead of `flight_state`; this task hardcodes the field name in `config.c12.flight_state_payload_field`.
- *Mitigation*: The field name is a config knob (default `"flight_state"`); during integration with AZ-272, the default is updated to match AZ-272's actual contract. Tests use the default; integration tests against real FDR fixtures catch a mismatch immediately.

**Risk 2: The aircraft logs ON_GROUND briefly during taxi before takeoff**
- *Risk*: The flight starts ON_GROUND, transitions to IN_FLIGHT, lands ON_GROUND again. The "contiguous from most recent" semantic correctly handles this — but if the FDR is truncated mid-flight, the most recent record might be from the taxi phase, falsely suggesting a landed flight.
- *Mitigation*: The truncation case is captured by AC-3 / AC-11 — a truncated log ending in IN_FLIGHT correctly refuses. A truncated log ending in the early ON_GROUND taxi phase is indistinguishable from a real landing, but this is an FDR integrity concern out of scope; in practice the FDR writes are continuous.

**Risk 3: FDR segment file naming convention drift**
- *Risk*: C13 (AZ-291..296) may name segments differently than `segment_<NNN>.fdr`.
- *Mitigation*: The naming pattern is captured in `LocalFdrSegmentReader` with a `glob_pattern` constructor parameter (default `segment_*.fdr`); update the default if AZ-291 picks a different name. Tests cover both patterns.

**Risk 4: `parse_iso` timezone handling**
- *Risk*: Two records with the same wall-clock time but different timezones produce a wrong duration calculation.
- *Mitigation*: AZ-272's contract specifies all timestamps are ISO 8601 UTC microseconds; this task asserts UTC at parse time and raises `FdrUnreadableError("non-UTC timestamp in record")` otherwise. Defense-in-depth.

**Risk 5: A future cycle adds a third flight state value (e.g. `EMERGENCY`)**
- *Risk*: The contiguous-counting code treats anything other than `ON_GROUND` as breaking the run; a new `EMERGENCY` value during landing rollout could shorten the inferred duration spuriously.
- *Mitigation*: Acceptable for this cycle — emergency states should not allow upload anyway. A future cycle that introduces such states must update this task's logic explicitly via a Plan-cycle change.

## Runtime Completeness

- **Named capability**: post-flight ON_GROUND-gated upload trigger per description.md § 2 (`trigger_post_landing_upload`) + AC-8.4 + C12-IT-03.
- **Production code that must exist**: real `PostLandingUploadOrchestrator` consuming real `TileUploader` (AZ-319) + real `LocalFdrSegmentReader` reading real on-disk FDR segments + real `FdrRecord.parse` (AZ-272).
- **Allowed external stubs**: tests MAY use fakes for `FdrSegmentReader` and `TileUploader`; the C12-IT-03 integration tests use real FDR fixture files + a fake `TileUploader` that records the call (no real network).
- **Unacceptable substitutes**: in-memory FDR (defeats the streaming guarantee NFR); a "force-upload" override (defeats the gate); shelling out to `cat <fdr>` instead of using `FdrRecord.parse` (no schema validation, no forward-compat); reading the FDR via the producer-side ring buffer (wrong API; ring buffer is for live producers, not post-flight reads).
