Skip to content

Per-Device Storage Toggle Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A FacilityAdmin-settable per-device storage_disabled toggle that drops bulk-timeseries persistence (S3 audio chunks, metadata_chunks, derived noise samples, UWB edges) for bench/test devices while the device keeps streaming and stays visible live.

Architecture: One new boolean column on devices, threaded through the existing is_active end-to-end pattern (migration → model → DTO → repo COALESCE → PATCH → UI). The load-bearing part is a 30 s-TTL in-memory cache on IngestManager that flush_samples consults; the PATCH handler proactively invalidates it so a toggle takes effect at the next flush. Fail-open on lookup errors.

Tech Stack: Rust 2024 / Axum 0.8 / SQLx 0.8 (PostgreSQL), React 19 + TS + TailwindCSS 4 (admin frontend only).

Spec: docs/superpowers/specs/2026-07-17-device-storage-toggle-design.md

Global Constraints

  • All output (comments, docs, commit messages) in English. No emojis in UI — inline SVG icons only.
  • Commits: GPG-signed (git commit -S), Conventional Commits + gitmoji, one commit per task, NO Co-Authored-By lines. After each commit run ~/flash-shared/gitminer-cuda/mine_commit.sh 7, then git pull --rebase && git push.
  • Migration version prefixes must be strictly monotonic; verify with ls crates/xylolabs-db/migrations | awk -F_ '{print $1}' | sort -c (silent = OK).
  • Validation per change: cargo check (+ cargo clippy — run cargo jobs SERIALLY, never concurrently, per flash-shared filesystem quirk), npx tsc -b --noEmit + npx vite build in frontend/, Playwright at 375×812 / 768×1024 / 1280×800 for UI changes.
  • Integration tests are #[ignore]d (docker Postgres). They MUST compile (cargo check --tests covers this via workspace check); run them only if the test Postgres is up (cargo test -p xylolabs-server --test <file> -- --ignored). If the flash-shared exec stall bites, copy test binaries to /tmp per project memory.
  • Deploy at the end only: bash scripts/deploy.sh to api.xylolabs.com, then post-deploy verification.
  • Do NOT reuse is_active — it is a soft-delete flag with facility-cascade semantics.

Task 1: Schema + model + DTOs + repo + PATCH surface

The whole read/write API surface for the flag, without the ingest gate yet (harmless on its own — the flag persists and round-trips, nothing consumes it until Task 2).

Files: - Create: crates/xylolabs-db/migrations/20260717010000_devices_storage_disabled.sql - Modify: crates/xylolabs-core/src/models/device.rs (~line 142, end of Device struct) - Modify: crates/xylolabs-core/src/dto/ingest.rs (UpdateDeviceRequest ~line 177, DeviceResponse ~line 283) - Modify: crates/xylolabs-core/src/dto/device.rs (DeviceDashboardItem ~line 257) - Modify: crates/xylolabs-db/src/repo/device.rs (SQL ~line 465, update signature ~line 666, binds ~line 748; new scalar fn after update) - Modify: crates/xylolabs-server/src/routes/devices.rs (update handler ~line 538, audit JSON ~line 603, to_response ~line 2354, dashboard mapping ~line 2033) - Test: crates/xylolabs-server/tests/api_devices.rs (append)

Interfaces: - Produces: devices.storage_disabled BOOLEAN NOT NULL DEFAULT false; Device.storage_disabled: bool; UpdateDeviceRequest.storage_disabled: Option<bool>; DeviceResponse.storage_disabled: bool; DeviceDashboardItem.storage_disabled: bool; repo::device::update(..., storage_disabled: Option<bool>) (new LAST param); repo::device::storage_disabled(pool: &PgPool, id: Uuid) -> Result<Option<bool>, sqlx::Error>. - Consumes: nothing new.

  • [ ] Step 1: Write the failing integration test

Append to crates/xylolabs-server/tests/api_devices.rs:

#[tokio::test]
#[ignore] // requires docker test infrastructure
async fn test_storage_toggle_patch_roundtrip() {
    let app = common::TestApp::setup().await;

    // Create a device — new devices default to storage enabled.
    let body = serde_json::json!({ "device_uid": 4242, "name": "Bench Unit" });
    let req = Request::builder()
        .method("POST")
        .uri("/api/v1/devices")
        .header("content-type", "application/json")
        .header("Authorization", app.facility_auth_header())
        .body(Body::from(serde_json::to_string(&body).unwrap()))
        .unwrap();
    let (status, json) = app.request_json(req).await;
    assert_eq!(status, StatusCode::CREATED, "create device failed: {json}");
    assert_eq!(json["storage_disabled"], false);
    let id = json["id"].as_str().unwrap().to_string();

    // Pause storage.
    let patch = serde_json::json!({ "storage_disabled": true });
    let req = Request::builder()
        .method("PATCH")
        .uri(format!("/api/v1/devices/{id}"))
        .header("content-type", "application/json")
        .header("Authorization", app.facility_auth_header())
        .body(Body::from(serde_json::to_string(&patch).unwrap()))
        .unwrap();
    let (status, json) = app.request_json(req).await;
    assert_eq!(status, StatusCode::OK, "pause patch failed: {json}");
    assert_eq!(json["storage_disabled"], true);

    // An unrelated PATCH leaves the flag unchanged (COALESCE semantics).
    let patch = serde_json::json!({ "alias": "bench-1" });
    let req = Request::builder()
        .method("PATCH")
        .uri(format!("/api/v1/devices/{id}"))
        .header("content-type", "application/json")
        .header("Authorization", app.facility_auth_header())
        .body(Body::from(serde_json::to_string(&patch).unwrap()))
        .unwrap();
    let (status, json) = app.request_json(req).await;
    assert_eq!(status, StatusCode::OK, "alias patch failed: {json}");
    assert_eq!(json["storage_disabled"], true);

    // Resume storage.
    let patch = serde_json::json!({ "storage_disabled": false });
    let req = Request::builder()
        .method("PATCH")
        .uri(format!("/api/v1/devices/{id}"))
        .header("content-type", "application/json")
        .header("Authorization", app.facility_auth_header())
        .body(Body::from(serde_json::to_string(&patch).unwrap()))
        .unwrap();
    let (status, json) = app.request_json(req).await;
    assert_eq!(status, StatusCode::OK, "resume patch failed: {json}");
    assert_eq!(json["storage_disabled"], false);
}
  • [ ] Step 2: Verify it fails to compile (field doesn't exist yet)

Run: cargo check --tests -p xylolabs-server 2>&1 | tail -20 Expected: the test compiles fine actually (it's all serde_json) — so instead confirm the CURRENT behavior would reject the field: UpdateDeviceRequest is #[serde(deny_unknown_fields)], so before the DTO change the PATCH would 4xx. Proceed — the compile gate for this task is Step 8.

  • [ ] Step 3: Migration

Create crates/xylolabs-db/migrations/20260717010000_devices_storage_disabled.sql:

-- Per-device storage toggle (2026-07-17,
-- docs/superpowers/specs/2026-07-17-device-storage-toggle-design.md).
--
-- TRUE = the server accepts this device's uploads but does NOT persist bulk
-- timeseries data: S3 audio chunks, metadata_chunks rows, derived noise_db
-- samples, UWB survey edges. Health reports, device_events, and ingest
-- session rows are still stored, so the device stays visible in the fleet
-- list and live dashboards. Operator-set via the admin device PATCH
-- (FacilityAdmin); never written by ingest. Default FALSE = normal
-- persistence. Motivation: bench/test units upload large volumes of data
-- nobody needs to keep.
ALTER TABLE devices
    ADD COLUMN storage_disabled BOOLEAN NOT NULL DEFAULT false;

Run: ls crates/xylolabs-db/migrations | awk -F_ '{print $1}' | sort -c Expected: silent (monotonic). If another migration landed today with a higher prefix, bump this filename above it.

  • [ ] Step 4: Model field

In crates/xylolabs-core/src/models/device.rs, append to the END of struct Device (after identity_collision_suspected_at, ~line 142):

    /// Per-device storage toggle (2026-07-17): TRUE = the server accepts this
    /// device's uploads but drops bulk timeseries (S3 audio chunks,
    /// metadata_chunks, derived noise samples, UWB edges) instead of storing
    /// it. Health reports and device_events still persist. Operator-set via
    /// the admin device PATCH; read by the ingest flush gate through a 30 s
    /// TTL cache on IngestManager. See migration `20260717010000`.
    pub storage_disabled: bool,

(Device derives sqlx::FromRow and every Device query uses SELECT * / RETURNING * / SELECT d.*, so no query changes are needed for reads.)

  • [ ] Step 5: DTOs

In crates/xylolabs-core/src/dto/ingest.rs, inside UpdateDeviceRequest directly after pub is_active: Option<bool>, (~line 177):

    /// Per-device storage toggle: `true` pauses bulk-timeseries persistence
    /// for this device (ingest is still accepted and the device stays live),
    /// `false` resumes it. Omit = no change.
    pub storage_disabled: Option<bool>,

Inside DeviceResponse directly after pub is_active: bool, (~line 283):

    /// Per-device storage toggle: `true` = bulk timeseries from this device
    /// is currently being dropped, not stored (health/events still persist).
    pub storage_disabled: bool,

In crates/xylolabs-core/src/dto/device.rs, inside DeviceDashboardItem after pub identity_collision_suspected_at: ... (~line 297):

    /// Per-device storage toggle: surfaced on the list row so a paused
    /// device is visible fleet-wide. See `DeviceResponse::storage_disabled`.
    pub storage_disabled: bool,
  • [ ] Step 6: Repo update SQL + bind + scalar read

In crates/xylolabs-db/src/repo/device.rs:

a) In DEVICE_FACILITY_SYNC_SQL, directly after is_active = COALESCE($5, is_active), (line ~465) add:

                storage_disabled = COALESCE($18, storage_disabled),

b) update() signature: append a new LAST parameter after location (~line 692):

    // Per-device storage toggle (2026-07-17): appended last per this fn's
    // append-last convention so existing param numbers are untouched;
    // binds $18. None = no change.
    storage_disabled: Option<bool>,

c) Bind chain: after the two location binds (~line 748) add:

        // storage_disabled: plain COALESCE bool ($18), same contract as
        // is_active ($5).
        .bind(storage_disabled)

d) New scalar read, placed directly after update():

/// Per-device storage toggle: the ingest gate's single-column read.
/// `Ok(None)` = device row not found (callers treat it as enabled —
/// fail-open).
pub async fn storage_disabled(pool: &PgPool, id: Uuid) -> Result<Option<bool>, sqlx::Error> {
    sqlx::query_scalar("SELECT storage_disabled FROM devices WHERE id = $1")
        .bind(id)
        .fetch_optional(pool)
        .await
}
  • [ ] Step 7: PATCH handler + audit + response mappings

In crates/xylolabs-server/src/routes/devices.rs:

a) In the repo::device::update(...) call (~line 538-556), append the new final argument after req.location...:

        req.location.as_ref().map(|o| o.as_deref()),
        // Per-device storage toggle.
        req.storage_disabled,

b) In the audit-log JSON (~line 603-612), after the "identity_collision_cleared" line add:

            // Per-device storage toggle: null when the PATCH didn't touch it,
            // true/false = the value the operator set.
            "storage_disabled": req.storage_disabled,

c) In to_response (~line 2354), after is_active: d.is_active,:

        storage_disabled: d.storage_disabled,

d) In the dashboard mapping (DeviceDashboardItem { ... }, ~line 2056), after identity_collision_suspected_at: d.identity_collision_suspected_at,:

                storage_disabled: d.storage_disabled,
  • [ ] Step 8: Compile + lint

Run (serially): cargo check --tests then cargo clippy --all-targets 2>&1 | tail -5 Expected: zero errors. Any DeviceDashboardItem/DeviceResponse/update() construction site the compiler flags that this plan missed: add storage_disabled: <device>.storage_disabled (or None for the repo param) there — the compiler is the source of truth.

  • [ ] Step 9: Run the integration test if the docker Postgres is available; otherwise rely on compile + Task 5 post-deploy verification

Run: cargo test -p xylolabs-server --test api_devices test_storage_toggle_patch_roundtrip -- --ignored --nocapture 2>&1 | tail -5 Expected: test result: ok. 1 passed (or skip if infra is down — note it in the commit's verification).

  • [ ] Step 10: Commit + mine + push
git add crates/xylolabs-db/migrations/20260717010000_devices_storage_disabled.sql \
  crates/xylolabs-core/src/models/device.rs crates/xylolabs-core/src/dto/ingest.rs \
  crates/xylolabs-core/src/dto/device.rs crates/xylolabs-db/src/repo/device.rs \
  crates/xylolabs-server/src/routes/devices.rs crates/xylolabs-server/tests/api_devices.rs
git commit -S -m "feat(devices): ✨ per-device storage_disabled flag + PATCH surface"
~/flash-shared/gitminer-cuda/mine_commit.sh 7
git pull --rebase && git push

Task 2: Ingest flush gate (TTL cache + PATCH invalidation)

Files: - Modify: crates/xylolabs-server/src/ingest/manager.rs (struct field ~line 600, new() ~line 623, consts ~line 663, two new methods near latest_samples_for_device, gate in flush_samples ~line 2207) - Modify: crates/xylolabs-server/src/routes/devices.rs (update handler, after the threshold-cache invalidation block ~line 591) - Test: crates/xylolabs-server/tests/api_ingest.rs (append)

Interfaces: - Consumes: repo::device::storage_disabled(pool, id) from Task 1. - Produces: IngestManager::storage_disabled_for(&self, device_id: Uuid) -> bool (async), IngestManager::invalidate_storage_gate(&self, device_id: Uuid) (async) — Task 3 does NOT use these (it reads the row directly); the PATCH handler uses invalidate_storage_gate.

  • [ ] Step 1: Write the failing integration test

Append to crates/xylolabs-server/tests/api_ingest.rs (the file's existing create_session / build_f64_batch / send_batch / close_session helpers are in scope; session create responses expose the id as json["id"]):

/// Per-device storage toggle: a paused device's flushes must persist NOTHING
/// (no metadata_chunks rows, no byte stats), while the session itself is
/// still accepted; resuming restores persistence.
#[tokio::test]
#[ignore] // requires docker test infrastructure
async fn storage_toggle_gates_flush_persistence() {
    let app = common::TestApp::setup().await;

    // Pre-create the device so we can PATCH it before any session exists.
    let device = xylolabs_db::repo::device::create(
        &app.pool,
        app.facility_id,
        9_960,
        None,
        "storage-toggle-device",
        None,
        None,
        None,
        None,
    )
    .await
    .unwrap();

    // Pause storage via the real PATCH (exercises the cache invalidation).
    let patch = serde_json::json!({ "storage_disabled": true });
    let req = Request::builder()
        .method("PATCH")
        .uri(format!("/api/v1/devices/{}", device.id))
        .header("content-type", "application/json")
        .header("Authorization", app.facility_auth_header())
        .body(Body::from(serde_json::to_string(&patch).unwrap()))
        .unwrap();
    let (status, json) = app.request_json(req).await;
    assert_eq!(status, StatusCode::OK, "pause patch failed: {json}");

    // Session 1 while paused: batch accepted, close-flush persists nothing.
    let streams = serde_json::json!([
        { "stream_id": 0, "name": "temperature", "value_type": "f64", "sample_rate_hz": 10.0 }
    ]);
    let session = create_session(&app, 9_960, streams.clone()).await;
    let session_id = session["id"].as_str().unwrap().to_string();
    let batch = build_f64_batch(0, 1, 1_000_000, 50, 100_000);
    let (status, json) = send_batch(&app, &session_id, &batch).await;
    assert_eq!(status, StatusCode::OK, "send while paused failed: {json}");
    let (status, json) = close_session(&app, &session_id).await;
    assert_eq!(status, StatusCode::OK, "close while paused failed: {json}");

    let chunks: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM metadata_chunks c \
         JOIN metadata_streams s ON s.id = c.stream_id \
         WHERE s.session_id = $1",
    )
    .bind(Uuid::parse_str(&session_id).unwrap())
    .fetch_one(&app.pool)
    .await
    .unwrap();
    assert_eq!(chunks, 0, "paused device must not persist chunks");

    // Resume storage; a fresh session must persist chunks again.
    let patch = serde_json::json!({ "storage_disabled": false });
    let req = Request::builder()
        .method("PATCH")
        .uri(format!("/api/v1/devices/{}", device.id))
        .header("content-type", "application/json")
        .header("Authorization", app.facility_auth_header())
        .body(Body::from(serde_json::to_string(&patch).unwrap()))
        .unwrap();
    let (status, json) = app.request_json(req).await;
    assert_eq!(status, StatusCode::OK, "resume patch failed: {json}");

    let session = create_session(&app, 9_960, streams).await;
    let session_id = session["id"].as_str().unwrap().to_string();
    let batch = build_f64_batch(0, 1, 2_000_000, 50, 100_000);
    let (status, json) = send_batch(&app, &session_id, &batch).await;
    assert_eq!(status, StatusCode::OK, "send after resume failed: {json}");
    let (status, json) = close_session(&app, &session_id).await;
    assert_eq!(status, StatusCode::OK, "close after resume failed: {json}");

    let chunks: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM metadata_chunks c \
         JOIN metadata_streams s ON s.id = c.stream_id \
         WHERE s.session_id = $1",
    )
    .bind(Uuid::parse_str(&session_id).unwrap())
    .fetch_one(&app.pool)
    .await
    .unwrap();
    assert!(chunks > 0, "resumed device must persist chunks again");
}

(If repo::device::create's arity differs from the 9 args used by this file's existing create_db_only_session helper, mirror that helper exactly — it is the canonical call shape.)

  • [ ] Step 2: Run it to make sure it fails (gate not implemented → second half passes, first half fails)

Run: cargo test -p xylolabs-server --test api_ingest storage_toggle_gates_flush_persistence -- --ignored --nocapture 2>&1 | tail -10 Expected: FAIL at paused device must not persist chunks (chunks == 1). Skip if docker infra is down; the compile check still validates the test.

  • [ ] Step 3: Cache field + consts + methods on IngestManager

In crates/xylolabs-server/src/ingest/manager.rs:

a) Struct field, directly before shutdown_token (~line 601):

    /// Per-device storage toggle cache (2026-07-17): device_id → (flag,
    /// fetched_at). `flush_samples` consults it before persisting; entries
    /// older than `STORAGE_GATE_TTL_SECS` re-read the single `devices`
    /// column. The device PATCH invalidates entries proactively (same
    /// process), so the TTL is only the fallback. Bounded by fleet size and
    /// opportunistically pruned past `STORAGE_GATE_MAP_MAX`.
    storage_gate: RwLock<HashMap<Uuid, (bool, Instant)>>,

b) In new() (~line 623), before shutdown_token: ...:

            storage_gate: RwLock::new(HashMap::new()),

c) Consts, next to ANOMALY_COOLDOWN_MAP_MAX (~line 676):

    /// Per-device storage toggle: seconds a cached flag stays fresh. The
    /// device PATCH invalidates proactively; this only bounds staleness if
    /// an invalidation is ever missed (e.g. a future multi-instance deploy).
    const STORAGE_GATE_TTL_SECS: u64 = 30;
    /// Opportunistic prune threshold for the storage-gate map (same hygiene
    /// as ANOMALY_COOLDOWN_MAP_MAX).
    const STORAGE_GATE_MAP_MAX: usize = 4096;

d) Methods, directly before latest_samples_for_device (~line 706):

    /// Per-device storage toggle, fail-open: `true` = drop this device's
    /// bulk-timeseries writes at flush. A DB error or missing row reads as
    /// `false` (persist) — losing production data is worse than storing
    /// extra bench data. Nil device ids (sessions without a resolved
    /// device) always persist.
    pub async fn storage_disabled_for(&self, device_id: Uuid) -> bool {
        if device_id.is_nil() {
            return false;
        }
        let ttl = std::time::Duration::from_secs(Self::STORAGE_GATE_TTL_SECS);
        if let Some((disabled, fetched_at)) =
            self.storage_gate.read().await.get(&device_id).copied()
            && fetched_at.elapsed() < ttl
        {
            return disabled;
        }
        let disabled = match repo::device::storage_disabled(&self.db, device_id).await {
            Ok(flag) => flag.unwrap_or(false),
            Err(e) => {
                tracing::warn!(
                    device_id = %device_id,
                    error = %e,
                    "storage-gate lookup failed; failing open (persisting)"
                );
                false
            }
        };
        let mut map = self.storage_gate.write().await;
        if map.len() >= Self::STORAGE_GATE_MAP_MAX {
            map.retain(|_, (_, fetched_at)| fetched_at.elapsed() < ttl);
        }
        map.insert(device_id, (disabled, Instant::now()));
        disabled
    }

    /// Drop the cached storage-toggle flag for one device so its next flush
    /// re-reads the DB. Called by the admin device PATCH on flag change.
    pub async fn invalidate_storage_gate(&self, device_id: Uuid) {
        self.storage_gate.write().await.remove(&device_id);
    }

(RwLock, HashMap, Instant, Uuid, and repo are already imported in this file — closing_sessions: RwLock<HashMap<Uuid, Instant>> uses all of them.)

  • [ ] Step 4: The flush gate

In flush_samples (~line 2205), directly after the existing empty guard:

        if samples.is_empty() {
            return Ok(());
        }

        // Per-device storage toggle (2026-07-17 design): drop the buffered
        // samples without persisting when the operator paused storage for
        // this device. Returning Ok(()) keeps the device streaming (it sees
        // normal success), and the live-broadcast/latest-sample paths in
        // process_batch already ran — only the S3 upload, metadata_chunks
        // insert, and session byte stats are skipped. Placed before the P739
        // history-trim so a paused device pays no decode/DB work at all.
        if self.storage_disabled_for(device_id).await {
            tracing::debug!(
                session_id = %session_id,
                device_id = %device_id,
                dropped_samples = samples.len(),
                "storage disabled for device; dropping flush"
            );
            return Ok(());
        }
  • [ ] Step 5: PATCH-side proactive invalidation

In crates/xylolabs-server/src/routes/devices.rs::update, directly after the threshold_cache.invalidate_device_hw block (~line 591) and before the log_audit call:

    // Per-device storage toggle: drop the ingest gate's cached flag so the
    // change takes effect at the device's next flush instead of after the
    // 30 s TTL. Post-write ordering mirrors the hw_version bust above.
    if req.storage_disabled.is_some() {
        state.ingest.invalidate_storage_gate(id).await;
    }
  • [ ] Step 6: Compile + lint (serially)

Run: cargo check --tests then cargo clippy --all-targets 2>&1 | tail -5 Expected: zero errors.

  • [ ] Step 7: Run the integration test (if infra available)

Run: cargo test -p xylolabs-server --test api_ingest storage_toggle_gates_flush_persistence -- --ignored --nocapture 2>&1 | tail -5 Expected: PASS.

  • [ ] Step 8: Commit + mine + push
git add crates/xylolabs-server/src/ingest/manager.rs \
  crates/xylolabs-server/src/routes/devices.rs crates/xylolabs-server/tests/api_ingest.rs
git commit -S -m "feat(ingest): ✨ storage-toggle flush gate with TTL cache + PATCH invalidation"
~/flash-shared/gitminer-cuda/mine_commit.sh 7
git pull --rebase && git push

Task 3: UWB accept-and-drop + noise-level skip

Files: - Modify: crates/xylolabs-server/src/routes/uwb_ingest.rs (device resolution ~line 62, gate before the survey transaction ~line 123) - Modify: crates/xylolabs-server/src/services/noise_level.rs (derive_window SQL ~line 209) - Test: crates/xylolabs-server/tests/api_uwb.rs (append)

Interfaces: - Consumes: Device.storage_disabled (Task 1). Deliberately does NOT use the IngestManager cache — both paths already touch the devices row / a DB query, so they read the flag directly (always-fresh, no extra moving parts). - Produces: nothing consumed later.

  • [ ] Step 1: Write the failing test

Append to crates/xylolabs-server/tests/api_uwb.rs (its insert_device helper is in scope; note the file has no ingest-POST test yet — this adds one):

/// Per-device storage toggle: a paused device's UWB edge batches are
/// accepted (200, device stays visible) but nothing is persisted.
#[tokio::test]
#[ignore] // requires the integration Postgres (TEST_DATABASE_URL / :5433)
async fn uwb_edges_dropped_while_storage_disabled() {
    let app = common::TestApp::setup().await;
    let device_id =
        insert_device(&app.pool, app.facility_id, 9_970, 7, "uwb-paused-device").await;
    sqlx::query("UPDATE devices SET storage_disabled = true WHERE id = $1")
        .bind(device_id)
        .execute(&app.pool)
        .await
        .unwrap();

    let edges = serde_json::json!([
        { "i": 0, "j": 1, "dist_m": 2.5, "weight": 1.0 }
    ]);
    let req = Request::builder()
        .method("POST")
        .uri("/api/v1/ingest/uwb-edges?device_id=7")
        .header("content-type", "application/json")
        .header("X-Api-Key", &app.api_key_raw)
        .body(Body::from(serde_json::to_string(&edges).unwrap()))
        .unwrap();
    let (status, json) = app.request_json(req).await;
    assert_eq!(status, StatusCode::OK, "paused uwb post failed: {json}");
    assert_eq!(json["accepted_edges"], 0);
    assert_eq!(json["opened_survey"], false);

    let edge_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM uwb_survey_edges")
        .fetch_one(&app.pool)
        .await
        .unwrap();
    assert_eq!(edge_count, 0, "paused device must not persist uwb edges");
    let survey_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM uwb_surveys")
        .fetch_one(&app.pool)
        .await
        .unwrap();
    assert_eq!(survey_count, 0, "paused device must not open a survey");
}
  • [ ] Step 2: Run it to verify it fails (if infra available)

Run: cargo test -p xylolabs-server --test api_uwb uwb_edges_dropped_while_storage_disabled -- --ignored --nocapture 2>&1 | tail -5 Expected: FAIL (accepted_edges == 1, edge persisted).

  • [ ] Step 3: UWB gate

In crates/xylolabs-server/src/routes/uwb_ingest.rs::ingest_uwb_edges, change the device-resolution block (~line 62-85) to keep the full row, then gate. Replace:

        match repo::device::find_by_device_id(&state.db, facility_id, did as i32).await? {
            Some(d) => Some(d.id),

with:

        match repo::device::find_by_device_id(&state.db, facility_id, did as i32).await? {
            Some(d) => Some(d),

rename the binding device_uuiddevice_row (and its else { None } stays), then directly after the resolution block insert:

    // Per-device storage toggle: accept-and-drop the batch when the operator
    // paused storage for this device. The device keeps posting (200) and its
    // last_seen is still touched so it stays visible in the fleet list, but
    // no survey is opened and no edges are stored. The ack reports a nil
    // survey id and zero accepted/rejected edges (dropped-by-toggle is
    // neither "stored" nor "malformed").
    if let Some(d) = &device_row
        && d.storage_disabled
    {
        if let Err(e) = repo::device::update_last_seen(&state.db, d.id).await {
            tracing::warn!(device_id = %d.id, error = %e, "uwb-edges: failed to update last_seen");
        }
        return Ok(Json(UwbEdgesAck {
            survey_id: uuid::Uuid::nil(),
            accepted_edges: 0,
            rejected_edges: 0,
            opened_survey: false,
        }));
    }
    let device_uuid = device_row.as_ref().map(|d| d.id);

(The trailing let device_uuid = ... line keeps every downstream use — insert_edges, the update_last_seen at the end — compiling unchanged.)

  • [ ] Step 4: Noise-level skip

In crates/xylolabs-server/src/services/noise_level.rs::derive_window, extend the session-selection SQL WHERE clause (~line 209-211). Replace:

        WHERE s.started_at <= $2
          AND (s.closed_at IS NULL OR s.closed_at >= $1)

with:

        WHERE s.started_at <= $2
          AND (s.closed_at IS NULL OR s.closed_at >= $1)
          -- Per-device storage toggle: skip paused devices so no new derived
          -- noise_db chunks are written for them. Their audio source is
          -- already gated at flush; this also stops derivation from chunks
          -- stored just before the toggle flipped.
          AND NOT EXISTS (
              SELECT 1 FROM devices d
              WHERE d.id = s.device_id AND d.storage_disabled
          )
  • [ ] Step 5: Compile + lint (serially)

Run: cargo check --tests then cargo clippy --all-targets 2>&1 | tail -5 Expected: zero errors.

  • [ ] Step 6: Run the test (if infra available)

Run: cargo test -p xylolabs-server --test api_uwb uwb_edges_dropped_while_storage_disabled -- --ignored --nocapture 2>&1 | tail -5 Expected: PASS.

  • [ ] Step 7: Commit + mine + push
git add crates/xylolabs-server/src/routes/uwb_ingest.rs \
  crates/xylolabs-server/src/services/noise_level.rs crates/xylolabs-server/tests/api_uwb.rs
git commit -S -m "feat(ingest): ✨ storage toggle gates uwb edges + derived noise samples"
~/flash-shared/gitminer-cuda/mine_commit.sh 7
git pull --rebase && git push

Task 4: Admin UI (API types, badge, toggle, i18n)

Files: - Modify: frontend/src/api/devices.ts (Device ~line 13, UpdateDeviceBody ~line 102, DeviceDashboardItem ~line 208) - Modify: frontend/src/pages/DevicesPage.tsx (badge component near SentinelBadge ~line 321, status cell ~line 819, DeviceDetailRow props + grid + actions ~lines 374-495, mutations ~line 636, render-site prop ~line 908, ConfirmDialog block ~line 991) - Modify: frontend/src/i18n/index.ts (devices.* keys in BOTH the en and ko blocks — the keyParity test enforces both)

Interfaces: - Consumes: storage_disabled on DeviceResponse/DeviceDashboardItem and UpdateDeviceRequest (Tasks 1-2, already deployed to the type level). - Produces: nothing consumed later.

  • [ ] Step 1: API types

In frontend/src/api/devices.tsDevice interface, after is_active: boolean:

  // Per-device storage toggle: true = the server accepts this device's
  // uploads but drops bulk timeseries (audio + metadata chunks, derived
  // noise, UWB edges) instead of storing it. Health/events still stored.
  storage_disabled: boolean

UpdateDeviceBody, after clear_identity_collision?: boolean:

  // Per-device storage toggle (FacilityAdmin): true pauses bulk-timeseries
  // persistence for this device, false resumes it. Omit = no change.
  storage_disabled?: boolean

DeviceDashboardItem interface, after is_quarantine_sentinel: boolean:

  /** See Device.storage_disabled. */
  storage_disabled: boolean
  • [ ] Step 2: i18n keys

In frontend/src/i18n/index.ts, add to the en block next to the existing 'devices.clearCollision' keys (~line 377):

    'devices.storage': 'Data storage',
    'devices.storageOn': 'Storing',
    'devices.storageOff': 'Storage off',
    'devices.storageOffDesc':
      'The server is accepting this device\'s uploads but not storing its timeseries data.',
    'devices.disableStorage': 'Pause data storage',
    'devices.enableStorage': 'Resume data storage',
    'devices.disableStorageConfirm':
      'Stop storing data from {name}? The device keeps streaming and stays visible live, but audio and sensor history will be dropped until storage is resumed.',

and the mirror keys to the ko block (find it via grep -n "'devices.clearCollision'" frontend/src/i18n/index.ts — second hit):

    'devices.storage': '데이터 저장',
    'devices.storageOn': '저장 중',
    'devices.storageOff': '저장 꺼짐',
    'devices.storageOffDesc':
      '서버가 이 기기의 업로드를 받고 있지만 시계열 데이터를 저장하지 않습니다.',
    'devices.disableStorage': '데이터 저장 일시중지',
    'devices.enableStorage': '데이터 저장 재개',
    'devices.disableStorageConfirm':
      '{name} 기기의 데이터 저장을 중지할까요? 기기는 계속 스트리밍되고 실시간 화면에도 표시되지만, 저장을 재개할 때까지 오디오와 센서 기록은 저장되지 않습니다.',
  • [ ] Step 3: Badge component

In frontend/src/pages/DevicesPage.tsx, next to SentinelBadge (~line 321) add (Heroicons 20/solid circle-stack cylinder + slash overlay — no emoji):

/** Per-device storage toggle: shown when the server is dropping this
 *  device's bulk timeseries instead of storing it. */
function StorageOffBadge() {
  const { t } = useTranslation()
  return (
    <span
      title={t('devices.storageOffDesc')}
      className="inline-flex items-center gap-1 rounded-full bg-slate-200 dark:bg-slate-700 px-2 py-0.5 text-[11px] font-medium text-slate-600 dark:text-slate-300"
    >
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-3 h-3" aria-hidden="true">
        <path d="M10 1c3.866 0 7 1.79 7 4s-3.134 4-7 4-7-1.79-7-4 3.134-4 7-4zm5.694 8.13c.464-.264.91-.583 1.306-.952V10c0 2.21-3.134 4-7 4s-7-1.79-7-4V8.178c.396.37.842.688 1.306.953C5.838 10.006 7.854 10.5 10 10.5s4.162-.494 5.694-1.37zM3 13.179V15c0 2.21 3.134 4 7 4s7-1.79 7-4v-1.822c-.396.37-.842.688-1.306.953-1.532.875-3.548 1.369-5.694 1.369s-4.162-.494-5.694-1.37A6.009 6.009 0 013 13.18z" />
        <path fillRule="evenodd" d="M2.22 2.22a.75.75 0 011.06 0l14.5 14.5a.75.75 0 11-1.06 1.06L2.22 3.28a.75.75 0 010-1.06z" clipRule="evenodd" />
      </svg>
      {t('devices.storageOff')}
    </span>
  )
}
  • [ ] Step 4: Status-cell badge + detail-grid entry + action button

a) Status cell (~line 819), after the OtaBadge line:

                        {device.storage_disabled && <StorageOffBadge />}

b) DeviceDetailRow props (~line 374): add onToggleStorage beside onClearCollision:

function DeviceDetailRow({
  device,
  canManage,
  onClearCollision,
  onToggleStorage,
}: {
  device: Device
  canManage: boolean
  onClearCollision: (d: Device) => void
  onToggleStorage: (d: Device) => void
}) {

c) Detail grid (~line 461), after the devices.active cell:

          <div>
            <p className="text-slate-500 dark:text-slate-300 font-medium uppercase tracking-wide mb-0.5">{t('devices.storage')}</p>
            <p className={clsx('font-medium', device.storage_disabled ? 'text-amber-600 dark:text-amber-400' : 'text-emerald-600')}>
              {device.storage_disabled ? t('devices.storageOff') : t('devices.storageOn')}
            </p>
          </div>

d) Action row (~line 463 <div className="mt-4 flex flex-wrap items-center gap-2">), append after the existing Links (one-click toggle; pausing is confirmed by the parent, resuming is immediate):

          {canManage && (
            <button
              onClick={() => onToggleStorage(device)}
              className={clsx(
                'inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1.5 rounded-lg border',
                device.storage_disabled
                  ? 'text-emerald-700 dark:text-emerald-400 border-emerald-300 dark:border-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-900/30'
                  : 'text-amber-700 dark:text-amber-400 border-amber-300 dark:border-amber-700 hover:bg-amber-50 dark:hover:bg-amber-900/30',
              )}
            >
              <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-3.5 h-3.5" aria-hidden="true">
                <path d="M10 1c3.866 0 7 1.79 7 4s-3.134 4-7 4-7-1.79-7-4 3.134-4 7-4zm5.694 8.13c.464-.264.91-.583 1.306-.952V10c0 2.21-3.134 4-7 4s-7-1.79-7-4V8.178c.396.37.842.688 1.306.953C5.838 10.006 7.854 10.5 10 10.5s4.162-.494 5.694-1.37zM3 13.179V15c0 2.21 3.134 4 7 4s7-1.79 7-4v-1.822c-.396.37-.842.688-1.306.953-1.532.875-3.548 1.369-5.694 1.369s-4.162-.494-5.694-1.37A6.009 6.009 0 013 13.18z" />
              </svg>
              {device.storage_disabled ? t('devices.enableStorage') : t('devices.disableStorage')}
            </button>
          )}
  • [ ] Step 5: Mutation + confirm dialog in the page component

a) Next to clearCollisionMutation (~line 636):

  const [storageTarget, setStorageTarget] = useState<Device | null>(null)
  // Per-device storage toggle. Pausing (ON→OFF) goes through the confirm
  // dialog below since it starts dropping data; resuming applies immediately.
  const storageToggleMutation = useMutation({
    mutationFn: (d: Device) => updateDevice(d.id, { storage_disabled: !d.storage_disabled }),
    onSuccess: () => {
      setStorageTarget(null)
      void qc.invalidateQueries({ queryKey: ['devices'] })
    },
    onError: (err: Error) => {
      setStorageTarget(null)
      setToast(friendlyErrorMessage(err))
    },
  })
  function handleToggleStorage(d: Device) {
    if (d.storage_disabled) storageToggleMutation.mutate(d)
    else setStorageTarget(d)
  }

b) Render site (~line 908): add the prop next to onClearCollision:

                      onClearCollision={setCollisionTarget}
                      onToggleStorage={handleToggleStorage}

c) After the collision ConfirmDialog block (~line 991):

      {storageTarget !== null && (
        <ConfirmDialog
          open={storageTarget !== null}
          title={t('devices.disableStorage')}
          message={t('devices.disableStorageConfirm').replace(
            '{name}',
            storageTarget.alias ?? storageTarget.name ?? storageTarget.dongle_id ?? '',
          )}
          confirmLabel={t('devices.disableStorage')}
          cancelLabel={t('common.cancel')}
          variant="danger"
          onConfirm={() => {
            if (storageTarget) storageToggleMutation.mutate(storageTarget)
          }}
          onCancel={() => setStorageTarget(null)}
        />
      )}
  • [ ] Step 6: Type-check + build (both frontends)

Run: cd frontend && npx tsc -b --noEmit && npx vite build then cd ../frontend-app && npx tsc -b --noEmit && npx vite build Expected: zero errors in both (frontend-app is untouched but must still build clean). Also run the frontend unit tests if wired: cd frontend && npx vitest run src/i18n/keyParity.test.ts — Expected: PASS (en/ko parity holds).

  • [ ] Step 7: Playwright viewport check

Against the deployed site AFTER Task 5's deploy (or a local pnpm dev if preferred): login as xylolabs / solution_6231 at https://admin.api.xylolabs.com, open the Devices page, expand a row, at 375×812 / 768×1024 / 1280×800; assert zero pageerror events and screenshot each. Exercise the toggle once on a test/bench device (pause → badge appears → resume). If deferring to post-deploy, note it and perform in Task 5 Step 4.

  • [ ] Step 8: Commit + mine + push
git add frontend/src/api/devices.ts frontend/src/pages/DevicesPage.tsx frontend/src/i18n/index.ts
git commit -S -m "feat(admin): ✨ per-device storage toggle UI with badge + confirm"
~/flash-shared/gitminer-cuda/mine_commit.sh 7
git pull --rebase && git push

Task 5: Docs + deploy + post-deploy verification

Files: - Modify: docs/API.en.md (device PATCH section — locate with grep -n "PATCH /api/v1/devices" docs/API.en.md) - Modify: docs/API.ko.md (same section — grep -n "PATCH /api/v1/devices" docs/API.ko.md)

Interfaces: none (documentation).

  • [ ] Step 1: English docs

In the PATCH /api/v1/devices/{id} request-field documentation of docs/API.en.md, add (matching the surrounding field-list style):

- `storage_disabled` (boolean, optional) — per-device storage toggle. `true` pauses bulk-timeseries persistence for this device: the server keeps accepting its uploads (sessions open, live view and latest-sample dashboards keep working, health reports and device events are still stored), but S3 audio chunks, metadata chunks, derived noise samples, and UWB survey edges are dropped instead of stored. `false` resumes normal persistence. Takes effect within seconds for in-flight sessions (no reconnect needed). Omit = no change. The flag is returned on all device read endpoints as `storage_disabled` and changes are audit-logged.
  • [ ] Step 2: Korean docs

Mirror in docs/API.ko.md:

- `storage_disabled` (boolean, 선택) — 기기별 저장 토글. `true`로 설정하면 이 기기의 대용량 시계열 저장을 일시중지합니다: 서버는 업로드를 계속 수신하고(세션 열림, 실시간 보기·최신 샘플 대시보드 정상 동작, 상태 보고와 기기 이벤트도 계속 저장) S3 오디오 청크, 메타데이터 청크, 파생 소음 샘플, UWB 측량 엣지는 저장하지 않고 폐기합니다. `false`면 정상 저장을 재개합니다. 진행 중인 세션에도 수 초 내에 적용됩니다(재접속 불필요). 생략 시 변경 없음. 이 플래그는 모든 기기 조회 응답에 `storage_disabled`로 포함되며 변경은 감사 로그에 기록됩니다.
  • [ ] Step 3: Commit + mine + push
git add docs/API.en.md docs/API.ko.md
git commit -S -m "docs(api): 📝 document per-device storage_disabled toggle"
~/flash-shared/gitminer-cuda/mine_commit.sh 7
git pull --rebase && git push
  • [ ] Step 4: Deploy + verify
bash scripts/deploy.sh

Then: 1. curl -s https://admin.api.xylolabs.com/ | grep -o 'assets/index-[^"]*' — bundle hash changed. 2. App container Up (healthy); api.xylolabs.com, admin.api.xylolabs.com, docs.api.xylolabs.com respond. 3. Footer build stamp matches the pushed rev (a split stamp = partial deploy → re-run scripts/deploy.sh). 4. Run the Task 4 Step 7 Playwright pass against production if it was deferred; hard-refresh, zero console errors. 5. Functional spot-check: PATCH /api/v1/devices/{id} with {"storage_disabled": true} on a bench device via the UI toggle; confirm the badge appears, then (after a minute of device traffic) confirm no new chunks: the device timeline shows a gap from the toggle time while the live/latest values keep updating.


Self-Review Notes

  • Spec coverage: migration ✓ model ✓ DTOs (Update/Response/DashboardItem) ✓ repo COALESCE+bind ✓ scalar read ✓ PATCH+audit ✓ TTL cache ✓ flush gate ✓ proactive invalidation ✓ fail-open ✓ UWB accept-and-drop ✓ noise-level skip ✓ session rows still created ✓ (untouched) frontend types/badge/toggle/confirm/i18n ✓ docs EN+KO ✓ tests (PATCH roundtrip, flush gate, UWB drop) ✓ deploy+verify ✓.
  • Names used consistently across tasks: storage_disabled (column/field everywhere), storage_disabled_for / invalidate_storage_gate (manager methods), handleToggleStorage / storageToggleMutation / storageTarget (UI).
  • Line numbers are anchors, not gospel — match on the quoted code, not the number.