TacidOS
Developers
Documentation

Decision 0093

0093. A scan is asked of the supplicant that owns the radio

Architecture decision record 0093: 0093. A scan is asked of the supplicant that owns the radio

Status
accepted
Date
2026-09-11

Context

ADR-0085 gave this machine the read: system.wireless speaks nl80211 by hand and returns what the kernel already heard. It left the write, and said why in one sentence — triggering a scan “needs CAP_NET_ADMIN, which means it belongs to the stem and wants its own record.”

That sentence is right about the half it names, and wireless.rs’s own header, written the same day and three files away, carried the other half: triggering “needs CAP_NET_ADMIN, which nothing here has, and would fight with the supplicant that owns the interface”. The capability was never what was stopping it.

iwd owns the interface on a Tacid machine. It scans on its own every few seconds while a station is disconnected — most often exactly when a person is looking, which is why the read is useful at all. Two scan requests on one interface is -EBUSY for whoever asked second, and the second can be this machine’s own supplicant, failing at the moment somebody is standing there waiting for a list. A stem holding CAP_NET_ADMIN does not fix that; it just makes this machine the one that wins the race sometimes.

So the question was never “how do we get the capability”. It was which door to knock on, and there were three:

  • NL80211_CMD_TRIGGER_SCAN from the stem. Shortest, no dependency, and what ADR-0085’s sentence assumes. It races iwd on every machine where iwd is what makes the answer useful.
  • iwd’s own D-Bus interface, net.connman.iwd.Station.Scan. The supplicant is asked rather than raced, and it already serialises its own scans.
  • iwctl station <device> scan. Consistent with how the stem runs tools, and not a violation of ADR-0028 — that rule forbids parsing prose, not invoking programs — but iwctl is a readline shell with a command mode bolted on, which is a poor foundation for a guarded operation.

Decision

Ask iwd, over its own D-Bus interface, and reach that interface with busctl.

The first half is about correctness: the process that owns the radio is the one that should be told to use it, and it is the only one in a position to serialise the request against the scans it starts itself.

The second half is about where a dependency would land. ADR-0085 declined zbus for the read because it is a large dependency for one question and there was a kernel socket to use instead. That argument does not transfer to a write — there is no kernel socket here that does not race — so it would have had to be made again, and it would have been made about the stem, whose Cargo.toml lists five crates and is that short on purpose: it is the only process here that runs as root.

It does not have to be made at all. busctl is systemd’s own D-Bus client, systemd is this machine’s init (ADR-0002), and the stem already reaches systemd-run from the same directory through the same helper. iwd’s door costs no dependency in the one process where a dependency costs the most.

Operation::NetworkScan takes an interface name, never a D-Bus object path. iwd names a station /net/connman/iwd/<wiphy>/<ifindex>, and both numbers are files the kernel writes under /sys/class/net/<name>/ — so the path is derived where the work happens and nothing a caller supplies reaches the bus as a destination. That is the same shape as NetworkKnow, which takes an SSID and builds the filename itself rather than being handed one. Asking the bus what objects it has would mean reading busctl tree, which prints a tree for a person, and that is the one thing ADR-0028 is about.

It is not guarded: a scan changes nothing a later generation would have to undo. A radio looks, the kernel’s list is newer, and that is the whole of the effect. What stands in front of it is the keeper, refusing a principal who could not approve a change — the same standard as NetworkKnow.

And it is asked, not waited on. iwd answers when it has started a scan; what the radio hears arrives over the seconds after. tacid network scan returns then, and tacid network a moment later says what was heard. A verb that blocked until a list was ready would be a verb that lies about what a scan is.

Consequences

The two halves of one question now go two different ways, and that is right rather than untidy: system.wireless reads the kernel directly because reading is not changing, and a scan goes keeper-then-stem because it is. Nothing in the fact layer gained a capability.

tacid network scan takes an interface only when it needs one. A laptop has one radio, and naming it every time would be a verb asking a person for what their machine already knows; a machine with two is asked which, because scanning on an interface somebody did not mean is worse than a question. An interface is wireless when it has a phy80211, which is the kernel’s own answer — there is no name pattern that is reliable, since wlan0, wlp2s0 and wlan-home are all real and so is an ethernet port somebody renamed.

The stem grew its twenty-fourth operation and its twenty-fourth tool, and finding room for the tool found something else: the guard that exists to stop a tool arriving unaccounted for could not see a tool whose call rustfmt had wrapped across two lines. Six had. That is written up in the build log and fixed in the same change; it is mentioned here because this ADR is the reason anybody looked.

What is proven and what is not, exactly. The interface resolution, the refusals and the authority chain are held by the lifecycle leg on a machine with four wireless interfaces — hostapd’s three virtual ones and a station — which is the awkward case rather than the easy one. The busctl call itself is described there and not made, because that leg’s stem is the stand-in (TACID_STEM_DRY_RUN). A real scan on a real radio wants a machine with a wireless card iwd can have to itself, which is the same thing the association half of ADR-0085 is still waiting for.