# 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).
