# Contract: fdr_client_protocol

**Component**: shared_fdr_client (cross-cutting concern owned by E-CC-FDR-CLIENT / AZ-247)
**Producer task**: AZ-273 — `_docs/02_tasks/todo/AZ-273_fdr_client_ringbuf.md`
**Consumer tasks**: every onboard component that emits FDR records (C1–C13), the C13 writer thread (AZ-248 / E-C13), the overrun-policy task (AZ-XX / E-CC-FDR-CLIENT #3), `FakeFdrSink` (AZ-XX / E-CC-FDR-CLIENT #4), and the composition root (`runtime_root.py`)
**Version**: 1.0.0
**Status**: draft
**Last Updated**: 2026-05-10

## Purpose

Frozen public surface for the producer-side FDR queue. Every onboard producer holds exactly one `FdrClient(producer_id)`, calls `enqueue(record)`, and never blocks. The C13 writer thread is the sole consumer via `pop_one` / `drain`. The `on_overrun` hook is the documented extension point through which the overrun-policy PBI (next task in this epic) implements drop-oldest + `kind="overrun"` emission — without this hook, overrun behaviour would be hard-coded into the queue and AC-NEW-3 ("no silent drops") would be unobservable from outside.

## Shape

### Function and method APIs

```python
from typing import Callable
from .fdr_record_schema import FdrRecord  # owned by AZ-272

class EnqueueResult:
    OK = "ok"
    OVERRUN = "overrun"

class FdrSpscViolationError(RuntimeError):
    """Raised when the SPSC contract is violated (concurrent dequeue, multi-producer enqueue)."""

class FdrClient:
    def __init__(self, producer_id: str, capacity: int) -> None: ...
    @property
    def producer_id(self) -> str: ...
    @property
    def on_overrun(self) -> Callable[[FdrRecord], None] | None: ...
    @on_overrun.setter
    def on_overrun(self, hook: Callable[[FdrRecord], None] | None) -> None: ...

    # Producer-side (single-threaded per FdrClient; lock-free; never blocks).
    def enqueue(self, record: FdrRecord) -> EnqueueResult: ...

    # Consumer-side (C13 writer; single-threaded per FdrClient; SPSC contract).
    def pop_one(self) -> FdrRecord | None: ...
    def drain(self, max_records: int) -> list[FdrRecord]: ...

    # Test-only.
    def flush(self) -> None: ...

# Module-level factory; preferred entrypoint for production code.
def make_fdr_client(producer_id: str, config: Config) -> FdrClient: ...
```

| Symbol | Required | Description | Constraints |
|--------|----------|-------------|-------------|
| `FdrClient(producer_id, capacity)` | yes | Construct a per-producer client; `capacity` MUST be `>= 16` and a power of two (ring-buffer-friendly) | `producer_id` non-empty; raises `ValueError` otherwise |
| `enqueue(record)` | yes | Non-blocking single-producer enqueue | Returns `OK` on success or `OVERRUN` when buffer is full; never raises into the caller; allocation-free on steady state |
| `on_overrun` (property) | yes | Hook invoked exactly once per overrun event with the would-be-enqueued record | Set by the overrun-policy PBI; default is `None` (records dropped silently is NOT acceptable in production — AC-NEW-3 requires the hook to be wired in `compose_root`) |
| `pop_one()` | yes | Single-consumer dequeue; returns the next record or `None` if empty | SPSC: only ONE thread may call `pop_one` / `drain` |
| `drain(max_records)` | yes | Pop up to `max_records` records in a single call | Same SPSC constraint as `pop_one` |
| `flush()` | yes | Test-only: blocks the calling thread until the buffer is empty | Production code MUST NOT call this on the hot path |
| `make_fdr_client(producer_id, config)` | yes | Factory; reads capacity from `config.fdr_client.<producer_id>.capacity` with documented default; caches one instance per `producer_id` | Two calls with the same `producer_id` return the same instance |

## Invariants

- **Lock-free**: `enqueue` and `pop_one` MUST NOT acquire a lock that any other thread can hold. They MAY use atomic primitives (CAS, single-word reads/writes, memory barriers) — these are not "locks" in the queue's sense.
- **Non-blocking enqueue**: `enqueue` returns within O(1) and never transitions the calling thread to BLOCKED state. When the buffer is full, it returns `OVERRUN` synchronously and invokes `on_overrun(record)` exactly once if the hook is set.
- **Allocation-free steady state**: `enqueue` for an in-buffer record (slot is free) MUST NOT allocate any heap object. The contract test verifies this with a `tracemalloc` snapshot diff (0 new objects).
- **SPSC**: each `FdrClient` instance has at most ONE producer thread (calls `enqueue`) and at most ONE consumer thread (calls `pop_one` / `drain`). Multi-producer or multi-consumer use is undefined behaviour. The instance includes an opt-in guard that raises `FdrSpscViolationError` on concurrent entry — used by the contract test.
- **One client per producer_id**: `make_fdr_client(producer_id, config)` returns the same cached instance on repeat calls. Tests use `_reset_for_tests()` (private, documented in Non-Goals) to clear the cache.
- **Producer_id stamped on every record**: `enqueue` does NOT mutate `record.producer_id` — the caller is responsible for setting it. The contract test verifies that `enqueue(FdrRecord(..., producer_id="c1_vio"))` lands on the consumer side with `producer_id == "c1_vio"`.
- **Cold-start budget**: constructing all FdrClient instances during `compose_root` is a one-time cost; the contract requires per-instance construction p99 ≤ 1 ms on Tier-2 (so ≤ 13 producers × 1 ms = 13 ms within the 1 s `compose_root` budget from the composition_root_protocol contract).

## Non-Goals

- This contract does NOT define the drop-oldest behaviour or what `on_overrun` does — that is the next PBI (AZ-XX) in this epic. The contract only defines the hook signature and the "exactly-once" invariant.
- This contract does NOT define the C13 writer thread, segment files, segment rotation, or 64 GB cap — owned by E-C13 (AZ-248).
- This contract does NOT define the `FdrRecord` schema or its serialisation — owned by AZ-272.
- This contract does NOT define `FakeFdrSink` — owned by the fourth PBI in this epic. `FakeFdrSink` SHOULD conform to `FdrClient`'s public surface so it is a drop-in replacement for component tests.
- `_reset_for_tests()` is intentionally private and test-only. Production code calling it is a contract violation.

## Versioning Rules

- **Breaking changes** (renaming a public method, changing return types, removing a method, weakening an invariant) → new major version + a deprecation pass through every consumer.
- **Non-breaking additions** (adding a new method, adding an optional kwarg with a default, strengthening an invariant) → minor version bump.
- **Patch changes** (doc clarification, performance budget tightening within tested limits) → patch bump.
- The contract test (`tests/contract/fdr_client_protocol.py`) MUST be updated alongside any version bump.

## Test Cases

| Case | Input | Expected | Notes |
|------|-------|----------|-------|
| valid-enqueue-pop-roundtrip | One `enqueue(record)` followed by one `pop_one()` | record returned; second `pop_one()` returns None | basic happy path |
| nonblocking-stalled-consumer | Consumer never calls `pop_one`; producer calls `enqueue` 1025 times into 1024-cap client | every call returns within 50 µs; #1025 returns `OVERRUN` | covers AC-1 |
| allocation-free-steady-state | Warmup, then `tracemalloc` snapshot diff across one `enqueue` | 0 new heap objects | covers AC-2 |
| capacity-from-config | `make_fdr_client("c1_vio", config_with_capacity_4096)` | `client._capacity() == 4096` | covers AC-3 |
| spsc-guard-rejects-multi-consumer | Two threads call `pop_one()` concurrently with guard enabled | `FdrSpscViolationError` raised | covers AC-4 |
| on-overrun-fires-once | Recording closure on `on_overrun`; force one overrun | closure called exactly once with the offending record | covers AC-5 |
| flush-drains | N records buffered, draining consumer thread, call `flush()` | returns only after buffer empty | covers AC-6 |
| empty-producer-id-rejected | `FdrClient(producer_id="")` | `ValueError` mentioning `producer_id` | covers AC-7 |
| invariant-cached-instance | Two `make_fdr_client("c1_vio", config)` calls | same instance | NFR-reliability |
| spsc-guard-rejects-multi-producer | Two threads call `enqueue` concurrently on same client with guard enabled | `FdrSpscViolationError` raised | strengthens AC-4 |
| no-mutation-of-producer-id | `enqueue(FdrRecord(producer_id="c1_vio"))` then `pop_one()` | popped record has `producer_id == "c1_vio"` | invariant test |

## Change Log

| Version | Date | Change | Author |
|---------|------|--------|--------|
| 1.0.0 | 2026-05-10 | Initial contract derived from E-CC-FDR-CLIENT epic (AZ-247) | autodev decompose Step 2 |
