# FdrRecord Schema + Versioned Serialiser

**Task**: AZ-272_fdr_record_schema
**Name**: FdrRecord Schema
**Description**: Define the `FdrRecord` versioned schema (one record kind per payload class — `log`, `vio.tick`, `state.tick`, `tile_match`, `overrun`, `segment_rollover`, `failed_tile_thumbnail`, `mid_flight_tile_snapshot`, etc.) and the matching serialiser/parser pair so every onboard producer emits and post-flight tooling reads the same wire format. Library choice (orjson or msgpack) is pinned at E-BOOT; the schema layer is library-agnostic.
**Complexity**: 3 points
**Dependencies**: AZ-263_initial_structure, AZ-266_log_module
**Component**: shared.fdr_client (cross-cutting; epic AZ-247 / E-CC-FDR-CLIENT)
**Tracker**: AZ-272
**Epic**: AZ-247 (E-CC-FDR-CLIENT)

## Problem

C13 (FdrWriter) and every onboard producer must agree on a single, versioned wire format for FDR records. Without one frozen schema:
- Producers drift in field naming over time, breaking post-flight analysis.
- Forward-compatible parsing is impossible — a new field added in version N+1 silently breaks tooling pinned at version N.
- The cross-component "no silent drops" guarantee (AC-NEW-3) is unenforceable because the `kind=overrun` record has no canonical shape.

## Outcome

- A single `FdrRecord` definition is the only record type any onboard process emits, and the only one C13's writer thread + post-flight tooling parses.
- The schema carries a top-level `schema_version` integer; the parser is forward-compatible — a record at version N is readable by tooling pinned at version N-1 with documented field-set degradation rules.
- Adding a new record `kind` is a minor version bump; renaming or removing a field is a major version bump (covered by `Versioning Rules` in the contract).

## Scope

### Included

- `FdrRecord` outer envelope: `schema_version: int`, `ts: str (ISO 8601 UTC, µs)`, `producer_id: str`, `kind: str`, `payload: object`.
- A closed enum of supported `kind` values for v1.0.0 covering: `log`, `vio.tick`, `state.tick`, `tile_match`, `overrun`, `segment_rollover`, `failed_tile_thumbnail`, `mid_flight_tile_snapshot`, `flight_header`, `flight_footer`. Per-`kind` payload shape is documented in the contract.
- A `serialise(record: FdrRecord) -> bytes` and `parse(buf: bytes) -> FdrRecord` pair. Library is pinned at E-BOOT (orjson or msgpack); the public API hides the choice.
- A forward-compat parser: unknown future fields inside `payload` are preserved on read (deserialised into a generic `extra: dict[str, Any]` bucket); unknown future `kind` values surface as `FdrRecord(kind=<raw>, payload=<raw>)` so tooling can skip them rather than crash.
- Public interface contract published at `_docs/02_document/contracts/shared_fdr_client/fdr_record_schema.md`.

### Excluded

- The lock-free ring buffer (`FdrClient.enqueue`) — owned by AZ-XX (next task in this epic).
- Drop-oldest + overrun-emission policy — owned by the third task in this epic.
- The `FakeFdrSink` test double — owned by the fourth task in this epic.
- The C13 writer thread, segment files, and 64 GB cap — owned by E-C13 (AZ-248).

## Acceptance Criteria

**AC-1: One envelope, every kind**
Given any of the v1.0.0 `kind` values
When a producer constructs an `FdrRecord(kind=<kind>, payload=<payload>)` and `serialise` is called
Then the resulting bytes parse back to a deep-equal `FdrRecord` via `parse`

**AC-2: Forward-compatible parser**
Given a record serialised at schema_version 1.1 (a hypothetical future minor) with an additional payload field `new_field`
When tooling pinned at schema_version 1.0 calls `parse`
Then the record parses successfully; `new_field` is preserved under `payload.extra["new_field"]`

**AC-3: Unknown kind tolerated**
Given a record whose `kind` is not in the v1.0.0 closed enum
When `parse` runs
Then a valid `FdrRecord` is returned with `kind` set to the raw string and `payload` set to the raw decoded object — no exception is raised

**AC-4: Schema version is mandatory**
Given a serialised record missing `schema_version` (or with a non-integer value)
When `parse` runs
Then `FdrSchemaError` is raised with a message naming the offending field

**AC-5: Overrun record shape is canonical**
Given a `kind="overrun"` record
When constructed by any producer
Then `payload` MUST contain `producer_id: str` and `dropped_count: int (>0)` — schema validation rejects payloads missing either field

**AC-6: Producer ID is required on every record**
Given any `FdrRecord` with an empty or missing top-level `producer_id`
When `serialise` runs
Then `FdrSchemaError` is raised — there are no anonymous records on the wire

## Non-Functional Requirements

**Performance**
- `serialise` p99 ≤ 20 µs on Tier-2 for a record with `len(payload) ≤ 16` scalar entries (this is the budget that lets the enqueue path stay within its 5 µs hot-path target — serialisation may run on the writer thread instead of the producer; the contract test asserts the producer path does not call `serialise`).
- `parse` p99 ≤ 50 µs on the same record shape.

**Reliability**
- `serialise` and `parse` are pure functions: same input → byte-identical output (or deep-equal record).
- `FdrSchemaError` is the ONLY exception type either function raises on schema violation; no `KeyError`, `ValueError`, or library-specific exceptions leak to callers.

## Unit Tests

| AC Ref | What to Test | Required Outcome |
|--------|-------------|-----------------|
| AC-1 | Round-trip every v1.0.0 kind | `parse(serialise(r)) == r` for every kind |
| AC-2 | Parse a synthetic v1.1 record (extra field added) at v1.0 | record parses; extra field preserved in `payload.extra` |
| AC-3 | Parse a record with `kind="future.kind"` | record parses; `kind` and `payload` opaque |
| AC-4 | Missing `schema_version` | `FdrSchemaError` mentions `schema_version` |
| AC-5 | `overrun` record missing `dropped_count` | `FdrSchemaError` mentions `dropped_count` |
| AC-6 | Serialise with empty `producer_id` | `FdrSchemaError` mentions `producer_id` |
| NFR-perf | Microbench `serialise` and `parse` on a 16-entry payload | p99 within budget over 10k iterations |
| NFR-reliability | Call `serialise` twice with same input | byte-identical outputs |

## Constraints

- Public surface frozen by `_docs/02_document/contracts/shared_fdr_client/fdr_record_schema.md` v1.0.0.
- Underlying serialisation library (orjson vs. msgpack) is pinned in `pyproject.toml` at E-BOOT and must NOT leak through the public API — the contract talks about bytes in/out only.
- No new dependency beyond what AZ-263 / E-BOOT pinned.

## Risks & Mitigation

**Risk 1: Library choice (orjson vs msgpack) changes after consumers exist**
- *Risk*: Switching from JSON-bytes to msgpack-bytes after producers are wired forces a wire-format migration.
- *Mitigation*: The library is pinned in `pyproject.toml` at AZ-263 / E-BOOT; the contract's `Shape` section documents the chosen wire format and its `magic` prefix so post-flight tooling can detect a wrong-format file fast.

**Risk 2: `payload.extra` preserves bytes-blob fields and bloats memory**
- *Risk*: A future schema adds a large binary field; old tooling preserves it under `extra` and balloons in-memory record size.
- *Mitigation*: The forward-compat rule documents that fields larger than 4 KiB MUST be referenced by sidecar path, not embedded — enforced in the contract `Invariants` section.

## Runtime Completeness

- **Named capability**: `FdrRecord` versioned schema + serialiser/parser pair (architecture / E-CC-FDR-CLIENT / AC-NEW-3, AC-8.5).
- **Production code that must exist**: real schema enforcement (every required field validated), real round-trip tested against the chosen library.
- **Allowed external stubs**: none — the library (orjson/msgpack) is the production dependency.
- **Unacceptable substitutes**: hand-rolled `repr()` -> `eval()` round-trip; "for now we just store dicts and worry about schema later"; serialiser that drops unknown fields silently (breaks forward-compat AC-2).

## Contract

This task produces the contract at `_docs/02_document/contracts/shared_fdr_client/fdr_record_schema.md`.
Consumers MUST read that file — not this task spec — to discover the interface.
