Decision 0085
0085. Some questions have no file, and the kernel answers them on a socket
Architecture decision record 0085: 0085. Some questions have no file, and the kernel answers them on a socket
Status: accepted. Extends ADR-0028, and widens one line of ADR-0084 by exactly one address family.
Context
ADR-0028 is the rule the whole fact layer is built on: this machine
learns about itself from files the kernel writes, never from the output of a command. lsblk
changes its columns, ip changes its wording, and a machine that reads its own body by scraping
prose starts lying after an upgrade. Sysfs is an interface with a stability guarantee. Prose is
not.
tacid network join <ssid> has worked for some time: the keeper takes a passphrase, the stem
writes iwd’s provisioning file, and iwd associates. It is a good shape — no supplicant is driven
over a bus, and joining a network is a file appearing where iwd looks. But it can only join a
network whose name is already known, and there was no way on this machine to find out what names
there are. A person had to leave, use another machine or another program, and come back with the
name typed out. On a laptop that is not a missing feature; it is the machine failing at the first
thing it is asked to do in a new place.
The reason it was missing is that the rule had no answer here. The kernel keeps what a radio
heard in memory and hands it out on netlink. There is no file. /proc/net/wireless carries link
quality for the interface that is already associated and no names at all; /sys/class/net/*/
says an interface is wireless and nothing about the air around it; iwd stores what it has been
told about networks and never what it has heard. The list a person wants exists in exactly one
place, reachable in exactly one way: NL80211_CMD_GET_SCAN, over a generic-netlink socket.
Three ways out were considered and two were wrong.
Parse iw dev wlan0 scan dump. This is the shortcut, and it is the exact thing ADR-0028
exists to forbid. iw’s output is prose with an unstable shape, and a fact layer that scrapes it
would be a fact layer with one lie in it.
Talk to iwd over D-Bus. iwd exposes net.connman.iwd.Station.GetOrderedNetworks, which is a
typed interface with a stability guarantee — the objection is not correctness. It is that a D-Bus
client in Rust is zbus, and zbus is a large dependency tree brought in for one question, on a
machine whose whole argument is that its core is small enough to read. ADR-0005
already says zbus is for desktop interop and never the core path. It would also make the answer
depend on a supplicant being present and running, when the question — what did the radio hear —
is the kernel’s to answer.
Speak netlink. No dependency at all: rustix is already in this tree, and turning on its
net feature adds no package to the lock. The wire format is a header, a length, and
type-length-value attributes, and it has the same stability guarantee sysfs has, for the same
reason. It is about four hundred lines, and every constant in it was read out of
linux/nl80211.h, linux/genetlink.h and linux/netlink.h on this machine rather than
remembered.
Three facts about the interface decided the shape of what was built.
The dump is passive and needs no privilege. NL80211_CMD_GET_SCAN returns the access points
the kernel already heard. Putting the radio into a scan is NL80211_CMD_TRIGGER_SCAN, which
needs CAP_NET_ADMIN and would contend with the supplicant that owns the interface. A station
that is not associated scans every few seconds on its own, which is precisely the moment a person
is looking — so the passive read answers the question that is actually being asked, and does it
without asking for any authority at all.
Everything in a beacon is written by strangers. An SSID is a byte string chosen by whoever is transmitting: not UTF-8 by rule, not printable by rule, up to 32 octets and frequently claiming more. It is the first content on this machine that arrives from the air rather than from a disk, a socket or a person.
A netlink socket has no root. Every other fact reads beneath a root that is a fixture directory in the tests, which is why the tests can assert exact values for machines that do not exist. A socket cannot be rooted, and a fact function handed a fixture would answer with whatever the developer’s own laptop can hear.
Decision
A fact may ask the kernel on a socket when the kernel keeps the answer in memory and writes no
file. system.wireless is the first, and the bar for the next one is that sentence: not “a
socket is more convenient”, but “there is no file”.
The narrow consequences, each answering one of the three facts above:
It asks only about the machine it is running on. system.wireless reads sysfs for the
wireless interfaces beneath its root, and asks the kernel only when that root is /. A fixture
machine’s interfaces are reported with networks: null — not asked — which is a different
answer from [], nothing in range. The tests drive the parser with bytes they build themselves,
which is the only honest way to test a wire format without the hardware that speaks it.
A name from the air is untrusted data, and is only ever printed. It is decoded lossily, control characters are dropped, and its length is bounded at the 32 octets 802.11 allows. Dropping the escape rather than the whole sequence is deliberate: what is left is inert text, and a name that could move the cursor is a name that can rewrite the line above it. Every length in a beacon is a number a stranger wrote, so every walk over one stops rather than indexes when a length claims more than remains.
tacidd gains AF_NETLINK, and nothing else does. ADR-0084
gave the daemon RestrictAddressFamilies = [ "AF_UNIX" ], and this widens it by one family. The
promise that record makes is about AF_INET: the daemon can open no socket to the network, and
netlink is not a route off this machine — it reaches the kernel and stops. What netlink can
change is gated by capabilities, and CapabilityBoundingSet is empty for this unit, so every
netlink protocol that could alter this machine refuses the daemon before it starts. The executor
already carried the family, for its sandbox’s loopback. The lifecycle test asserts that those two
are the only ones, and that neither holds a capability, so the widening cannot spread by
inattention.
Triggering a scan is not built. It is an action in ADR-0006’s sense
and it needs CAP_NET_ADMIN, which means it belongs to the stem and wants its own record. What
exists is the read, and seen_ms_ago on every row so that a stale list cannot pass for a fresh
one.
Those three sentences stand as they were written and they are right about the half they name. They
are quoted rather than edited because the rest of it was written the same day, three files away, in
wireless.rs’s own header: triggering “needs CAP_NET_ADMIN, which nothing here has, and would
fight with the supplicant that owns the interface”. The capability is the easy half. iwd owns
the interface, scans on its own every few seconds while a station is disconnected, and a second
NL80211_CMD_TRIGGER_SCAN arriving mid-scan is -EBUSY for whoever asked second — which can be
this machine’s own supplicant, failing at the moment somebody is standing there waiting for a list.
So “it belongs to the stem” names where the capability would live and not what the work is. What the work is, is a choice between three doors, and none of them is obviously right:
NL80211_CMD_TRIGGER_SCANfrom the stem. The shortest path and the one the sentence above assumes. It is also the one that races iwd, on every machine where iwd is the thing that would make the answer useful.- iwd’s own D-Bus interface (
net.connman.iwd.Station.Scan). The polite door: the supplicant is asked rather than raced, and it already serialises its own scans. It costs a D-Bus dependency this ADR declined for the read — where the argument was thatzbusis a large dependency for one question, and where there was a kernel socket to use instead. For the write there is no such alternative, so the argument is not the same argument and would have to be made again. iwctl station <device> scan. Consistent with how the stem runs tools — by absolute path, for an exit status — and not a violation of ADR-0028, which forbids parsing prose rather than invoking programs. Butiwctlis a readline shell with a command mode bolted on, and a tool whose primary interface is a prompt is a poor thing to build an operation on.
The stem’s existing wireless operation takes none of these routes: it writes iwd’s provisioning
files and lets iwd act on them. There is no file that means “scan now”, which is why this one is
different in kind from NetworkKnow rather than more of the same.
This is recorded here rather than decided here. It is the owner’s call, it wants its own decision record when it is made, and what was stopping it was never the capability.
Consequences
tacid network now answers both halves of the question: what this machine is on, and what else it
could be on. The list is one row per name — a network announced by eight access points is one
thing to join, not eight — with the strongest one’s numbers, because that is the one the machine
would associate with, and a count of how many were behind it. Hidden networks keep their own rows,
because there is no name to group them by.
The agent can answer “what wireless networks are in range” from the fact layer, as untrusted data
like the contents of any file, and can act on the answer with tacid network join, which was
already built. The two halves are now one capability.
The rule in ADR-0028 is unchanged in substance and clearer in wording: nothing scrapes prose. That was always what the rule was for. “Read a file” was how it had been said, because until this every answer was in one.
RestrictAddressFamilies on the daemon is one family wider than it was for a day. That is a real
cost, recorded here rather than in a commit message, and the reason it is acceptable is that the
thing it protects — no route off this machine — is untouched, and the thing it might have opened —
changing the machine through netlink — is closed by a bounding set that is empty.
Alternatives rejected
Parse iw scan dump. The one thing ADR-0028 forbids.
zbus against iwd. A large dependency for one question, on the core path
ADR-0005 reserves for Unix sockets and JSON-RPC, and an answer that depends on
a supplicant running rather than on the kernel that heard the beacon.
Put the read in the stem. It needs no privilege, so routing it through the one root process on the machine would add authority to a read that does not want any, and put a parser of bytes from the air inside the process that activates generations.
Return one row per access point. Faithful to the wire and useless to a person: the same name eight times is a list that has to be read twice to learn one thing. The count is kept; the noise is not.
Skip the fact and have the shell ask iwd directly. Two ways to learn one thing, one of which the agent cannot see. Every surface renders what the protocol carries (ADR-0005), and this is the protocol carrying it.