[AZ-385] C5 SourceLabelStateMachine + spoof-promotion gate

Implements Invariants 5 + 8 + AC-NEW-2 / AC-NEW-8: the
EstimatorOutput.source_label now reflects a real state machine
(DEAD_RECKONED → SATELLITE_ANCHORED ↔ VISUAL_PROPAGATED) governed by
a spoof-promotion gate that latches closed on FC SPOOFED GPS health
and re-opens only when BOTH conditions hold — ≥10 s
STABLE_NON_SPOOFED AND next anchor within
spoof_promotion_visual_consistency_tol_m.

Every reject emits a c5.state.spoof_rejected FDR record plus a
subscriber-fan-out STATUSTEXT (severity WARNING, 50-char cap per
MAVLink). FDR and subscriber paths bypass the standard logger so
silencing logs cannot suppress the spoof trail (R07 / AC-6).

GtsamIsam2StateEstimator now eagerly builds the SM from C5StateConfig
in __init__; new public methods notify_gps_health() (delegates to
SM, called by composition root from C8 inbound) and
subscribe_spoof_rejection() (composition root attaches C8's
QgcTelemetryAdapter here). health_snapshot.spoof_promotion_blocked
+ current_estimate.source_label now flow from the live SM.

25 new unit tests across all 12 ACs plus cancellation, subscriber
exception isolation, and estimator wire-up integration cases. One
AZ-384 test renamed + updated to expect DEAD_RECKONED before any
anchor (was VISUAL_PROPAGATED placeholder pre-AZ-385).

Full suite: 632 passed, 2 skipped.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 07:06:38 +03:00
parent 31a300f8a2
commit 7cbd17ee83
7 changed files with 1148 additions and 11 deletions
@@ -0,0 +1,97 @@
# C5 SourceLabelStateMachine + spoof-promotion gate
**Task**: AZ-385_c5_source_label_spoof_gate
**Name**: C5 `SourceLabelStateMachine` + AC-NEW-2 / AC-NEW-8 spoof-promotion gate
**Description**: Implement the `SourceLabelStateMachine` (component-internal helper per description.md § 6) governing the `source_label` value emitted in every `EstimatorOutput`. The state machine inputs: most-recent `PoseEstimate` arrival; FC `GpsHealth` (from `add_fc_imu` window's gps_health field); time since last `SATELLITE_ANCHORED` add. Outputs: `SATELLITE_ANCHORED` | `VISUAL_PROPAGATED` | `DEAD_RECKONED` per the gate logic. Spoof-promotion gate (AC-NEW-2 / AC-NEW-8): NEVER re-introduce a previously-spoofed FC GPS source until BOTH (i) FC `gps_health == STABLE_NON_SPOOFED` for ≥10 s (config `spoof_promotion_min_stable_s`) AND (ii) the next satellite-anchored frame agrees with the FC GPS within `spoof_promotion_visual_consistency_tol_m` (default 30 m). Document EVERY reject in FDR + GCS STATUSTEXT (R07; C5-ST-01 — logging cannot be silenced). Inject the state machine into `GtsamIsam2StateEstimator` via the constructor extension point (AZ-384); wire `health_snapshot.spoof_promotion_blocked` to query the state machine.
**Complexity**: 5 points
**Dependencies**: AZ-384 (outputs + state-machine injection point), AZ-381 / AZ-382 / AZ-383, AZ-263, AZ-269, AZ-266, AZ-272 (FDR), AZ-391 (E-C8 — `GpsHealth` from inbound subscription; co-developed), AZ-397 (E-C8 — GCS STATUSTEXT broadcast via QgcTelemetryAdapter; co-developed; if not ready, this task ships against the AZ-390 Protocol contract surface)
**Component**: c5_state (epic AZ-260 / E-C5)
**Tracker**: AZ-385
**Epic**: AZ-260 (E-C5)
### Document Dependencies
- `_docs/02_document/contracts/c5_state/state_estimator_protocol.md` — Invariants 5 (label reflects gate state), 8 (spoof-rejection logging cannot be silenced).
- `_docs/02_document/components/07_c5_state/description.md` — § 5 spoof-promotion gate semantics; § 6 `SourceLabelStateMachine` helper ownership; § 9 logging.
- `_docs/02_document/architecture.md` — ADR-008 + spoof gate logic; R07.
## Problem
Without this task, every `EstimatorOutput` would carry a static `SATELLITE_ANCHORED` label regardless of actual gate state — defeating AC-NEW-2 / AC-NEW-8 (spoofing-promotion gate) and AC-3.5 (VIO-only fallback under matcher failure). Spoof-rejection events would not be logged → R07 silent rollback risk.
## Outcome
- `src/gps_denied_onboard/components/c5_state/_source_label_sm.py` defining:
- `SourceLabelStateMachine` class (component-internal; not in `__all__`).
- State: current label, `_last_anchored_frame_ns`, `_gps_health_stable_since_ns`, `_pending_promotion`, `_promotion_blocked` (latched until consistency check).
- Method: `update(now_ns, gps_health, last_satellite_anchor_age_ms, last_visual_consistent_with_gps) -> PoseSourceLabel`.
- Method: `is_spoof_promotion_blocked() -> bool`.
- Defensive logging: every state transition emits an INFO log; every rejected promotion emits a WARN log + FDR record + GCS STATUSTEXT (mandatory; unconditional; cannot be silenced via config — AC-NEW-2 / R07 mitigation).
- Configurable thresholds from `config.state.{spoof_promotion_min_stable_s, spoof_promotion_visual_consistency_tol_m}`.
- Wire the state machine into `GtsamIsam2StateEstimator.__init__` via the AZ-78 injection point.
- `health_snapshot.spoof_promotion_blocked` queries `source_label_state_machine.is_spoof_promotion_blocked()`.
- `current_estimate.source_label` is computed from the state machine for every emission.
- Unit tests: gate transitions; ≥10 s STABLE_NON_SPOOFED requirement; consistency check requirement; rejected promotion emits FDR + GCS STATUSTEXT; logging cannot be suppressed (test attempts to suppress via mocked log handler — assertion still fires the FDR + GCS broadcast).
## Scope
### Included
- `SourceLabelStateMachine` impl.
- Wire-up to `GtsamIsam2StateEstimator`.
- Unconditional logging path for spoof-rejection (cannot be silenced).
- FDR record + GCS STATUSTEXT emission on every rejected promotion.
- Unit tests covering all gate transitions + reject-logging mandatory invariant.
### Excluded
- The `GpsHealth` source — owned by AZ-261 (E-C8 inbound); this task imports the DTO surface only.
- The GCS STATUSTEXT broadcast wire — owned by AZ-261 (C8 outbound); this task calls a Protocol method `gcs_broadcast.statustext(text)`; concrete impl in AZ-261.
- AC-5.2 fallback — owned by AZ-81.
- C5-IT-06 / C5-IT-07 / C5-ST-01 — deferred to E-BBT.
## Acceptance Criteria
**AC-1: Initial label is `INIT`-mapped to `DEAD_RECKONED`** — until first successful pose anchor, label is `DEAD_RECKONED`.
**AC-2: First successful anchor → `SATELLITE_ANCHORED`** — only if no spoof-block is active.
**AC-3: Stale anchor → `VISUAL_PROPAGATED`** — when `last_satellite_anchor_age_ms` exceeds threshold AND VIO is healthy.
**AC-4: Spoof detection → block promotion** — when FC reports `gps_health == SPOOFED`, set `_promotion_blocked = True`; subsequent attempts to promote rejected; FDR + GCS STATUSTEXT log the reject.
**AC-5: Spoof recovery requires BOTH conditions**`gps_health == STABLE_NON_SPOOFED` for ≥10 s AND visual-consistent next anchor within tol_m. Either alone does NOT lift the block.
**AC-6: Logging cannot be silenced** — even when log level is set to ERROR (suppressing INFO/WARN/DEBUG), the FDR record AND GCS STATUSTEXT still emit on every reject. Test: mock the logger to drop all records, assert FDR client + GCS broadcast were still called.
**AC-7: `is_spoof_promotion_blocked()`** — returns True when `_promotion_blocked` is set; False otherwise.
**AC-8: `health_snapshot.spoof_promotion_blocked`** — wired through.
**AC-9: Configurable thresholds** — changing `spoof_promotion_min_stable_s` from 10 to 30 changes the gate timing; verified via parametrised test.
**AC-10: State transition logging** — every label change emits ONE INFO log `kind="c5.state.source_label_changed"` with `{from, to, reason}`.
**AC-11: Reject FDR record shape**`kind="c5.state.spoof_rejected"` with `{reason, gps_health, time_since_stable_s, visual_consistency_delta_m}`.
**AC-12: GCS STATUSTEXT severity**`WARNING` per MAVLink convention; message format `"GPS spoof rejected: <reason>"` ≤ 50 chars (MAVLink `STATUSTEXT.text` max).
## Non-Functional Requirements
- `update` p99 ≤ 5 µs (state machine is O(1)).
- Logging path is on the hot path; FDR + GCS broadcast are buffered (AZ-273 `FdrClient.put_nowait`); GCS broadcast is non-blocking.
## Constraints
- Logging cannot be silenced (AC-6; R07 mitigation).
- `gps_health` semantics owned by C8 inbound (AZ-261); this task consumes the DTO surface.
- Single-writer thread (consistent with C5).
## Risks & Mitigation
- **Risk: AZ-261 `GpsHealth` DTO not yet defined** — this task ships against the documented schema surface; if AZ-261 changes the schema, both tasks update lockstep.
- **Risk: GCS STATUSTEXT broadcast is best-effort** — AZ-261's broadcast may drop messages under load; this task records to FDR REGARDLESS of GCS broadcast success.
## Runtime Completeness
- **Named capability**: source-label state machine + spoof-promotion gate.
- **Production code**: real state machine, real FDR record + GCS STATUSTEXT emission, real configurable thresholds.
- **Unacceptable substitutes**: a label-rotation timer (would not detect spoof); silently swallowing reject logs (R07); a config flag that disables the spoof gate (defeats AC-NEW-2).