TacidOS
Developers
Documentation

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
Date
2026-09-08

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_SCAN from 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 that zbus is 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. But iwctl is 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.