# Contract: log_record_schema

**Component**: shared_logging (cross-cutting concern owned by E-CC-LOG / AZ-245)
**Producer task**: AZ-266 — `_docs/02_tasks/todo/AZ-266_log_module.md`
**Consumer tasks**: every component task that emits logs (C1–C13 components, plus C12 operator tooling)
**Version**: 1.0.0
**Status**: draft
**Last Updated**: 2026-05-10

## Purpose

Frozen, machine-parseable JSON envelope for every log record emitted by any onboard component. Stable field set + ordering is a hard requirement for FDR analysis tooling (`kind="log"` records are post-flight queryable) and for the contract test that verifies field-name + ordering invariants.

## Shape

### One JSON object per log line, UTF-8, no trailing comma, newline-terminated

```python
# Conceptual dataclass — actual implementation may emit via orjson / python-json-logger
@frozen
class LogRecord:
    ts: str              # ISO 8601 UTC, microsecond precision, e.g. "2026-05-10T03:14:15.123456Z"
    level: str           # one of {"DEBUG", "INFO", "WARN", "ERROR"} — matches Python stdlib levelname (no "WARNING")
    component: str       # component slug from module-layout.md, e.g. "c2_vpr", "c5_state", "shared.logging"
    frame_id: int | None # monotonic per-flight frame counter; None for non-frame-correlated records (startup, shutdown, periodic)
    kind: str            # categorical tag, e.g. "vio.tick", "vpr.query", "fdr.write", "log.diag"
    msg: str             # human-readable short message, no PII, no stack traces (those go in `exc`)
    kv: dict[str, Any]   # arbitrary structured key-value payload, JSON-safe scalars + nested dict/list only
    exc: str | None      # optional formatted exception traceback for ERROR/WARN; None otherwise
```

| Field | Type | Required | Description | Constraints |
|-------|------|----------|-------------|-------------|
| `ts` | string (ISO 8601 UTC, µs) | yes | Emit timestamp | RFC 3339 with `Z` suffix |
| `level` | string | yes | Log level | strictly one of `DEBUG`, `INFO`, `WARN`, `ERROR` |
| `component` | string | yes | Origin component slug | snake_case, must match a module-layout entry or `shared.<name>` |
| `frame_id` | integer or null | no | Per-flight monotonic frame index | non-negative when present |
| `kind` | string | yes | Record category tag | dotted snake_case, max 64 chars |
| `msg` | string | yes | Human message | no embedded newlines (use `kv` for multi-line context) |
| `kv` | object | yes (may be `{}`) | Structured key-value payload | JSON-safe scalars + nested objects/arrays |
| `exc` | string or null | no | Exception traceback for ERROR/WARN | absent or `null` for INFO/DEBUG |

### Field ordering (REQUIRED — verified by contract test)

`ts, level, component, frame_id, kind, msg, kv, exc` — formatter MUST emit keys in this order. Re-ordering breaks downstream column-aligned parsers used by FDR tooling.

## Invariants

- Every record is a single JSON object on a single line (newline-terminated, no embedded newlines in any field value).
- `level` value uses `WARN` not `WARNING` (intentional, simpler grep target).
- `frame_id` is omitted (`null`) — never invented — when the emitter has no current frame context.
- `kv` values must be JSON-serialisable without custom encoders; binary payloads are base64-encoded strings within `kv`.
- `exc` is present only for `level in {WARN, ERROR}` records that originated from an exception; otherwise it is `null` or absent.
- The schema is strictly additive — no field is ever removed or renamed without a major version bump and a matching FDR record-schema migration in E-CC-FDR-CLIENT.

## Non-Goals

- This contract does not define WHAT to log (per-component § 9 sections own that).
- This contract does not define log routing (stdout vs journald vs FDR — see handler topology in E-CC-LOG epic).
- This contract does not define structured event types — `kind` is a free-form tag, not a closed enum.

## Versioning Rules

- **Breaking changes** (field renamed/removed, type changed, ordering changed, level enum reduced) require a new major version + a deprecation pass through every consumer.
- **Non-breaking additions** (new optional field appended at the end of the order, new `kind` tag, new `level` value) require a minor version bump.
- The contract test (`tests/contract/log_schema.py`) MUST be updated alongside any version bump.

## Test Cases

| Case | Input | Expected | Notes |
|------|-------|----------|-------|
| valid-info-no-frame | `level=INFO, component="c2_vpr", kind="vpr.warmup", msg="loaded model", kv={"model": "salad"}` | accepted; `frame_id=null`, `exc=null`; field order matches spec | Startup-time INFO record |
| valid-warn-with-frame | `level=WARN, component="c5_state", frame_id=4321, kind="state.cov_spike", msg="covariance jumped 5x", kv={"jump_factor": 5.2}` | accepted; key order locked; FDR bridge MUST forward this record | Cross-cuts AC: WARN flows into FDR |
| valid-error-with-exc | `level=ERROR, component="c11_tilemanager", kind="tile.upload_fail", msg="HTTP 503", kv={"tile": "z18/x12345/y67890"}, exc="Traceback (most recent call last):..."` | accepted; `exc` present and non-null; FDR bridge MUST forward | Cross-cuts AC: ERROR + exc captured |
| invalid-bad-level | `level="WARNING"` | rejected with `LogSchemaError` (or formatter logs at ERROR and drops record) | Contract test enforces `WARN` not `WARNING` |
| invalid-multiline-msg | `msg="line1\nline2"` | rejected OR newline replaced with `\\n` literal — formatter must guarantee single-line output | One JSON object per line invariant |
| invalid-non-serialisable-kv | `kv={"obj": <numpy.ndarray>}` | rejected with `LogSchemaError` (caller must convert to list before passing) | JSON-safe-only invariant |
| ordering-stable | any valid record | emitted JSON keys appear in `ts, level, component, frame_id, kind, msg, kv, exc` order regardless of construction order | Contract test parses raw bytes and asserts key order |

## Change Log

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