# Batch 17 — Cycle 1 Implementation Report

**Batch**: 17 of N
**Tasks landed**: AZ-385 (`SourceLabelStateMachine` + AC-NEW-2 / AC-NEW-8 spoof-promotion gate)
**Cycle**: 1
**Date**: 2026-05-11

## Scope

| Task | Component | Purpose |
|------|-----------|---------|
| AZ-385 | C5 state estimator | Implements Invariant 5 (`source_label` reflects gate state) + Invariant 8 (spoof-rejection always lands in FDR + GCS STATUSTEXT, unsilenceable) + AC-NEW-2 / AC-NEW-8 (gate opens only when BOTH ≥10 s `STABLE_NON_SPOOFED` AND next anchor within `tol_m`). Adds a new `SourceLabelStateMachine` helper module owned by C5; the estimator constructs one eagerly per instance and exposes two new public surfaces — `notify_gps_health(GpsHealth, now_ns=None)` (forwarded from C8 inbound; transitions the gate latch + the stable-dwell timer) and `subscribe_spoof_rejection(cb) -> RejectionSubscription` (composition root wires C8's `QgcTelemetryAdapter.send_statustext` here so STATUSTEXT mirrors fire on the C8 outbound thread). `health_snapshot.spoof_promotion_blocked` is now backed by the live state machine; `current_estimate.source_label` likewise reflects the live label rather than the AZ-384-era hard-coded default. Every reject emits a `c5.state.spoof_rejected` FDR record AND a subscriber callback in the same call — both paths bypass the standard logger so silencing logs cannot suppress the spoof trail (R07 / AC-6). |

## Files added / modified

### Added (prod)

- `src/gps_denied_onboard/components/c5_state/_source_label_sm.py` — new `SourceLabelStateMachine` class. Public read API: `current_label()`, `is_spoof_promotion_blocked()`. Public write API: `notify_gps_health(gps_health, now_ns=None)` (transitions `_promotion_blocked` on first `SPOOFED`, starts the dwell timer on transition to `STABLE_NON_SPOOFED`, clears it on any other status), `notify_satellite_anchor(now_ns, gps_consistency_delta_m)` (updates `_last_anchored_frame_ns`; while blocked, tries to lift the gate using the BOTH-conditions test, otherwise emits a `c5.state.spoof_rejected` FDR record + subscriber fan-out). Subscription API: `subscribe_rejection(cb) -> RejectionSubscription` with cancellable handle. Internal label rules: `DEAD_RECKONED` until first anchor → `SATELLITE_ANCHORED` if gate open AND anchor < 1 s old → `VISUAL_PROPAGATED` otherwise. Threading: explicit `threading.Lock` around every state mutation; subscriber callbacks dispatched OUTSIDE the lock so a slow subscriber doesn't block writers.

### Modified (prod)

- `src/gps_denied_onboard/components/c5_state/gtsam_isam2_estimator.py` — three changes: (1) the constructor now eagerly builds a `SourceLabelStateMachine` from `C5StateConfig.spoof_promotion_min_stable_s` and `spoof_promotion_visual_consistency_tol_m` and the injected `fdr_client`; (2) `add_pose_anchor` on the MARGINALS path now calls a new internal helper `_notify_source_label_anchor(pose)` that forwards the anchor to the SM with `gps_consistency_delta_m=None` (the visual-vs-GPS delta will be supplied by AZ-389 orthorectifier or the composition root once that wiring lands); (3) new public methods `notify_gps_health(gps_health, now_ns=None)` (delegates to the SM) and `subscribe_spoof_rejection(callback) -> RejectionSubscription` (delegates; raises `StateEstimatorConfigError` if `attach_source_label_state_machine` was used with a stub that lacks the subscription surface). The pre-existing `attach_source_label_state_machine` injection point is preserved and re-documented as a test-only override (production code uses the eagerly-built instance).

### Added (tests)

- `tests/unit/c5_state/test_az385_source_label_spoof_gate.py` — 25 tests across all 12 ACs plus subscription cancellation, subscriber-exception isolation, and three estimator-wire-up integration tests. Uses a deterministic synthetic `_Clock` (no real wall-clock dependence). Mocks the SM's logger via `mock.patch.object` to prove AC-6 — FDR and subscriber paths fire even when every `logger.*` call is a no-op. The estimator integration tests use the same `_seed_prior` helper pattern from AZ-388 to seed a minimal iSAM2 prior so `current_estimate` returns without raising.

### Modified (tests)

- `tests/unit/c5_state/test_az384_marginals_outputs.py` — `test_ac9_default_source_label_is_visual_propagated` renamed to `test_ac9_default_source_label_is_dead_reckoned_before_any_anchor` and updated to expect `DEAD_RECKONED`. The previous behaviour (no SM attached → `VISUAL_PROPAGATED`) was an AZ-384-era placeholder; AZ-385 supersedes it by attaching the real SM eagerly. Inline comment cites AC-1 of AZ-385 + Invariant 5 for traceability.

## Architectural notes

- **Eager construction over lazy injection** — AZ-384 left `_source_label_machine` as `Any | None` with `attach_source_label_state_machine` as the wiring seam, on the theory that AZ-385 would own the actual machine instance. With the SM now landed, eagerly constructing it in `__init__` removes the "is it attached yet?" branch from every read path AND lets `health_snapshot.spoof_promotion_blocked` return a meaningful answer from the very first call. `attach_source_label_state_machine` stays available as a test-only override (one test exercises it; see `test_estimator_subscribe_after_attach_with_stub_raises`).
- **Subscriber pattern over direct GCS dependency** — same shape as AZ-388's `FallbackWatcher.subscribe_engaged`. The C5 estimator does NOT take a `GcsAdapter` in its constructor; instead C8's `QgcTelemetryAdapter` registers itself via `subscribe_spoof_rejection` at composition time. This keeps the C5 module's import graph free of C8 dependencies and matches the contract's "C8 owns the STATUSTEXT broadcast wire" phrasing.
- **Unsilenceable logging path (R07 / AC-6)** — both the FDR record and the subscriber fan-out go through dedicated methods (`FdrClient.enqueue`, direct callback invocation) that do NOT touch the `logging` module's filter chain. The informational `c5.state.spoof_rejected` WARN log is emitted AFTER both side-effects, so even if every `logger.*` call is monkey-patched to a no-op, the FDR + STATUSTEXT trail still lands. Verified by `test_ac6_silenced_logger_does_not_suppress_fdr_or_subscriber`.
- **BOTH-conditions promotion (AC-NEW-2 / AC-NEW-8)** — the gate is lifted ONLY when `(now - _gps_health_stable_since_ns) >= min_stable_ns` AND `gps_consistency_delta_m is not None AND <= consistency_tol_m`. Each condition is tested in isolation (`test_ac5_only_stable_dwell_does_not_lift_block`, `test_ac5_only_consistency_does_not_lift_block`) — the AND semantics are not paraphrased into OR by accident.
- **Default `gps_consistency_delta_m=None` from the estimator** — the production wire-up cannot compute the visual-vs-GPS delta yet (AZ-389 orthorectifier supplies the rectified ENU position for cross-check; the composition root will own the comparison once that task lands). Until then, ALL in-block anchor attempts emit a reject for `no_gps_observation` — which is the conservative + correct behaviour (you can't unblock until you have evidence). When AZ-389 lands, the composition root will replace the `gps_consistency_delta_m=None` call with the real delta and the dwell+consistency AND-test will be exercised end-to-end.
- **Anchor-staleness threshold = 1 s** — encoded as a module-level constant `_ANCHOR_STALENESS_THRESHOLD_MS = 1000` rather than a new config field. The C5 contract's "Config schema additions" section already enumerates the fields the operator may tune; adding a sixth field for an internal SM-only decision would be over-configuration. Documented in the module docstring.
- **STATUSTEXT cap at 50 chars (AC-12)** — the formatter pre-caps the message to 50 chars in `_format_statustext`; reason tokens (`gps_spoofed`, `dwell_short`, `consistency`, `no_gps_obs`) are intentionally short so the full `"GPS spoof rejected: <reason>"` fits without truncation in the common case.
- **Reject-reason classification ladder** — the four reasons cover the four possible failure modes of the AND-test: GPS still SPOOFED (highest-priority — never check anything else), no GPS observation at all (`gps_consistency_delta_m is None`), dwell insufficient, consistency violation. The ladder is deterministic; tests assert on the reason string per case.

## Test counts

| Suite | Before (B16) | After (B17) | Delta |
|-------|--------------|-------------|-------|
| Total passing | 607 | 632 | +25 |
| Skipped | 2 | 2 | 0 |
| AZ-385 (new) | 0 | 25 | +25 |
| AZ-384 (preserved) | 27 | 27 | 0 (test renamed + flipped expectation; count unchanged) |

Run command: `PYTHONPATH=src pytest tests/ -q` → `632 passed, 2 skipped in ~32s`.

## Lint / type

- `ruff check src/gps_denied_onboard/components/c5_state/ tests/unit/c5_state/` — clean after one auto-fix (import order in `gtsam_isam2_estimator.py`).
- `ruff format` — 1 file reformatted, 3 already formatted; second pass clean.
- `ReadLints` on touched files — 0 errors.

## Acceptance evidence

| AC | Test(s) | Status |
|----|---------|--------|
| AC-1 Initial label DEAD_RECKONED | `test_ac1_initial_label_is_dead_reckoned`, `test_ac1_initial_label_remains_dead_reckoned_after_gps_only` | PASS |
| AC-2 First anchor → SATELLITE_ANCHORED | `test_ac2_first_anchor_promotes_to_satellite_anchored`, `test_ac2_first_anchor_when_blocked_emits_reject` | PASS |
| AC-3 Stale anchor → VISUAL_PROPAGATED | `test_ac3_stale_anchor_falls_back_to_visual_propagated` | PASS |
| AC-4 Spoof detection latches + emits reject | `test_ac4_spoofed_status_latches_gate_closed`, `test_ac4_reject_fires_subscriber_callback`, `test_ac4_reject_fires_fdr_record` | PASS |
| AC-5 BOTH conditions required | `test_ac5_only_stable_dwell_does_not_lift_block`, `test_ac5_only_consistency_does_not_lift_block`, `test_ac5_both_conditions_lift_block` | PASS |
| AC-6 Logging cannot be silenced | `test_ac6_silenced_logger_does_not_suppress_fdr_or_subscriber` | PASS |
| AC-7 `is_spoof_promotion_blocked()` | `test_ac7_block_query_reflects_latch` | PASS |
| AC-8 `health_snapshot.spoof_promotion_blocked` wired | `test_ac8_health_snapshot_reflects_spoof_block` | PASS |
| AC-9 Configurable thresholds | `test_ac9_min_stable_30s_shifts_dwell_window`, `test_ac9_consistency_tol_5m_rejects_15m_delta` | PASS |
| AC-10 Label-change INFO log | `test_ac10_label_change_emits_info_log` | PASS |
| AC-11 Reject FDR record shape | `test_ac11_reject_fdr_record_shape` | PASS |
| AC-12 STATUSTEXT severity + 50-char cap | `test_ac12_statustext_severity_is_warning`, `test_ac12_statustext_max_50_chars` | PASS |
| Subscription cancellation | `test_subscription_cancel_stops_callbacks` | PASS |
| Subscriber exception isolation | `test_subscriber_exception_does_not_break_state_machine` | PASS |
| Estimator wire-up — label flows | `test_estimator_current_estimate_label_reflects_sm` | PASS |
| Estimator wire-up — subscription delegates | `test_estimator_subscribe_spoof_rejection_returns_handle` | PASS |
| Stub injection raises on subscribe | `test_estimator_subscribe_after_attach_with_stub_raises` | PASS |

## Known gaps / followups

- **Visual-vs-GPS consistency delta** — currently the estimator passes `None` on every anchor notification. AZ-389 (orthorectifier → C6) will supply the rectified ENU position; the composition root will then compute the delta against the FC GPS WGS84 → ENU position and pass it in via a new method (likely `notify_visual_consistency` on the SM). Until that lands the BOTH-conditions test always falls into the `no_gps_observation` reject reason, which is the conservative + safe default.
- **ESKF wire-up** — `EskfStateEstimator` (AZ-386) needs the same SM wire-up (one constructor line + the `add_pose_anchor` hook). Per the AZ-385 contract "both estimators participate"; the AZ-386 wire-up cost will be a single line each.
- **C5-IT-06 / C5-IT-07 / C5-ST-01** — scoped out per AZ-385 § Excluded; live in E-BBT.
- **C8 outbound STATUSTEXT subscription** — AZ-261 will register the C8 `QgcTelemetryAdapter` via `subscribe_spoof_rejection` at composition time. AZ-385 only exposes the subscription point.

## Risks accepted

- **SM lock contention under high anchor rate** — anchor cadence is bounded by D-C5-3 (~3 Hz worst case); lock-acquire is microseconds. Verified informally; if profiling reveals contention later we can swap to a CAS pattern or split into a state-machine-per-input.
- **`gps_consistency_delta_m=None` always rejecting under spoof** — by design until AZ-389 lands. The conservative behaviour is "stay blocked until evidence proves otherwise", which is exactly what R07 wants.
