ADR-0007: Mitigating Spoof Permanence via Hysteresis and NIS Recovery Window¶
- Status: accepted (implementation partial; detection trigger still open)
- Date: 2026-09
- Builds on: ADR-0006 (adaptive inflation), ADR-0005 (chi-square gating)
Context¶
ADR-0006 lets a channel buy bounded covariance after silence, so a healthy
receiver can re-enter a filter whose covariance has collapsed. The inflation
is bounded per-grant and per-block, but the permanence the attack exploits is
different and worse: the grant is large, the filter moves most of the way to
the offset in one update, and every later spoofed fix is then small relative
to the corrupted mean. Measured in tests/test_nis_monitor.py (_denial_then_spoof):
a 22 m post-outage offset is admitted with exactly one grant, and no second
rejection ever occurs to count.
The proposed trigger in the original write-up (escalate a channel that needs inflation grants on consecutive updates) provably does not fire on the measured case: it never requires a second consecutive grant. The grants are one-off.
Decision¶
- Implement the escalation machinery: a
REJECTED_SPOOFstatus, aspoof_lockout_scooldown on the channel, and covariance re-expansion on the block the channel constrains, so a walked-off filter does not coast on a covariance that no longer describes where it is. - Do not pretend the consecutive-grants trigger works. The open question below is the trigger itself.
Open question: the detection signal¶
Measured separators of a spoofed return from an honest one, at grant time:
| Post-outage offset | Innovation / grant sigma |
|---|---|
| 0 m (truth) | 1.09 |
| 10 m | 1.09 |
| 15 m | 1.44 |
| 20 m | 1.95 |
| 22 m | 1.99 |
The honest band and the modest-spoof band overlap below ~1.4 sigma, so a
threshold on this ratio cannot separate a genuine re-acquisition from a modest
spoof. Post-grant re-gating does not help either: after one inflated update the
innovation collapses to ~0 for both truthful and spoofed channels, so there is
no signal left in the channel's own residuals. A second modality (cross-check
against the visual anchor) or an independent drift model is required, and that
is a larger change than this ADR authorises. Until one lands, the escalation
machinery is implemented and unit-tested but the permanence case is pinned as
xfail (TestPermanenceIsStillUndetected), not silently passed.
Update (ADR-0008). That larger change was made, in
src/navkit/estimators/eskf.py, after this ADR was written. It does not change this conclusion. The frozen-anchor cross-check has correct arithmetic and an unsatisfiable guard, so it never runs; the one-grant-per-episode policy named above is what starves both this trigger and that check. The permanence case below therefore remains open, and thexfailstands.
Consequences¶
- A second grant in one episode escalates to
REJECTED_SPOOFand a 60 s cooldown the ordinary recovery hysteresis cannot express. - The cooldown expires the lockout but not the fault: recovery still needs
auto_recovery_countclean updates. - The measured permanence case remains undetected (
xfail), and should stay red-on-purpose until it is not.