fix(sensing): recover saved empty reference without renewing authority

This commit is contained in:
Dragan Spiridonov committed 2026-10-12 10:37:11 +02:00
1 parent ce93244117
commit 05c7ec032d
4 files changed
+250 -2

No files matched your search

@@ -72,6 +72,27 @@ host to provide a protected state directory.
## Consequences
### Explicit recovery without restarting, 2026-10-12
`POST /api/v1/calibration/bootstrap/recover` requires administrator authority,
the current server boot epoch and the stored payload digest. It validates the
installation-bound file and original expiry before changing live state, and
requires at least ten seconds of eligible raw CSI on its original source/grid.
An active unfinished capture must be cancelled first.
Recovery clears old source sequence faults and runtime windows, withdraws any
full room session/receipt, and restores only `bootstrap_only`. New packets must
build a complete matching runtime window before negative suppression applies.
Duplicates and backward discontinuities remain rejected. No calibration frame
count, training timestamp, threshold or expiry is renewed, and the stored file
is not rewritten. Full calibrated evidence and numeric vitals remain disabled.
The twelve-hour stale boundary still withdraws full calibrated authority. A
restored negative-only prior may match fresh runtime windows while stale until
its original twenty-four-hour hard expiry. Expiry stops matching and recovery.
This resolves the mismatch between the advertised twenty-four-hour prior and
the previous fresh-only runtime gate; it makes no room-selectivity claim.
Startup can use recent measured background structure instead of only generic
heuristics. No accuracy lift is claimed until occupied and empty held out data
from multiple installations is recorded.
@@ -1975,6 +1975,7 @@ mod scope_gate_polarity_tests {
(Method::DELETE, "/api/v1/recording/r1"),
(Method::POST, "/api/v1/config/ground-truth"),
(Method::POST, "/api/v1/calibration/reset"),
(Method::POST, "/api/v1/calibration/bootstrap/recover"),
] {
assert_eq!(
required_scope_for(&m, p),
@@ -264,7 +264,7 @@ pub fn bootstrap_background_match(
observed_at_us: u64,
) -> Option<BootstrapBackgroundMatch> {
if frame_history.len() < OCCUPANCY_WINDOW
|| field.check_freshness(observed_at_us) != CalibrationStatus::Fresh
|| !matches!(field.check_freshness(observed_at_us), CalibrationStatus::Fresh | CalibrationStatus::Stale)
{
return None;
}
@@ -923,6 +923,11 @@ mod tests {
.is_some_and(|score| (0.5..=1.0).contains(&score)));
let short_history: VecDeque<Vec<f64>> = empty_history.iter().take(49).cloned().collect();
// Staleness withdraws full calibrated authority after half the lifetime,
// but the persisted negative-only reference remains useful until expiry.
assert!(bootstrap_background_match(&field, &empty_history, 32_000_000).is_some());
assert!(calibrated_occupancy(&field, &empty_history, 32_000_000).is_none());
assert!(bootstrap_background_match(&field, &empty_history, 62_000_000).is_none());
assert!(!bootstrap_empty_prior_applies(
&field,
&short_history,
@@ -2646,6 +2646,11 @@ impl AppStateInner {
(chrono::Utc::now().timestamp_millis().max(0) as u64)
.saturating_mul(1_000),
) == CalibrationStatus::Fresh
|| (self.bootstrap_baseline_active
&& field.check_freshness(
(chrono::Utc::now().timestamp_millis().max(0) as u64)
.saturating_mul(1_000),
) == CalibrationStatus::Stale)
}
});
if accepted {
@@ -3109,7 +3114,7 @@ mod calibration_expiry_tests {
use super::*;
use wifi_densepose_signal::ruvsense::field_model::FieldModelConfig;
fn finalized_model(calibrated_at_unix_ms: u64) -> FieldModel {
pub(super) fn finalized_model(calibrated_at_unix_ms: u64) -> FieldModel {
let mut model = FieldModel::new(FieldModelConfig {
n_links: 1,
n_subcarriers: 56,
@@ -9486,6 +9491,221 @@ struct BootstrapRefineQuery {
source_node_id: Option<u8>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct BootstrapRecoverRequest {
boot_epoch: String,
content_sha256: String,
}
/// Explicitly restore the verified negative-only prior, just as a clean boot
/// would. This never renews its lifetime or restores a full room receipt.
async fn calibration_recover_bootstrap(
State(state): State<SharedState>,
Json(request): Json<BootstrapRecoverRequest>,
) -> Json<serde_json::Value> {
let mut s = state.write().await;
if request.boot_epoch != s.calibration_boot_epoch
|| !valid_binding_digest(&request.content_sha256)
|| s.bootstrap_baseline.as_ref().is_none_or(|metadata| {
metadata.content_sha256 != request.content_sha256
})
{
return Json(serde_json::json!({
"success": false, "error_code": "bootstrap_recovery_identity_mismatch",
"error": "The saved reference or server boot changed. Refresh calibration status before restoring it.",
}));
}
if s.field_model.as_ref().is_some_and(|model| matches!(
model.status(), CalibrationStatus::Uncalibrated | CalibrationStatus::Collecting
)) && s.calibration_session_id.is_some() {
return Json(serde_json::json!({
"success": false, "error_code": "calibration_capture_active",
"error": "Cancel the active capture before restoring the saved empty reference.",
}));
}
let Some(installation_id) = s.installation_id.as_deref() else {
return Json(serde_json::json!({
"success": false, "error_code": "installation_id_required",
"error": "A stable installation identity is required to restore the saved reference.",
}));
};
let path = bootstrap_baseline::path_in(&s.data_dir);
// Validate disk content before touching the live model, histories or faults.
let (model, metadata) = match bootstrap_baseline::load(
&path, installation_id, chrono::Utc::now().timestamp_millis().max(0) as u64,
) {
Ok(value) => value,
Err(_) => return Json(serde_json::json!({
"success": false, "error_code": "bootstrap_reference_unavailable",
"error": "The saved empty reference is expired or failed validation. Collect a new baseline.",
})),
};
if metadata.content_sha256 != request.content_sha256 {
return Json(serde_json::json!({
"success": false, "error_code": "bootstrap_recovery_identity_mismatch",
"error": "The saved reference changed. Refresh calibration status before restoring it.",
}));
}
let [source_node_id] = metadata.source_node_ids.as_slice() else {
return Json(serde_json::json!({
"success": false, "error_code": "bootstrap_source_invalid",
"error": "The saved reference must have one source radio.",
}));
};
let grid = CsiGridKey::from_bootstrap_grid(metadata.source_grid);
let now = std::time::Instant::now();
let evidence = s.node_states.get(source_node_id).and_then(|node| {
node.calibration_grid_candidates(now).into_iter().find_map(|(candidate, evidence)| {
(candidate == grid
&& evidence.span_s >= CALIBRATION_GRID_MIN_SPAN_S
&& evidence.rate_hz >= CALIBRATION_GRID_MIN_RATE_HZ
&& evidence.max_gap_s < CALIBRATION_GRID_MAX_GAP_S
&& evidence.latest_age_s < CALIBRATION_GRID_MAX_GAP_S).then_some(evidence)
})
});
let Some(evidence) = evidence else {
return Json(serde_json::json!({
"success": false, "error_code": "bootstrap_source_unavailable",
"error": "The saved source radio and CSI grid need at least ten seconds of fresh live data before recovery.",
"source_node_id": source_node_id,
}));
};
let binding = CalibrationGridBinding { source_node_id: *source_node_id, grid, evidence: Some(evidence) };
s.clear_field_model_binding();
s.clear_calibration_sequence_state();
s.calibration_source_node_ids.clear();
s.calibration_source_node_ids.insert(binding.source_node_id);
s.calibration_observed_source_node_ids.clear();
s.calibration_model_id = None;
s.calibration_session_id = None;
s.calibration_binding_digest = None;
s.calibration_model_receipt = None;
s.field_model = Some(model);
s.calibration_grid_binding = Some(binding);
s.bootstrap_baseline = Some(metadata.clone());
s.bootstrap_baseline_active = true;
Json(serde_json::json!({
"success": true, "boot_epoch": s.calibration_boot_epoch,
"status": s.field_model_status_at(chrono::Utc::now().timestamp_millis().max(0) as u64).map(|value| format!("{value:?}").to_lowercase()),
"bootstrap_baseline": metadata, "restored_frames": 0,
"full_calibration_required": true,
"calibrated_evidence_authorized": false, "numeric_vitals_authorized": false,
}))
}
#[cfg(test)]
mod bootstrap_recovery_tests {
use super::*;
fn fixture() -> (tempfile::TempDir, SharedState, BootstrapRecoverRequest) {
let directory = tempfile::tempdir().unwrap();
let now_ms = chrono::Utc::now().timestamp_millis() as u64;
let model = super::calibration_expiry_tests::finalized_model(now_ms);
let mut snapshot = model.export_snapshot().unwrap();
snapshot.config.baseline_expiry_s = 86_400.0;
snapshot.modes.calibrated_at_us = (now_ms - 13 * 3_600_000) * 1_000;
let model = FieldModel::from_snapshot(snapshot, now_ms * 1_000).unwrap();
let grid = CsiGridKey { n_subcarriers: 64, ppdu_type: 0 };
let metadata = bootstrap_baseline::store(
&bootstrap_baseline::path_in(directory.path()), "recovery-test", &[5],
grid.as_bootstrap_grid(), "cal-model-saved", now_ms, &model,
).unwrap();
let mut state = AppStateInner::minimal();
state.installation_id = Some("recovery-test".into());
state.data_dir = directory.path().to_path_buf();
state.bootstrap_baseline = Some(metadata.clone());
state.field_model = Some(model);
state.calibration_session_id = Some("old-session".into());
state.calibration_model_id = Some("old-model".into());
state.calibration_binding_digest = Some("ab".repeat(32));
state.calibration_sequence_fault_node_ids.insert(5);
state.calibration_last_sequences.insert(5, 50_000);
let mut node = NodeState::new();
let now = std::time::Instant::now();
for index in 0..=240 {
node.observe_raw_grid(grid, now - Duration::from_millis(12_000 - index * 50));
}
node.field_model_history.push_back(vec![999.0; 56]);
node.field_model_latest_sequence = Some(50_000);
node.field_model_latest_seen = Some(now);
state.node_states.insert(5, node);
let request = BootstrapRecoverRequest {
boot_epoch: state.calibration_boot_epoch.clone(), content_sha256: metadata.content_sha256,
};
(directory, Arc::new(RwLock::new(state)), request)
}
#[tokio::test]
async fn stale_recovery_retains_expiry_withdraws_full_receipt_and_requires_new_frames() {
let (_directory, state, request) = fixture();
let expiry = state.read().await.bootstrap_baseline.as_ref().unwrap().expires_at_unix_ms;
let Json(response) = calibration_recover_bootstrap(State(state.clone()), Json(request)).await;
assert_eq!(response["success"], true);
assert_eq!(response["bootstrap_baseline"]["expires_at_unix_ms"], expiry);
assert_eq!(response["restored_frames"], 0);
assert_eq!(response["calibrated_evidence_authorized"], false);
let mut s = state.write().await;
assert!(s.bootstrap_baseline_active);
assert!(s.calibration_model_receipt.is_none());
assert!(s.calibration_session_id.is_none());
assert!(s.calibration_sequence_fault_node_ids.is_empty());
assert!(s.node_states[&5].field_model_history.is_empty());
assert!(s.node_states[&5].field_model_latest_seen.is_none());
assert!(!s.bootstrap_empty_prior_applies(chrono::Utc::now().timestamp_millis() as u64));
let frame = s.field_model.as_ref().unwrap().modes().unwrap().baseline[0].clone();
let grid = s.calibration_grid_binding.unwrap().grid;
assert!(s.maybe_feed_calibration_frame(5, grid, 1, &frame, std::time::Instant::now()));
assert!(!s.maybe_feed_calibration_frame(5, grid, 1, &frame, std::time::Instant::now()));
assert!(s.maybe_feed_calibration_frame(5, grid, 100, &frame, std::time::Instant::now()));
assert!(!s.maybe_feed_calibration_frame(5, grid, 2, &frame, std::time::Instant::now()));
assert!(s.calibration_sequence_fault_node_ids.contains(&5));
assert!(!s.explicit_calibration_fresh_at(chrono::Utc::now().timestamp_millis() as u64));
}
#[tokio::test]
async fn rejected_recovery_preserves_live_state_and_saved_image() {
for invalid_case in ["boot", "digest", "tamper", "source", "grid", "capture", "installation", "expired"] {
let (directory, state, mut request) = fixture();
let path = bootstrap_baseline::path_in(directory.path());
match invalid_case {
"boot" => request.boot_epoch = "old-boot".into(),
"digest" => request.content_sha256 = "cd".repeat(32),
"tamper" => std::fs::write(&path, b"{}").unwrap(),
"source" => state.write().await.node_states.clear(),
"grid" => {
state.write().await.node_states.get_mut(&5).unwrap().raw_grid_observations.clear();
}
"capture" => {
state.write().await.field_model = Some(FieldModel::new(field_bridge::single_link_config()).unwrap());
}
"installation" => state.write().await.installation_id = Some("another-installation".into()),
"expired" => {
let mut s = state.write().await;
let now_ms = chrono::Utc::now().timestamp_millis() as u64;
let old = s.field_model.as_ref().unwrap().export_snapshot().unwrap();
let mut old = old;
old.modes.calibrated_at_us = (now_ms - 25 * 3_600_000) * 1_000;
// Store a valid image when it was still young, then load now.
let model = FieldModel::from_snapshot(old, (now_ms - 24 * 3_600_000) * 1_000).unwrap();
let metadata = bootstrap_baseline::store(&path, "recovery-test", &[5],
s.calibration_grid_binding.map_or(CsiGridKey { n_subcarriers:64, ppdu_type:0 }, |b| b.grid).as_bootstrap_grid(),
"cal-model-saved", now_ms - 24 * 3_600_000, &model).unwrap();
request.content_sha256 = metadata.content_sha256.clone(); s.bootstrap_baseline = Some(metadata);
}
_ => unreachable!(),
}
let bytes = std::fs::read(&path).unwrap();
let Json(response) = calibration_recover_bootstrap(State(state.clone()), Json(request)).await;
assert_eq!(response["success"], false, "{invalid_case}");
let s = state.read().await;
assert_eq!(s.calibration_session_id.as_deref(), Some("old-session"), "{invalid_case}");
assert!(s.calibration_sequence_fault_node_ids.contains(&5), "{invalid_case}");
assert_eq!(std::fs::read(&path).unwrap(), bytes);
}
}
}
async fn calibration_start(
State(state): State<SharedState>,
Query(query): Query<CalibrationStartQuery>,
@@ -15236,6 +15456,7 @@ async fn main() {
.route("/api/v1/calibration/cancel", post(calibration_cancel))
.route("/api/v1/calibration/status", get(calibration_status))
.route("/api/v1/calibration/reset", post(calibration_reset))
.route("/api/v1/calibration/bootstrap/recover", post(calibration_recover_bootstrap))
// ADR-044 §5.3: runtime-configurable dedup factor
.route(
"/api/v1/config/dedup-factor",