diff --git a/README.md b/README.md
index e243fe6..6162353 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
-
+
diff --git a/backend/package.json b/backend/package.json
index 39b78a9..b4f9e9a 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
- "version": "0.23.7-beta",
+ "version": "0.23.8-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
diff --git a/docs/architecture.md b/docs/architecture.md
index 8c33c2d..c1322b3 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -109,6 +109,43 @@ Statements are written to be safe to re-run — `CREATE TABLE IF NOT EXISTS`,
`ADD COLUMN IF NOT EXISTS`, and guarded constraint swaps — so upgrading is just starting
the new image.
+## Developing against hardware you do not have
+
+Two bugs shipped in v0.23.5 and v0.23.6 because CAPsMAN code was written and
+"verified" against a fleet containing no CAPsMAN. Neither was catchable by unit
+tests: both lived in SQL — a join that could never match, and a `WHERE` clause that
+filtered out every managed radio before the analysis ran.
+
+`scripts/seed-capsman-fixture.sql` reproduces the shape of a real deployment so
+those paths can be exercised locally against the actual endpoints:
+
+```bash
+docker compose exec -T postgres psql -U mikrotik -d mikrotik_manager \
+ -q -f - < scripts/seed-capsman-fixture.sql
+
+# ... work, hit /api/wireless/rf/channels, /api/wireless/capsman ...
+
+docker compose exec -T postgres psql -U mikrotik -d mikrotik_manager \
+ -q -f - < scripts/seed-capsman-fixture-clean.sql
+```
+
+The fixture is deliberately awkward in the ways real deployments are:
+
+- the controller is a **router** with no radios of its own, so anything keyed on
+ `device_type = 'wireless_ap'` misses it
+- CAP interfaces carry **NULL frequency, SSID and band**, because the controller owns
+ the configuration — code that requires a frequency silently drops them
+- the controller **mirrors every CAP interface locally**, so a CAP's radio MAC
+ legitimately appears on two devices
+- channels appear only in RouterOS's `//` form, never
+ as a plain number
+- the radios are arranged to produce one partial overlap and one co-channel pair, so
+ interference classification has something real to get wrong
+
+Everything it creates is prefixed `fixture-`. Add to it rather than working from
+assumptions whenever a report arrives with device output attached — that output is
+the most valuable thing in the issue.
+
## Before you push
`scripts/ci-preflight.sh` runs the same gates as CI — lint, type-check, build, tests and
diff --git a/frontend/package.json b/frontend/package.json
index b61276f..36eea49 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
- "version": "0.23.7-beta",
+ "version": "0.23.8-beta",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/version.ts b/frontend/src/version.ts
index f8c30ed..b43a9ea 100644
--- a/frontend/src/version.ts
+++ b/frontend/src/version.ts
@@ -1 +1 @@
-export const APP_VERSION = 'v0.23.7 Beta';
+export const APP_VERSION = 'v0.23.8 Beta';
diff --git a/scripts/seed-capsman-fixture-clean.sql b/scripts/seed-capsman-fixture-clean.sql
new file mode 100644
index 0000000..2cf12b6
--- /dev/null
+++ b/scripts/seed-capsman-fixture-clean.sql
@@ -0,0 +1,5 @@
+-- Remove everything seed-capsman-fixture.sql created. Devices cascade to their
+-- wireless_interfaces and capsman_* rows.
+DELETE FROM devices WHERE name LIKE 'fixture-%';
+SELECT 'cleaned' AS status,
+ (SELECT count(*) FROM devices WHERE name LIKE 'fixture-%') AS remaining;
diff --git a/scripts/seed-capsman-fixture.sql b/scripts/seed-capsman-fixture.sql
new file mode 100644
index 0000000..8083a33
--- /dev/null
+++ b/scripts/seed-capsman-fixture.sql
@@ -0,0 +1,109 @@
+-- A representative CAPsMAN deployment for local development.
+--
+-- Two bugs shipped in v0.23.5 and v0.23.6 because CAPsMAN code was written and
+-- "verified" against a fleet that contains no CAPsMAN at all. Neither was catchable
+-- by unit tests: both lived in SQL — a join that could not match, and a WHERE clause
+-- that filtered out every managed radio before the analysis ran.
+--
+-- This reproduces the shape of a real deployment (reported on issue #94) so those
+-- paths can be exercised locally against the actual endpoints:
+--
+-- * a controller that is a ROUTER with no radios of its own
+-- * CAPs whose own wireless_interfaces rows carry NULL frequency and NULL ssid,
+-- because the controller owns the configuration
+-- * the controller's local mirror of each CAP interface, which makes a CAP's radio
+-- MAC legitimately appear on two devices
+-- * channels only in RouterOS's "//" form
+--
+-- Everything is prefixed `fixture-` and removed by seed-capsman-fixture-clean.sql.
+
+BEGIN;
+
+-- Controller: a router. Deliberately NOT device_type 'wireless_ap', because that
+-- misclassification is what hid controllers from the wireless section originally.
+INSERT INTO devices (name, ip_address, api_username, api_password_encrypted,
+ device_type, status, model, wifi_role, wifi_package)
+VALUES ('fixture-core-rt-001', '10.250.0.1', 'admin', 'x',
+ 'router', 'online', 'RB5009UPr+S+', 'controller', 'wifi')
+ON CONFLICT DO NOTHING;
+
+INSERT INTO devices (name, ip_address, api_username, api_password_encrypted,
+ device_type, status, model, wifi_role, wifi_package)
+VALUES ('fixture-hall-ap-001', '10.250.0.18', 'admin', 'x', 'wireless_ap', 'online', 'cAP ax', 'cap', 'wifi'),
+ ('fixture-left-ap-001', '10.250.0.20', 'admin', 'x', 'wireless_ap', 'online', 'cAP ax', 'cap', 'wifi'),
+ ('fixture-right-ap-001', '10.250.0.22', 'admin', 'x', 'wireless_ap', 'online', 'cAP ax', 'cap', 'wifi')
+ON CONFLICT DO NOTHING;
+
+-- CAP-side interfaces: no ssid, no frequency, no band. This is what a CAP actually
+-- reports, and assuming otherwise is what removed them from Channel Usage.
+INSERT INTO wireless_interfaces (device_id, name, ssid, frequency, channel_width, band,
+ disabled, running, registered_clients, managed_by_capsman,
+ radio_mac, mac_address, config_json)
+SELECT d.id, v.name, NULL, NULL, NULL, NULL, FALSE, TRUE, 0, TRUE, v.mac, v.mac, '{}'::jsonb
+FROM (VALUES
+ ('fixture-hall-ap-001', 'wifi1', '04:F4:1C:A2:C4:59'),
+ ('fixture-hall-ap-001', 'wifi2', '04:F4:1C:A2:C4:5A'),
+ ('fixture-left-ap-001', 'wifi1', '04:F4:1C:A3:1E:7B'),
+ ('fixture-left-ap-001', 'wifi2', '04:F4:1C:A3:1E:7C'),
+ ('fixture-right-ap-001', 'wifi1', '04:F4:1C:A5:0C:0D'),
+ ('fixture-right-ap-001', 'wifi2', '04:F4:1C:A5:0C:0E')
+) AS v(dev, name, mac)
+JOIN devices d ON d.name = v.dev
+ON CONFLICT (device_id, name) DO NOTHING;
+
+-- The controller mirrors every CAP interface locally, so each CAP radio MAC appears
+-- on two devices. A single-valued MAC index picked whichever row the database
+-- returned first, which is how radios ended up attributed to the controller.
+INSERT INTO wireless_interfaces (device_id, name, ssid, frequency, channel_width,
+ disabled, running, registered_clients, managed_by_capsman,
+ radio_mac, mac_address, config_json)
+SELECT d.id, v.name, NULL, NULL, NULL, FALSE, TRUE, 0, FALSE, v.mac, v.mac, '{}'::jsonb
+FROM (VALUES
+ ('wifi-fixture-hall-2g', '04:F4:1C:A2:C4:5A'),
+ ('wifi-fixture-hall-5g', '04:F4:1C:A2:C4:59'),
+ ('wifi-fixture-left-2g', '04:F4:1C:A3:1E:7C'),
+ ('wifi-fixture-left-5g', '04:F4:1C:A3:1E:7B'),
+ ('wifi-fixture-right-2g', '04:F4:1C:A5:0C:0E'),
+ ('wifi-fixture-right-5g', '04:F4:1C:A5:0C:0D')
+) AS v(name, mac)
+CROSS JOIN devices d
+WHERE d.name = 'fixture-core-rt-001'
+ON CONFLICT (device_id, name) DO NOTHING;
+
+-- Controller's view of the radios it manages. Channels only in RouterOS form, and
+-- deliberately including a partial overlap (2412 vs 2422) plus a co-channel pair
+-- (5680 twice) so the interference analysis has something real to classify.
+INSERT INTO capsman_radios (controller_device_id, radio_mac, interface_name, local, hw_type,
+ current_channel, matched_device_id, state, registered_peers,
+ authorized_peers, tx_power, ssid, config_json)
+SELECT c.id, v.mac, v.iface, FALSE, 'QCA6018', v.chan, a.id, 'running', v.peers, v.peers, 20, v.ssid, '{}'::jsonb
+FROM (VALUES
+ ('04:F4:1C:A2:C4:5A', 'wifi-fixture-hall-2g', '2412/ax/Ce', 'fixture-hall-ap-001', 8, 'Sunflower'),
+ ('04:F4:1C:A2:C4:59', 'wifi-fixture-hall-5g', '5680/ax/eCee/D', 'fixture-hall-ap-001', 1, 'Sunflower'),
+ ('04:F4:1C:A3:1E:7C', 'wifi-fixture-left-2g', '2422/ax/Ce', 'fixture-left-ap-001', 5, 'Sunflower'),
+ ('04:F4:1C:A3:1E:7B', 'wifi-fixture-left-5g', '5680/ax/eCee/D', 'fixture-left-ap-001', 7, 'Sunflower'),
+ ('04:F4:1C:A5:0C:0E', 'wifi-fixture-right-2g', '2462/ax/Ce', 'fixture-right-ap-001', 3, 'Sunflower'),
+ ('04:F4:1C:A5:0C:0D', 'wifi-fixture-right-5g', '5500/ax/Ceeeeeee/D','fixture-right-ap-001', 20, 'Sunflower')
+) AS v(mac, iface, chan, cap, peers, ssid)
+JOIN devices c ON c.name = 'fixture-core-rt-001'
+JOIN devices a ON a.name = v.cap
+ON CONFLICT (controller_device_id, radio_mac) DO NOTHING;
+
+INSERT INTO capsman_configurations (controller_device_id, name, ssid, mode, band, security, authentication_types, config_json)
+SELECT c.id, v.name, v.ssid, 'ap', v.band, 'public_security', 'wpa2-psk,wpa3-psk', '{}'::jsonb
+FROM (VALUES ('public_conf', 'Sunflower', NULL), ('public_conf_2g', 'Sunflower', '2ghz-ax'))
+ AS v(name, ssid, band)
+JOIN devices c ON c.name = 'fixture-core-rt-001'
+ON CONFLICT (controller_device_id, name) DO NOTHING;
+
+INSERT INTO capsman_provisioning (controller_device_id, ros_id, action, master_configuration, disabled, config_json)
+SELECT c.id, v.rid, 'create-dynamic-enabled', v.cfg, FALSE, '{}'::jsonb
+FROM (VALUES ('*f1', 'public_conf'), ('*f2', 'public_conf_2g')) AS v(rid, cfg)
+JOIN devices c ON c.name = 'fixture-core-rt-001'
+ON CONFLICT (controller_device_id, ros_id) DO NOTHING;
+
+COMMIT;
+
+SELECT 'seeded' AS status,
+ (SELECT count(*) FROM devices WHERE name LIKE 'fixture-%') AS devices,
+ (SELECT count(*) FROM capsman_radios) AS radios;