[AZ-294] [AZ-295] [AZ-296] Finish C13: tile snapshot + record-kind policy + takeoff abort

AZ-294: MidFlightTileSnapshotSink writes orthorectified tile JPEGs
atomically to flight_root/<flight_id>/tiles/<tile_id>.jpg, emits a
kind="mid_flight_tile_snapshot" pointer record, and evicts the oldest
tile when the per-flight 64 MiB cap is exceeded. Adds optional
frame_id to the snapshot payload (fdr_record_schema bump).

AZ-295: RecordKindPolicy with two paired gates:
- enforce_or_raise (producer-side) raises RawFrameWriteForbiddenError
  for raw_nav_frame / raw_ai_cam_frame at the call site, defending
  AC-8.5 / RESTRICT-UAV-4.
- gate_for_writer (writer-side) tumbling-window rate-caps
  failed_tile_thumbnail records at <= 0.1 Hz; over-cap drops are
  coalesced into kind="overrun" records with the originating
  producer slug.

AZ-296: take_off() composition-root sequence with strict ordering
(writer.__init__ -> start -> open_flight -> fc_adapter.__init__ ->
fc_adapter.open). On FdrOpenError, logs ERROR record, calls
writer.stop(), prints the documented FATAL line to stderr, and
sys.exit(EXIT_FDR_OPEN_FAILURE=2). composition_root_protocol bumped
to v1.1.0 with the new constants + takeoff-sequence section.

29 new tests; full suite 356 passed / 2 skipped / 0 failures.
No new dependencies (stdlib only).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 03:52:07 +03:00
parent b5dd6031d2
commit e4ecdaf619
21 changed files with 1657 additions and 9 deletions
@@ -2,25 +2,31 @@
from gps_denied_onboard.config.loader import ENV_KEY_MAP, load_config
from gps_denied_onboard.config.schema import (
DEFAULT_FORBIDDEN_RECORD_KINDS,
Config,
ConfigError,
FdrConfig,
FdrWriterConfig,
LogConfig,
RecordKindPolicyConfig,
RequiredFieldMissingError,
RuntimeConfig,
TileSnapshotConfig,
register_component_block,
)
__all__ = [
"DEFAULT_FORBIDDEN_RECORD_KINDS",
"ENV_KEY_MAP",
"Config",
"ConfigError",
"FdrConfig",
"FdrWriterConfig",
"LogConfig",
"RecordKindPolicyConfig",
"RequiredFieldMissingError",
"RuntimeConfig",
"TileSnapshotConfig",
"load_config",
"register_component_block",
]
+90 -1
View File
@@ -15,17 +15,29 @@ from dataclasses import dataclass, field, fields, is_dataclass, replace
from typing import Any, Final
__all__ = [
"DEFAULT_FORBIDDEN_RECORD_KINDS",
"Config",
"ConfigError",
"FdrConfig",
"FdrWriterConfig",
"LogConfig",
"RecordKindPolicyConfig",
"RequiredFieldMissingError",
"RuntimeConfig",
"TileSnapshotConfig",
"register_component_block",
]
# Default raw-frame kinds that AZ-295's RecordKindPolicy must reject
# synchronously at the producer call site. Removing any of these from
# a Config requires an explicit `unsafe_remove_default_forbidden=True`
# flag (which is intentionally not present in any standard preset).
DEFAULT_FORBIDDEN_RECORD_KINDS: Final[frozenset[str]] = frozenset(
{"raw_nav_frame", "raw_ai_cam_frame"}
)
class ConfigError(RuntimeError):
"""Base class for all config-loader errors that should reach the caller."""
@@ -73,6 +85,80 @@ class FdrWriterConfig:
debug_log_per_record: bool = False
@dataclass(frozen=True)
class TileSnapshotConfig:
"""C13 mid-flight tile snapshot sidecar block (AZ-294).
``tile_snapshot_cap_bytes`` is the per-flight ceiling on the
cumulative size of the ``tiles/`` subdirectory under the flight
root (default 64 MiB to comfortably hold the worst-case ~50 MB
from per-component description.md).
``jpeg_max_bytes`` rejects single tile JPEGs larger than this
bound (default 256 KiB; description.md gives 50-200 KiB).
"""
tile_snapshot_cap_bytes: int = 64 * 1024 * 1024
jpeg_max_bytes: int = 256 * 1024
@dataclass(frozen=True)
class RecordKindPolicyConfig:
"""C13 record-kind policy block (AZ-295).
``forbidden_record_kinds`` lists FdrRecord ``kind`` values that
the producer-side ``enforce_or_raise`` gate rejects with
``RawFrameWriteForbiddenError``. The default set
(``DEFAULT_FORBIDDEN_RECORD_KINDS``) MUST be a subset of the
configured set — removing defaults is a security-review-required
path guarded by ``unsafe_remove_default_forbidden``.
``failed_tile_thumbnail_max_hz`` caps the writer-side rate of
``kind="failed_tile_thumbnail"`` records (default 0.1 Hz per
AC-8.5 + description.md § 7). Setting this to 0 is rejected at
config validation (would silence the kind entirely; that path is
intentionally not exposed).
"""
forbidden_record_kinds: frozenset[str] = field(
default_factory=lambda: DEFAULT_FORBIDDEN_RECORD_KINDS
)
failed_tile_thumbnail_max_hz: float = 0.1
unsafe_remove_default_forbidden: bool = False
def __post_init__(self) -> None:
if not isinstance(self.forbidden_record_kinds, frozenset):
raise ConfigError(
"RecordKindPolicyConfig.forbidden_record_kinds must be a frozenset; "
f"got {type(self.forbidden_record_kinds).__name__}"
)
if not self.unsafe_remove_default_forbidden:
missing_defaults = DEFAULT_FORBIDDEN_RECORD_KINDS - self.forbidden_record_kinds
if missing_defaults:
raise ConfigError(
"RecordKindPolicyConfig.forbidden_record_kinds removes default raw-frame "
f"kinds without unsafe_remove_default_forbidden=True: missing {sorted(missing_defaults)}"
)
if not (
isinstance(self.failed_tile_thumbnail_max_hz, (int, float))
and not isinstance(self.failed_tile_thumbnail_max_hz, bool)
):
raise ConfigError(
"RecordKindPolicyConfig.failed_tile_thumbnail_max_hz must be a number; "
f"got {self.failed_tile_thumbnail_max_hz!r}"
)
if self.failed_tile_thumbnail_max_hz <= 0:
raise ConfigError(
"RecordKindPolicyConfig.failed_tile_thumbnail_max_hz must be > 0; "
f"got {self.failed_tile_thumbnail_max_hz}"
)
if self.failed_tile_thumbnail_max_hz > 10.0:
raise ConfigError(
"RecordKindPolicyConfig.failed_tile_thumbnail_max_hz must be <= 10.0; "
f"got {self.failed_tile_thumbnail_max_hz}"
)
@dataclass(frozen=True)
class FdrConfig:
"""Cross-cutting Flight Data Recorder block (E-CC-FDR-CLIENT / AZ-247).
@@ -82,7 +168,8 @@ class FdrConfig:
producer slug (consumed by AZ-273 ``make_fdr_client``); blocks
that omit a producer fall back to ``queue_size``.
``writer`` is the C13 writer-thread sub-block (AZ-291..AZ-296).
Sub-blocks (AZ-291..AZ-296): ``writer``, ``tile_snapshot``,
``record_policy``.
"""
queue_size: int = 4096
@@ -90,6 +177,8 @@ class FdrConfig:
path: str = "/var/lib/gps-denied/fdr"
per_producer_capacity: Mapping[str, int] = field(default_factory=dict)
writer: FdrWriterConfig = field(default_factory=FdrWriterConfig)
tile_snapshot: TileSnapshotConfig = field(default_factory=TileSnapshotConfig)
record_policy: RecordKindPolicyConfig = field(default_factory=RecordKindPolicyConfig)
@dataclass(frozen=True)