mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 17:41:13 +00:00
[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:
@@ -1,97 +0,0 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user