# C13 FlightHeader / FlightFooter + Accounting

**Task**: AZ-292_c13_flight_header_footer
**Name**: C13 Flight Header/Footer + Accounting
**Description**: Wire the writer thread's flight-lifetime contract: an `open_flight(header: FlightHeader)` method that emits a single `kind="flight_header"` record as the first record of segment 0, a `close_flight() -> FlightFooter` method that emits a single `kind="flight_footer"` record as the last record before drain + stop, and the cross-flight running counters (`records_written`, `records_dropped_overrun`, `bytes_written`, `rollover_count`) that the footer reports. This is what makes a flight directory self-describing — without it, post-flight tooling cannot verify completeness or attribute drops to producers.
**Complexity**: 3 points
**Dependencies**: AZ-291_c13_writer_thread, AZ-272_fdr_record_schema, AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module
**Component**: c13_fdr (epic AZ-248 / E-C13)
**Tracker**: AZ-292
**Epic**: AZ-248 (E-C13)

### Document Dependencies

- `_docs/02_document/contracts/shared_fdr_client/fdr_record_schema.md` — defines the canonical shape of `kind="flight_header"` and `kind="flight_footer"` payloads (consumed: every required field on each kind).
- `_docs/02_document/contracts/shared_config/composition_root_protocol.md` — Config snapshot + signing-key-rotation-event + manifest-content-hashes the composition root passes into the FlightHeader.

## Problem

The writer thread from AZ-291 drains and persists FdrRecords, but at flight-time there is currently no canonical first record (which would identify the flight + carry the build/config snapshot the operator needs to reproduce post-flight) and no canonical last record (which would close the flight + report what was actually written vs. dropped). Without:

- A `flight_header` record written FIRST, the operator post-flight has no flight_id, no build manifest hash, no config snapshot — so the FDR cannot be uniquely attributed and provenance is broken.
- A `flight_footer` record written LAST, post-flight tooling cannot distinguish a clean shutdown from a power-loss truncation, and AC-NEW-3 traceability ("how many records were dropped per producer this flight") has no canonical answer.
- Cross-flight running counters fed into the footer, the AC-NEW-3 "every drop visible" guarantee degrades into "every drop visible only inside individual records" — there is no single number the operator can audit at landing time.

## Outcome

- The writer's `open_flight(header)` method opens segment 0 (the path is created by AZ-291's `start()`) and writes a `kind="flight_header"` record as the first record on disk; `open_flight` returning successfully is the precondition every other onboard component uses to consider the FDR "ready" (this is the AC-NEW-3 every-payload-class-from-t=0 readiness gate the takeoff path checks — task #6 wires the gate, this task makes it observable).
- The writer maintains four monotonic counters across the entire flight: `records_written` (per-record on every successful append), `records_dropped_overrun` (incremented when the writer observes a `kind="overrun"` record from any producer — `payload.dropped_count` is added), `bytes_written` (cumulative serialised bytes), `rollover_count` (incremented per per-segment rotation from AZ-291).
- The writer's `close_flight()` method writes a single `kind="flight_footer"` record carrying those four counters + flight-end timestamp + flight_id, drains remaining records (per AZ-291's `stop()` contract), `fsync`s, releases the filelock, and returns the same FlightFooter to the caller.
- The `FlightFooter` is the canonical authoritative summary: post-flight tooling that finds a footer record with mismatched counts vs. the actual segment file contents reports a corruption finding; tooling that does NOT find a footer record marks the flight as truncated.

## Scope

### Included

- `FlightHeader` dataclass: `flight_id: UUID`, `flight_started_at_iso: str`, `flight_started_at_monotonic_ns: int`, `config_snapshot: dict`, `signing_key_rotation_event: dict`, `manifest_content_hashes: dict[str, str]`, `build_info: dict` (commit hash, build date, BUILD_* flag set per ADR-002).
- `FlightFooter` dataclass: `flight_id: UUID`, `flight_ended_at_iso: str`, `flight_ended_at_monotonic_ns: int`, `records_written: int`, `records_dropped_overrun: int`, `bytes_written: int`, `rollover_count: int`, `clean_shutdown: bool`.
- `FileFdrWriter.open_flight(header: FlightHeader) -> None` (extends AZ-291's writer): validates `header.flight_id` matches the `flight_id` `start()` was constructed with; serialises `header` into a `kind="flight_header"` `FdrRecord` (envelope `producer_id="shared.fdr_client"`); appends as the first record of segment 0; raises `FdrOpenError` on failure (the actual takeoff-abort wiring is task #6, this task only raises the right exception type).
- `FileFdrWriter.close_flight() -> FlightFooter` (extends AZ-291's writer): synthesises the `FlightFooter` from the running counters; serialises into a `kind="flight_footer"` `FdrRecord`; appends as the last record before drain-and-stop; returns the FlightFooter to the caller.
- Counter integration with AZ-291's writer loop: `records_written` increments on each successful `serialise + append`; `bytes_written` increments by `len(serialised)`; `rollover_count` increments per AZ-291's rotation event; `records_dropped_overrun` is updated by inspecting incoming `kind="overrun"` records and adding `payload.dropped_count`.
- `current_size_bytes() -> int` and `is_rolling() -> bool` exposed on the writer (interface methods promised by `_docs/02_document/components/14_c13_fdr/description.md` § 2). `current_size_bytes` returns the cumulative `bytes_written`; `is_rolling` is task #1's per-segment-rotation flag re-exposed here for completeness of the public surface.
- A diagnostic INFO log on `open_flight` (one record: `kind="fdr.flight_open"; flight_id`) and `close_flight` (one record: `kind="fdr.flight_close"; records_written; records_dropped_overrun; bytes_written; rollover_count; clean_shutdown`).
- A `clean_shutdown=True` set by `close_flight`; `False` if the writer detects it is being torn down without `close_flight` ever called (e.g. via a process-exit hook the composition root installs — wiring of the hook is owned by the composition root, this task only writes the path that decides the flag value).

### Excluded

- Background writer thread + segment file lifecycle — owned by AZ-291.
- 64 GB total-flight cap + oldest-segment-dropped + `kind="segment_rollover"` record emission — owned by task #3 (the `rollover_count` this task maintains is incremented PER SEGMENT regardless of whether the cap-policy task is online; once task #3 ships, segment_rollover records are emitted on top of the existing per-segment rotations from task #1).
- Mid-flight tile snapshot path / failed-tile thumbnail rate cap — tasks #4 and #5.
- `FdrOpenError`-driven takeoff abort wiring in the composition root — owned by task #6 (this task only raises the right exception type from `open_flight`; the abort path that translates the exception into "do NOT open the FC adapter" is the next task).
- Composing the `FlightHeader` content (config snapshot, signing key state, manifest hashes) — that is the composition root's responsibility; this task accepts the constructed header.
- Process-exit hook installation — owned by the composition root; this task only sets the `clean_shutdown` flag based on whether `close_flight` was reached.

## Acceptance Criteria

**AC-1: flight_header is the first record of segment 0**
Given a valid `FlightHeader` and a constructed-but-not-yet-started writer
When `start()` followed by `open_flight(header)` runs
Then segment 0's first parsed record is `FdrRecord(kind="flight_header", payload=<header_dict>)` with `payload.flight_id == header.flight_id` and the record sits at byte offset 0 (no other record precedes it)

**AC-2: flight_footer is the last record before clean stop**
Given a writer with N producer records appended and `clean_shutdown` reachable
When `close_flight()` is called
Then the last parsed record across all segments is `FdrRecord(kind="flight_footer", payload=<footer_dict>)` with `clean_shutdown=True`; the returned FlightFooter equals the on-disk footer payload deep-equal

**AC-3: counters reflect actual on-disk reality**
Given a flight with R producer records, D overrun-record drops, S segment rotations
When `close_flight()` runs and the test parses the footer
Then `records_written == R + 2` (the +2 is the header + footer themselves), `records_dropped_overrun == D`, `bytes_written == sum(len(serialised(r)) for r in [header, *records, footer])`, `rollover_count == S`

**AC-4: open_flight raises FdrOpenError on disk failure**
Given a `flight_root` whose segment 0 path cannot be opened (e.g. read-only mount)
When `open_flight(header)` runs
Then `FdrOpenError` is raised; no `flight_header` record lands on disk; the writer is in the `start()`-failed state with the filelock released

**AC-5: open_flight rejects flight_id mismatch**
Given a writer constructed with `flight_id=A` and an `open_flight(header)` where `header.flight_id=B`
When `open_flight` runs
Then `FdrOpenError` is raised with a message naming the mismatch; no `flight_header` record lands on disk

**AC-6: close_flight without open_flight raises**
Given a writer where `start()` ran but `open_flight()` was never called
When `close_flight()` is called
Then `FdrCloseWithoutOpenError` is raised; no `flight_footer` is appended; the writer transitions to stopped (filelock released, segment closed if any data was written)

**AC-7: clean_shutdown=False on uncleansed teardown**
Given a writer that `start()` + `open_flight()` ran and was then torn down via the composition-root process-exit hook (without `close_flight()` having been called)
When the test parses the resulting FDR directory
Then either (a) no `flight_footer` exists (truncated flight detected), OR (b) a `flight_footer` exists with `clean_shutdown=False` — implementation chooses; the contract is that `clean_shutdown=True` MUST NOT appear when `close_flight` was not called, but writing a partial footer is allowed

**AC-8: records_dropped_overrun aggregates payload.dropped_count**
Given the writer observes 5 `kind="overrun"` records with `payload.dropped_count` values [3, 7, 2, 11, 4]
When `close_flight()` runs
Then `records_dropped_overrun == 27` (sum of all dropped_count values, NOT the count of overrun records — the count is observable from the records themselves)

## Non-Functional Requirements

**Performance**
- `open_flight` returns within 50 ms p99 (it serialises one record + appends it; no network or compute beyond `serialise`).
- `close_flight` returns within 200 ms p99 for typical flights (it triggers the writer's drain-and-stop sequence, but the per-record cost is dominated by `fsync` and the typical residual buffer is small).
- Counter updates on the steady-state path add ≤ 0.5 µs per record (atomic increments; no locking — the writer thread is the sole mutator).

**Reliability**
- The four counters are write-once-per-record from the writer thread (the writer is the sole mutator); reads from outside the thread (e.g. `current_size_bytes()`) MUST be atomic snapshots — Python's GIL covers this for `int`, but the implementation MUST NOT introduce any non-atomic compound update.
- `close_flight()` is idempotent on success: a second call returns the same FlightFooter without writing again, OR raises `FdrAlreadyClosedError` — implementation chooses; the contract test covers either outcome and asserts no double-write of the footer.

## Unit Tests

| AC Ref | What to Test | Required Outcome |
|--------|-------------|-----------------|
| AC-1 | start + open_flight + parse segment 0 | Record at offset 0 is `flight_header` with matching flight_id |
| AC-2 | open_flight + N producer records + close_flight | Last record across segments is `flight_footer`; returned footer == on-disk footer deep-equal; clean_shutdown=True |
| AC-3 | Run a flight with known R, D, S; parse footer counters | counters match (records_written, records_dropped_overrun, bytes_written, rollover_count) |
| AC-4 | open_flight against read-only flight_root | `FdrOpenError`; no header on disk; filelock released |
| AC-5 | open_flight with mismatched flight_id | `FdrOpenError`; message names the mismatch |
| AC-6 | close_flight without open_flight | `FdrCloseWithoutOpenError`; no footer written |
| AC-7 | start + open_flight + tear down without close_flight | No flight_footer OR flight_footer with clean_shutdown=False |
| AC-8 | Inject 5 overrun records with known dropped_counts | records_dropped_overrun == sum of dropped_count |
| NFR-perf-open | Microbench open_flight | p99 ≤ 50 ms |
| NFR-perf-close | Microbench close_flight | p99 ≤ 200 ms |
| NFR-perf-counters | Microbench writer loop with counter updates vs. without | overhead ≤ 0.5 µs per record |
| NFR-reliability-idempotent-close | call close_flight twice | second returns same footer OR raises FdrAlreadyClosedError; no double-write |

## Constraints

- `FlightHeader.config_snapshot` MUST be JSON-safe (no Python objects); the composition root is responsible for serialising the typed Config dataclass into a plain dict before constructing the header.
- `FlightHeader.manifest_content_hashes` MUST be a `dict[str, str]` of `{relative_path: sha256_hex}`; relative-path keys are repository-rooted (matches the helper from AZ-280 sha256_sidecar's invariants).
- The footer's `clean_shutdown` flag is the ONLY way to distinguish a graceful landing from a crash; do NOT add a separate "fault" record kind for this purpose.
- This task does NOT add new Python dependencies — `uuid`, `datetime`, and `time.monotonic_ns` are stdlib.

## Risks & Mitigation

**Risk 1: FlightHeader carries secrets via config_snapshot**
- *Risk*: A composition-root config block contains an API key (e.g. satellite-provider) and ends up in the FDR — operator workstations now hold credentials in plain JSON.
- *Mitigation*: The composition root scrubs known-secret fields (per the redacted-config helper from AZ-269) before constructing the header. AC validation here checks the dict is JSON-safe; the secret-scrub is owned by the composition root and is out of scope for this task. Documented in the constraints.

**Risk 2: Counters drift under writer-thread crash**
- *Risk*: A crash mid-flight leaves the in-memory counters un-flushed; the post-flight reader infers different counts from segment-walking than the (absent) footer.
- *Mitigation*: The footer is the authoritative summary on clean shutdown; on crash the operator MUST re-derive counters from segment scan and treat the absence of a footer as a known signal. AC-7 covers this.

**Risk 3: open_flight side-effects on failure**
- *Risk*: `open_flight` opens segment 0, writes a partial header, then fails — leaving a half-written first record on disk.
- *Mitigation*: `open_flight` writes the header via `serialise(header_record)` first, computes the byte string, then performs a single `write()` + `fsync()`; on failure the segment file is closed and unlinked (since segment 0 is empty by construction at this point, deletion is safe). AC-4 covers this.

## Runtime Completeness

- **Named capability**: per-flight self-describing FDR (architecture / E-C13 / AC-NEW-3 every-payload-class-from-t=0; AC-NEW-3 audit trail).
- **Production code that must exist**: real `FlightHeader` and `FlightFooter` dataclasses, real header/footer record append paths, real four-counter accounting in the writer-thread loop, real `clean_shutdown` flag.
- **Allowed external stubs**: none — the header/footer + counters are the production runtime audit capability.
- **Unacceptable substitutes**: header-or-footer-only emission ("we'll add the other one later"), counter values stored only in logs ("the log file is the audit trail"), or counters that DON'T include header/footer in `records_written` ("only producer records count") — the latter would force operators to do special-case math at audit time and is exactly the kind of off-by-N bug AC-NEW-3 traceability is meant to prevent.
