Scan Pipeline & Device Presence Lifecycle
Scope
Covers what happens after a plugin's rows land in CurrentScan: presence computation, event generation, session/timeline derivation. Not plugin authoring (manifest, data contract, settings) — see plugin-development and docs/PLUGINS_DEV*.md. Not the general *Source attribution system or SQLite audit triggers — see database-patterns; the field-authority section below is the scan-pipeline-local half of that system.
Core tables and views
CurrentScan — ephemeral scratch table. process_plugin_events() populates it for any plugin whose config.json declares mapped_to_table; process_scan() deletes all rows at the end of every cycle. A value written to a CurrentScan row is not readable in a later cycle — the row is gone by then. See Gotcha 2.
Devices — persistent identity + state table.
Events — persistent, append-only log of state-transition events (New Device, Connected, Down Reconnected, Device Down, Disconnected, IP Changed). This is the audit trail; Sessions is derived from it, not the reverse.
Sessions — fully wiped and rebuilt every cycle from Convert_Events_to_Sessions (below), not incrementally updated. Treat it as a materialized query result, not a live connection state machine.
Online_History — one row per scan cycle, feeds the dashboard's online/offline graph. A rollup of devPresentLastScan/devAlertDown/devIsSleeping counts on DevicesView — no state of its own.
Key views
LatestDeviceScan (server/db/db_upgrade.py) — Devices LEFT JOIN'd to the most recent CurrentScan row per (scanMac, scanSourcePlugin) pair, via ROW_NUMBER() OVER (PARTITION BY scanMac, scanSourcePlugin ...). update_devices_data_from_scan() loops over DISTINCT scanSourcePlugin and re-queries this view once per plugin: when two plugins report the same device in one cycle, each contribution is evaluated separately, per field, through the authority mechanism below — they are not merged into one row first.
LatestEventsPerMAC — most recent Event per MAC, joined to Devices and CurrentScan. The "New Connections" query in insert_events() uses it to decide whether a device was previously down (→ Down Reconnected) or new (→ Connected).
Convert_Events_to_Sessions — defines "is this device's session still open." There is no close_session() function anywhere in this codebase. A session closes as an emergent property: pair_sessions_events() sets evePairEventRowid on a New Device/Connected/Down Reconnected Event to point at the next Disconnected/Device Down Event for that MAC; this view sets sesStillConnected = 1 exactly when that pairing is NULL. To close a session, insert the right Events row — never mutate Sessions directly (the one exception is create_new_devices()'s reconnect-insert, in the call order below).
DevicesView — adds computed devIsSleeping/devFlapping/devStatus on top of Devices. The UI and insertOnlineHistory() read presence from this, not the raw Devices table.
process_scan() call order (server/scan/session_events.py) — order is load-bearing
save_own_device(), exclude_ignored_devices()
insert_events(db) — runs before presence updates for this cycle. The Down/Disconnected/Connected queries need the previous cycle's devPresentLastScan to detect a transition. If this ran after the presence update, every query would see the new value and the edge-triggered design would break — firing never, or every cycle.
create_new_devices(db) — runs before presence updates so a brand-new device gets a New Device event, not a Connected event (it has no Devices row yet for step 2's queries to match). Also has a raw INSERT INTO Sessions ... sesStillConnected = 1 for existing devices with no open session — the one place outside the Events-derived path that writes Sessions directly.
update_devices_data_from_scan(db) — field-level updates for existing devices; see the authority mechanism below.
update_sync_hub_node, update_devLastConnection_from_CurrentScan
update_presence_from_CurrentScan(db) — sets devPresentLastScan from CurrentScan for this cycle (step 2 reads this as "previous" on the next cycle).
update_devPresentLastScan_based_on_nics(db) — NIC/parent-child presence aggregation; can override step 6 for parent devices.
update_devPresentLastScan_based_on_force_status(db) — the user's manual devForceStatus override; runs last, wins over everything above.
update_vendors_from_mac, update_ipv4_ipv6, update_icons_and_types
pair_sessions_events(db) — pairs Events rows as described above.
create_sessions_snapshot(db) — DELETE FROM Sessions; INSERT INTO Sessions SELECT * FROM Convert_Events_to_Sessions. Sessions reflects step 10's pairing from here.
insertOnlineHistory(db) — dashboard graph rollup.
skip_repeated_notifications(db)
DELETE FROM CurrentScan — the table's entire lifetime is one call to process_scan().
Field-write authority for scan-derived updates
update_devices_data_from_scan() (server/scan/device_handling.py) does not overwrite fields from whichever plugin ran most recently. Each trackable field is declared once in FIELD_SPECS (scan_col, source_col, a priority list of plugin prefixes, optional allow_override_if_changed). can_overwrite_field() uses that plus get_plugin_authoritative_settings() (a plugin's own authority-override setting, if any) to decide, per field per row, whether this plugin's value may replace what's there. The paired <field>Source column (devNameSource, devLastIPSource, etc.) records who currently owns the field. devMac is never a target of these updates — it's the join key, not a tracked field — so no scan-derived update can alter a device's identity, only its attributes.
This is the scan-pipeline-local half of a bigger attribution system — see database-patterns for FIELD_SOURCE_MAP/server/db/authoritative_handler.py, the full *Source model, and the SQLite triggers that consume it for audit logging. Read both before touching anything that writes a *Source column.
Gotchas
- A "presence" check exists in more than one place. A per-row signal meaning "don't count this as a live sighting" (e.g.
scanPresence) has to reach every query that independently re-derives "is this MAC currently present" from CurrentScan. current_scan_presence_condition() (server/scan/presence.py) centralizes that check for five sites: update_presence_from_CurrentScan() (both statements), update_devLastConnection_from_CurrentScan(), and three of insert_events()'s four queries (both Device Down variants, Disconnected). Two sites can't use it: the "New Connections" query and the raw Sessions insert in create_new_devices() need the actual scanLastIP/scanVendor value off the presence-asserting row via MIN()/GROUP BY, not just a boolean. Check any new presence-adjacent query against both patterns — a bare helper call isn't always enough.
CurrentScan is deleted at the end of every cycle — a per-row flag on it can't express a decision that needs to survive to a cycle where the row is gone. Anything that fires because a row is missing (Device Down, Disconnected) can't read a flag that lived on that row. A per-row plugin signal that needs to affect behavior beyond its own cycle has to persist onto the Devices row at creation time (e.g. seeding devAlertDown/devAlertEvents from the row's flag instead of the global NEWDEV_* defaults), not ride on the ephemeral table.
CurrentScan is not small, and it's indexed on scanMac. Real production users run 10,000+ devices; with one row per contributing plugin (see LatestDeviceScan above), a single cycle's CurrentScan is routinely 20,000-50,000+ rows. idx_currentscan_scanmac (server/db/db_upgrade.py:ensure_CurrentScan(), mirrored in server/db/schema/app.sql) covers every scanMac-keyed lookup in this file. ensure_CurrentScan()'s DROP TABLE/CREATE TABLE runs once, at app startup (DB.initDB(), server/__main__.py) — don't confuse this with the per-cycle DELETE FROM CurrentScan in point 1, which clears rows but leaves the table and its index in place.
server/plugins/sync/sync.py bypasses this pipeline on purpose, twice — a permanent exception, not a bug. It fires its own direct INSERT OR IGNORE INTO Events (... 'New Device' ...) for newly-seen synced devices (hardcoded evePendingAlertEmail = 1, no scanNotificationMode awareness), and in carbon-copy mode its own raw Devices UPSERT via ON CONFLICT(devMac) DO UPDATE — both skip create_new_devices()/update_devices_data_from_scan()/can_overwrite_field() (sync.py's own comments: "Node is fully authoritative in this mode"). It's a normal mapped_to_table: CurrentScan plugin for its presence contribution, so IMPORT_ON/scanPresence apply to it like any other plugin — but its two direct-write paths ignore scanNotificationMode = 'quiet' or scanCreatesDevice = 0. Don't assume every Events/Devices write goes through the generic pipeline — sync.py doesn't.
- A blank/null-equivalent
scanMac can create a phantom Devices row. create_new_devices()'s two creation-path queries filter scanMac NOT IN (NULL_EQUIVALENTS_SQL) (server/scan/device_handling.py, const.NULL_EQUIVALENTS_SQL) as a backstop, because scanCreatesDevice defaults to 1 — any plugin reporting a row with no real MAC, without setting scanCreatesDevice = 0 itself, would otherwise create a devMac = '' device, and every other blank-MAC row from every other plugin would then silently write onto it. The filter doesn't replace scanCreatesDevice = 0 as the correct thing for a plugin to set on such rows; it keeps a MAC-less row inert when some other plugin forgets to. Check any new creation-adjacent query against blank scanMac too.
app.sql is not dead code. install/production-filesystem/entrypoint.d/25-first-run-db.sh pipes it into sqlite3 to bootstrap a brand-new database on first install; scripts/db_cleanup/regenerate-database.sh uses it too. CurrentScan, Parameters, and Settings are safe from drift: each has a dedicated ensure_X() function (server/db/db_upgrade.py) that drops and recreates the table on every startup, superseding whatever app.sql bootstrapped. Plugins_Language_Strings gets the same treatment inside the shared ensure_plugins_tables(). AppEvents gets its own drop/recreate via AppEvent_obj.__init__() (server/workflows/app_events.py), independent of db_upgrade.py. Devices has no drop/recreate, but server/database.py has 18 explicit ensure_column() calls that backfill any column missing from an older app.sql snapshot on every startup. Events, Sessions, and Notifications get the same backfill via ensure_table_columns() (server/db/db_upgrade.py), driven by one Python column-list constant per table (server/db/schema_columns.py) that's diffed against app.sql in CI (test/db/test_schema_drift_guard.py). AppEvents/Notifications each also have a second schema-definition surface — their own inline CREATE TABLE IF NOT EXISTS in server/workflows/app_events.py/server/models/notification_instance.py — kept in sync by the same drift-check test. Check any new query here with EXPLAIN QUERY PLAN at a realistic row count rather than assuming it's fine because it resembles an existing one — a correlated subquery re-evaluated per row (an accidental self-join) is the pattern most likely to look reasonable while actually being quadratic at this scale.
When to read this vs. other docs/skills
- Writing or reviewing a plugin's
config.json/data contract → plugin-development, docs/PLUGINS_DEV*.md. This skill covers what happens after a plugin's rows land in CurrentScan, not the authoring contract.
- Devices-table write paths,
*Source attribution, audit/history logging, SQLite triggers → database-patterns.
- Implementing a change here → read the actual function in
server/scan/session_events.py/server/scan/device_handling.py first; this skill's line numbers are a map, not a guarantee, and drift as the code moves.
1---2name: scan-pipeline3description: Read before modifying the scan pipeline (server/scan/session_events.py, server/scan/device_handling.py). Covers process_scan()'s call order and why it's load-bearing, the CurrentScan/Events/Sessions/DevicesView relationships, how a session actually closes (there is no close function), and the FIELD_SPECS field-write authority mechanism. Use this when touching device presence, connect/disconnect events, or session/timeline behavior.4---56# Scan Pipeline & Device Presence Lifecycle78## Scope910Covers what happens after a plugin's rows land in `CurrentScan`: presence computation, event generation, session/timeline derivation. Not plugin authoring (manifest, data contract, settings) — see `plugin-development` and `docs/PLUGINS_DEV*.md`. Not the general `*Source` attribution system or SQLite audit triggers — see `database-patterns`; the field-authority section below is the scan-pipeline-local half of that system.1112## Core tables and views1314- **`CurrentScan`** — ephemeral scratch table. `process_plugin_events()` populates it for any plugin whose `config.json` declares `mapped_to_table`; `process_scan()` deletes all rows at the end of every cycle. A value written to a `CurrentScan` row is not readable in a later cycle — the row is gone by then. See Gotcha 2.15- **`Devices`** — persistent identity + state table.16- **`Events`** — persistent, append-only log of state-transition events (`New Device`, `Connected`, `Down Reconnected`, `Device Down`, `Disconnected`, `IP Changed`). This is the audit trail; `Sessions` is derived from it, not the reverse.17- **`Sessions`** — fully wiped and rebuilt every cycle from `Convert_Events_to_Sessions` (below), not incrementally updated. Treat it as a materialized query result, not a live connection state machine.18- **`Online_History`** — one row per scan cycle, feeds the dashboard's online/offline graph. A rollup of `devPresentLastScan`/`devAlertDown`/`devIsSleeping` counts on `DevicesView` — no state of its own.1920## Key views2122- **`LatestDeviceScan`** (`server/db/db_upgrade.py`) — `Devices` LEFT JOIN'd to the most recent `CurrentScan` row per `(scanMac, scanSourcePlugin)` pair, via `ROW_NUMBER() OVER (PARTITION BY scanMac, scanSourcePlugin ...)`. `update_devices_data_from_scan()` loops over `DISTINCT scanSourcePlugin` and re-queries this view once per plugin: when two plugins report the same device in one cycle, each contribution is evaluated separately, per field, through the authority mechanism below — they are not merged into one row first.23- **`LatestEventsPerMAC`** — most recent Event per MAC, joined to `Devices` and `CurrentScan`. The "New Connections" query in `insert_events()` uses it to decide whether a device was previously down (→ `Down Reconnected`) or new (→ `Connected`).24- **`Convert_Events_to_Sessions`** — defines "is this device's session still open." There is no `close_session()` function anywhere in this codebase. A session closes as an emergent property: `pair_sessions_events()` sets `evePairEventRowid` on a `New Device`/`Connected`/`Down Reconnected` Event to point at the next `Disconnected`/`Device Down` Event for that MAC; this view sets `sesStillConnected = 1` exactly when that pairing is `NULL`. To close a session, insert the right `Events` row — never mutate `Sessions` directly (the one exception is `create_new_devices()`'s reconnect-insert, in the call order below).25- **`DevicesView`** — adds computed `devIsSleeping`/`devFlapping`/`devStatus` on top of `Devices`. The UI and `insertOnlineHistory()` read presence from this, not the raw `Devices` table.2627## `process_scan()` call order (`server/scan/session_events.py`) — order is load-bearing28291. `save_own_device()`, `exclude_ignored_devices()`302. `insert_events(db)` — runs before presence updates for this cycle. The Down/Disconnected/Connected queries need the *previous* cycle's `devPresentLastScan` to detect a transition. If this ran after the presence update, every query would see the new value and the edge-triggered design would break — firing never, or every cycle.313. `create_new_devices(db)` — runs before presence updates so a brand-new device gets a `New Device` event, not a `Connected` event (it has no `Devices` row yet for step 2's queries to match). Also has a raw `INSERT INTO Sessions ... sesStillConnected = 1` for existing devices with no open session — the one place outside the `Events`-derived path that writes `Sessions` directly.324. `update_devices_data_from_scan(db)` — field-level updates for existing devices; see the authority mechanism below.335. `update_sync_hub_node`, `update_devLastConnection_from_CurrentScan`346. `update_presence_from_CurrentScan(db)` — sets `devPresentLastScan` from `CurrentScan` for this cycle (step 2 reads this as "previous" on the *next* cycle).357. `update_devPresentLastScan_based_on_nics(db)` — NIC/parent-child presence aggregation; can override step 6 for parent devices.368. `update_devPresentLastScan_based_on_force_status(db)` — the user's manual `devForceStatus` override; runs last, wins over everything above.379. `update_vendors_from_mac`, `update_ipv4_ipv6`, `update_icons_and_types`3810. `pair_sessions_events(db)` — pairs `Events` rows as described above.3911. `create_sessions_snapshot(db)` — `DELETE FROM Sessions; INSERT INTO Sessions SELECT * FROM Convert_Events_to_Sessions`. `Sessions` reflects step 10's pairing from here.4012. `insertOnlineHistory(db)` — dashboard graph rollup.4113. `skip_repeated_notifications(db)`4214. `DELETE FROM CurrentScan` — the table's entire lifetime is one call to `process_scan()`.4344## Field-write authority for scan-derived updates4546`update_devices_data_from_scan()` (`server/scan/device_handling.py`) does not overwrite fields from whichever plugin ran most recently. Each trackable field is declared once in `FIELD_SPECS` (`scan_col`, `source_col`, a `priority` list of plugin prefixes, optional `allow_override_if_changed`). `can_overwrite_field()` uses that plus `get_plugin_authoritative_settings()` (a plugin's own authority-override setting, if any) to decide, per field per row, whether this plugin's value may replace what's there. The paired `<field>Source` column (`devNameSource`, `devLastIPSource`, etc.) records who currently owns the field. `devMac` is never a target of these updates — it's the join key, not a tracked field — so no scan-derived update can alter a device's identity, only its attributes.4748This is the scan-pipeline-local half of a bigger attribution system — see `database-patterns` for `FIELD_SOURCE_MAP`/`server/db/authoritative_handler.py`, the full `*Source` model, and the SQLite triggers that consume it for audit logging. Read both before touching anything that writes a `*Source` column.4950## Gotchas51521. **A "presence" check exists in more than one place.** A per-row signal meaning "don't count this as a live sighting" (e.g. `scanPresence`) has to reach every query that independently re-derives "is this MAC currently present" from `CurrentScan`. `current_scan_presence_condition()` (`server/scan/presence.py`) centralizes that check for five sites: `update_presence_from_CurrentScan()` (both statements), `update_devLastConnection_from_CurrentScan()`, and three of `insert_events()`'s four queries (both `Device Down` variants, `Disconnected`). Two sites can't use it: the "New Connections" query and the raw `Sessions` insert in `create_new_devices()` need the actual `scanLastIP`/`scanVendor` value off the presence-asserting row via `MIN()`/`GROUP BY`, not just a boolean. Check any new presence-adjacent query against both patterns — a bare helper call isn't always enough.532. **`CurrentScan` is deleted at the end of every cycle — a per-row flag on it can't express a decision that needs to survive to a cycle where the row is gone.** Anything that fires because a row is *missing* (`Device Down`, `Disconnected`) can't read a flag that lived on that row. A per-row plugin signal that needs to affect behavior beyond its own cycle has to persist onto the `Devices` row at creation time (e.g. seeding `devAlertDown`/`devAlertEvents` from the row's flag instead of the global `NEWDEV_*` defaults), not ride on the ephemeral table.543. **`CurrentScan` is not small, and it's indexed on `scanMac`.** Real production users run 10,000+ devices; with one row per contributing plugin (see `LatestDeviceScan` above), a single cycle's `CurrentScan` is routinely 20,000-50,000+ rows. `idx_currentscan_scanmac` (`server/db/db_upgrade.py:ensure_CurrentScan()`, mirrored in `server/db/schema/app.sql`) covers every `scanMac`-keyed lookup in this file. `ensure_CurrentScan()`'s `DROP TABLE`/`CREATE TABLE` runs once, at app startup (`DB.initDB()`, `server/__main__.py`) — don't confuse this with the per-cycle `DELETE FROM CurrentScan` in point 1, which clears rows but leaves the table and its index in place.554. **`server/plugins/sync/sync.py` bypasses this pipeline on purpose, twice — a permanent exception, not a bug.** It fires its own direct `INSERT OR IGNORE INTO Events (... 'New Device' ...)` for newly-seen synced devices (hardcoded `evePendingAlertEmail = 1`, no `scanNotificationMode` awareness), and in `carbon-copy` mode its own raw `Devices` UPSERT via `ON CONFLICT(devMac) DO UPDATE` — both skip `create_new_devices()`/`update_devices_data_from_scan()`/`can_overwrite_field()` (`sync.py`'s own comments: "Node is fully authoritative in this mode"). It's a normal `mapped_to_table: CurrentScan` plugin for its presence contribution, so `IMPORT_ON`/`scanPresence` apply to it like any other plugin — but its two direct-write paths ignore `scanNotificationMode = 'quiet'` or `scanCreatesDevice = 0`. Don't assume every `Events`/`Devices` write goes through the generic pipeline — `sync.py` doesn't.565. **A blank/null-equivalent `scanMac` can create a phantom `Devices` row.** `create_new_devices()`'s two creation-path queries filter `scanMac NOT IN (NULL_EQUIVALENTS_SQL)` (`server/scan/device_handling.py`, `const.NULL_EQUIVALENTS_SQL`) as a backstop, because `scanCreatesDevice` defaults to `1` — any plugin reporting a row with no real MAC, without setting `scanCreatesDevice = 0` itself, would otherwise create a `devMac = ''` device, and every other blank-MAC row from every other plugin would then silently write onto it. The filter doesn't replace `scanCreatesDevice = 0` as the correct thing for a plugin to set on such rows; it keeps a MAC-less row inert when some other plugin forgets to. Check any new creation-adjacent query against blank `scanMac` too.576. **`app.sql` is not dead code.** `install/production-filesystem/entrypoint.d/25-first-run-db.sh` pipes it into `sqlite3` to bootstrap a brand-new database on first install; `scripts/db_cleanup/regenerate-database.sh` uses it too. `CurrentScan`, `Parameters`, and `Settings` are safe from drift: each has a dedicated `ensure_X()` function (`server/db/db_upgrade.py`) that drops and recreates the table on every startup, superseding whatever `app.sql` bootstrapped. `Plugins_Language_Strings` gets the same treatment inside the shared `ensure_plugins_tables()`. `AppEvents` gets its own drop/recreate via `AppEvent_obj.__init__()` (`server/workflows/app_events.py`), independent of `db_upgrade.py`. `Devices` has no drop/recreate, but `server/database.py` has 18 explicit `ensure_column()` calls that backfill any column missing from an older `app.sql` snapshot on every startup. `Events`, `Sessions`, and `Notifications` get the same backfill via `ensure_table_columns()` (`server/db/db_upgrade.py`), driven by one Python column-list constant per table (`server/db/schema_columns.py`) that's diffed against `app.sql` in CI (`test/db/test_schema_drift_guard.py`). `AppEvents`/`Notifications` each also have a second schema-definition surface — their own inline `CREATE TABLE IF NOT EXISTS` in `server/workflows/app_events.py`/`server/models/notification_instance.py` — kept in sync by the same drift-check test. Check any new query here with `EXPLAIN QUERY PLAN` at a realistic row count rather than assuming it's fine because it resembles an existing one — a correlated subquery re-evaluated per row (an accidental self-join) is the pattern most likely to look reasonable while actually being quadratic at this scale.5859## When to read this vs. other docs/skills6061- Writing or reviewing a plugin's `config.json`/data contract → `plugin-development`, `docs/PLUGINS_DEV*.md`. This skill covers what happens *after* a plugin's rows land in `CurrentScan`, not the authoring contract.62- Devices-table write paths, `*Source` attribution, audit/history logging, SQLite triggers → `database-patterns`.63- Implementing a change here → read the actual function in `server/scan/session_events.py`/`server/scan/device_handling.py` first; this skill's line numbers are a map, not a guarantee, and drift as the code moves.