Twelve completed or superseded task records leave the live queue; the byte-identical rename duplicate is removed.\n\nThe authority and somatic source records now point at SAF, while tasks 33, 48, 49, 53, 60, and 76 say what is actually left.
23 KiB
TASK 49 — Bearer selection and DNS belong to the state machine
Status: machine half landed and exercised on hardware; acceptance 3–5 and
split DNS remain open. Raised by Casey 2026-07-31 after three interlocking
failures were measured on the phone.
Repo: souveraine (sessiond), Pixel3Arch (blueline-clat.sh).
Goal
Which link carries what, and which resolver answers what, are currently decided by nobody. They are the residue of a DHCP metric, a NetworkManager penalty and a shell script's assumption, and when those disagree the device silently loses the network. Make bearer posture evidence the state machine holds and link selection an Action it emits — DEVICE-STATE-MACHINE §12, the same shape ambient light was given.
Casey, 2026-07-31: "I might need to build out a more sophisticated wifi v lte configuration grid for the device state machine — because I specifically need SOUVERAINE VPN stuff to be part of our system."
What was measured, 2026-07-31
All on the phone, on foreign wifi ("Mabee 19", 10.10.30.213), tunnel up.
1. Every IPv4 destination was leaving over cellular while wifi was associated.
default dev clat metric 2048 <- won
default via 10.10.30.1 wlan0 metric 20600
1.1.1.1 / 8.8.8.8 / github -> dev clat
2. The metric inversion is a NetworkManager penalty, not a configuration.
blueline-clat.sh states its premise in a comment — "while WiFi is up its
default route (metric 600) beats the CLAT default (2048)" — and 600 is indeed
NM's default for wifi. The observed 20600 is 600 + 20000: the penalty NM
applies to a device whose connectivity check fails. nmcli general status
reported limited. So the script's entire premise inverts exactly when
connectivity degrades, which is the moment it matters most.
3. The connectivity check failed because DNS was dead, and DNS was dead
because of the VPN profile. wiufph carried ipv4.dns=1.1.1.1 with
never-default=true, so its own resolver was not reachable through the tunnel
— it had to egress via the default route, which was the degraded clat. Every
lookup stalled on it. getent hosts archlinux.org returned nothing.
That is a closed loop: VPN DNS unreachable → lookups fail → NM calls wifi
limited → wifi penalised +20000 → cellular becomes default → cellular is the
degraded path → VPN DNS still unreachable.
4. The WireGuard handshake inherited the same broken path. Endpoint
173.246.142.24:51822 resolved out dev clat; wg show read
368 B received, 5.30 KiB sent with a handshake going stale against a 25 s
keepalive. Pinning the endpoint via wlan0 produced a handshake in seconds and
1.45 KiB received. VPN-WIUF.md's claim that this "works on both wifi and
cell (clat gives v4-over-v6)" is not true as written — over clat it sent and
never received.
Interim state left on the device (neither is durable, both are stopgaps):
a pinned host route 173.246.142.24/32 via 10.10.30.1 dev wlan0, and
wiufph DNS corrected to 10.10.20.5 / ~wiuf.net at dns-priority 10.
New observation, 2026-08-01 — leaving wifi for LTE did not connect at all
Casey, at the device: "when I left wifi to LTE it did not connect, I had to reboot the phone." Not a slow or degraded path like 2026-07-31 — no attach. A reboot cleared it, which is the signature of a service that dies once and is never re-established, not of a routing-metric fight.
Casey's read is that qcom_pd_mapper is implicated, and there is a real
mechanism for it. sdm845_domains[] in drivers/soc/qcom/qcom_pd_mapper.c
contains mpss_root_pd and mpss_wlan_pd — on this SoC the modem's
protection domains, including the WLAN one, are advertised by that broker. So
wifi and cellular are not independent subsystems here; they share a PD service.
If the broker stops serving across a bearer transition, the modem has no PD to
locate and parks rather than attaching — and only a cold path rebuilds it.
slpi.md already records the precedent: when the in-tree sdm845.config
carried CONFIG_QCOM_PD_MAPPER=n and won the merge, wifi, sound and SLPI all
lost their PD broker at once.
But the stranded mic-fix commit is not the fix. fdb22e5e2 flips
CONFIG_QCOM_PD_MAPPER=m → =y in arch/arm64/configs/sdm845.config and
debian/config/sdm845.config. Our PKGBUILD reads neither — prepare() does
cp config-blueline.aarch64 .config then olddefconfig, with no
merge_config.sh anywhere. That commit is inert in this build, which is why it
was left stranded. The merge-order hazard it was written for belongs to the
pmOS/pmaports era.
What is not settled is whether =m is the right value now. Our shipping
config has CONFIG_QCOM_PD_MAPPER=m. A module can be unbound, unloaded, or
re-probed; a built-in cannot, and is present earlier in boot. If the failure is
the broker going away, =y in config-blueline.aarch64 is a one-line test —
but it is a hypothesis, not a diagnosis, and it should not be flipped
before the evidence below is taken.
Capture this at the moment it recurs, before rebooting:
lsmod | grep -E 'qcom_pd_mapper|qcom_q6v5|qrtr'
cat /sys/class/remoteproc/remoteproc*/state # is MPSS still running?
dmesg | grep -iE 'pd_mapper|pdr|servreg|mpss|qrtr|glink' | tail -50
qrtr-lookup # does the modem still publish?
mmcli -m 0 | grep -iE 'state|failed'
If MPSS is running but qrtr-lookup shows no modem services, the PD/servreg
layer is the gap and this is the right thread. If MPSS itself has crashed or is
offline, it is a remoteproc-recovery problem and PD mapper is a symptom.
Distinguishing those two is the whole next step — do not flip the config first,
because a reboot destroys the evidence either way.
What wiuf-vpn-gate actually got wrong — read before designing the Action
The script that produced the 652 recycles was found still installed on the phone 2026-08-01, in no repo and in no repo's history — it was written straight onto the device. Removed then; its two defects are the durable part, and both are traps the state-machine version can walk into just as easily.
1. It was driven by the NM dispatcher and a 90 s timer, and it changed the
thing the dispatcher watches. nmcli connection up/down is itself an
NetworkManager event, so every correction re-entered the controller. 652
recycles in 90 minutes is ~7/minute — far faster than the 90 s timer alone can
explain, and the signature of a feedback loop rather than a bad interval. This
is the same shape as the hypridle lock_cmd self-loop: an actuator wired
back into its own sensor. In the §12 form the state machine is the only writer
and tick() is the only clock, which forecloses it by construction — but only
if bearer changes enter through sensor_input and never call back in.
2. Its home-LAN test is wrong on exactly the network this phone uses.
on_home_wifi() { ip -4 addr show wlan0 | grep -q ' inet 10\.10\.'; }
That matches 10.10. — so it treats any 10.10/16 address as home and takes
the tunnel down as "redundant". The phone sat on 10.10.30.213 on foreign wifi
while this was installed, and TASK-49's own 2026-07-31 measurements were taken
at 10.10.30.213 on "Mabee 19". Home is 10.10.20.0/24. A /16-wide prefix
match cannot tell the home LAN from a foreign network that happens to share the
first two octets, and this one does.
3. It managed the wrong profile. CONN=wiuf, but the phone carries two
WireGuard connections — wiuf and wiufph — and wiufph is the one with the
10.10.20.6 identity. Both are autoconnect=no, which is the real reason the
tunnel does not come back after a reboot; the gate was the thing papering over
that, badly. Deciding which profile is authoritative, and deleting the other,
is a prerequisite to the grid — an Action cannot target "the VPN" while two
profiles answer to that name.
The lesson for the grid below: "am I home" is not an address-prefix question. It needs identity — the gateway's MAC, the SSID, or a reachability probe against something only the home LAN serves — and it is a piece of evidence with a confidence, not a boolean derived from a regex.
Operational proof — 2026-08-07
At the foreign 10.10.30.x location, the phone correctly had no route to the
LAN-only package archive at 10.10.20.120 until Casey deliberately toggled
wiufph on. Afterward, ip route get 10.10.20.120 selected wiufph with
source 10.8.0.6, Gitea returned HTTP 200, and the ordinary pacman -Syu
completed. No VPN had been active before the toggle and no site-to-site route
exists at this location.
That is the UX requirement in one incident: the toggle must stay deliberate,
but the surface should make the missing private route obvious when a known
home service is requested. wiufph remains split-tunnel; do not turn this
convenience into an implicit 0.0.0.0/0 posture.
The constraints any design must respect
- Not a daemon, not a dispatcher hook.
VPN-WIUF.mdis explicit, and it was bought with an outage: the previous autonomous gate recycled the tunnel 652 times in 90 minutes, through doze tiers whose contract is "network fetchers stopped", and the user could not switch it off. Posture that must be automatic belongs in sessiond as anAction. - The VPN toggle stays deliberate. The user's switch is the authority for whether the tunnel is up. This task decides how traffic is carried, which is a different question and must not quietly re-acquire the first.
- Carrier services must ride the carrier. MMS is the concrete case: the
proxy
205.151.11.13is reachable only through clat (blueline-clat.shpins a/32), and Fido's own resolvers must answer Fido names. Casey, 2026-07-31: "fido dns is needed for fido services, mms etc and those services NEED to be carried by the fido network." A design that prefers wifi globally and evicts the carrier's DNS breaks MMS — negativeipv4.dns-prioritydoes exactly that, because in NetworkManager negative means exclusive, and it was observed emptyingresolv.confdown to a single nameserver. - Split DNS needs a resolver that can route per-domain.
resolv.confis NetworkManager-generated today (mode: foreign), which is a flat ordered list; glibc tries entries in order and nothing binds a domain to a link.~wiuf.neton the WireGuard profile therefore means nothing at present. This needsdns=systemd-resolved— already on the open list before today. - Public DNS is not a substitute for the tunnel.
dns.wiuf.netbehind the proxy was considered and rejected inVPN-WIUF.md, and the reason holds: it resolves names, it does not route to them. Answeringgitea.wiuf.net = 10.10.20.120is useless without a path to10.10.20.120. Exposing individual services through Pangolin is a real alternative for those services; raw10.10.xstill requires the tunnel.
Prior art — read on this machine, 2026-07-31
Casey: "PRIOR ART. We need to stop winging this. Sailfish, LineageOS BOTH are on THIS machine now." Both trees are local, so this is read rather than recalled. §9's warning still applies at the end: measure before adopting.
Android / LineageOS (~/Downloads/lineage-blueline-kernel/system.img)
debugfs reads the image with no root and no copy. Two findings, both decisive:
There is no /etc/resolv.conf. Not empty — absent. ls /system/etc has no
such file. The flat, ordered, global resolver list this device is fighting is
not a thing Android has at all.
DNS is bound per network. From strings on /system/bin/netd:
DNS: netId %d servers [%s]
dnsproxyd
failed to add fallthrough to %s for VPN netId %u
Destroyed %d sockets for netId %d permission=%d in %ldus
cannot set default to non-physical network with netId %u
Every network is a netId carrying its own resolvers; a local proxy
(dnsproxyd) routes each query to the right set. A VPN is its own netId with
an explicit fallthrough rule, not a set of nameservers poured into a file
shared with the carrier. And when a network changes, sockets bound to it are
destroyed rather than left to hang — which is what "tunnel up but deaf"
needs and what a stale WireGuard handshake currently does not get.
This answers the MMS constraint structurally: an MMS request asks for a network with that capability, gets that netId, and its lookups go to that network's resolvers over that link. Nothing has to be ordered correctly in a shared file, because there is no shared file.
Sailfish / connman (~/phone-qml-research/)
The tree is built on connman's object model — NetworkTechnology (94
references) and NetworkService (70), plus connman.vpn.Agent. Same three
shapes as Android by different names:
- a service per link, ranked by technology preference and by state;
- per-service nameservers, with connman's dnsproxy owning
resolv.confand routing queries per service; - VPN as a first-class service with its own agent, not a route hack.
connman also distinguishes ready from online, where online requires its
own connectivity probe to pass.
What both agree on, and where we differ
| Android | connman | this device today | |
|---|---|---|---|
| link preference | score, and only if validated | technology order, and only if online | a DHCP metric plus an NM penalty |
| DNS routing | per-netId via dnsproxyd |
per-service via dnsproxy |
one flat ordered list |
| VPN | own netId + fallthrough | own service + agent | nameservers appended to the shared list |
| link that lies | not validated → does not win | not online → not preferred | wins, and takes the tunnel down with it |
Both mature stacks solved this with a local resolver that routes per link and a probe that decides whether a link may win at all. We have neither, and the two failures compound: the flat list is why the VPN's resolver could poison carrier lookups, and the missing probe is why an associated-but-useless wifi got demoted by NM instead of the clat default being demoted.
The Linux equivalent of dnsproxyd/dnsproxy already exists and is already on
the open list: dns=systemd-resolved, which does per-link DNS with routing
domains — the ~wiuf.net on the WireGuard profile becomes meaningful the moment
resolved owns resolv.conf, and means nothing until then.
Where the constraints differ, per §9's lesson. Do not adopt Android's
scores. Its validation probe assumes a general-internet URL is reachable and
meaningful; ours is a split tunnel whose point is a private 10.10.0.0/16
that no public probe can see. A wifi link that fails a public probe may be
exactly the link we want for the tunnel. The probe we need is
per-consumer — "can this link carry this" — not "is this link on the
internet". wg's received-byte counter is that probe for the tunnel, and it is
already there, unread.
The grid
The thing to design is small and mostly a table. Inputs the machine can already observe or cheaply learn:
| evidence | source | today |
|---|---|---|
| wifi associated + has v4 default | NetworkManager | yes |
| wifi actually carries traffic | connectivity probe | NM's, and it is what mislabels the link |
| cellular bearer up / clat present | NetworkManager, blueline-clat.sh |
yes |
| tunnel up + handshaking | wg show received-bytes |
not read by anything |
| link is metered | NM connection.metered |
not read |
Outputs, as Actions from tick() through the existing executor table:
- prefer-link (set route metrics deliberately rather than inheriting them)
- pin the tunnel endpoint to the chosen underlay
- keep carrier
/32s and carrier DNS on the carrier, always
The load-bearing distinction is that "associated" is not "carrying" — the
same lesson §10 learned about sensors, where a source that reported and then
went silent is Down rather than quiet. A wifi link that associates and cannot
route is exactly a sensor that heartbeats and lies. wg's received-byte counter
is the honest instrument for the tunnel, the way logind is for the lock.
What landed 2026-08-01 — the machine half
souveraine/src/sessiond/bearer.rs, plus two Actions and a bearer verb.
The decision layer is done and tested; the acceptance list below is still open,
because none of it has run on the phone.
Evidence. LinkHealth is Absent < Down < Associated < Carrying, and the
ordering is the rule: a link may only win if something has been shown to
cross it. TunnelHealth reads wg's received-byte counter — the instrument
§10 says exists and nothing read — so Deaf (up, sending, nothing back) is a
first-class state rather than something that reports as connected. home is
Option<bool> over home_ssids, identity not prefix, and empty means "never
claim to be home".
Decision. preferred() is a pure function of the evidence: a carrying
cellular link beats a merely associated wifi one, which is the case NM's
+20000 penalty gets backwards; wifi wins only on equal health.
Anti-flap is a settling window, not a rate limit. A changed preference must
hold bearer_settle (20 s) before it emits anything, and the clock restarts
whenever the candidate itself changes — so a link oscillating between two
answers never accumulates enough time to act. A steady state emits nothing at
all, which is what makes it safe to evaluate every tick. Three tests pin this:
the flap that never settles, the settled state that must not re-emit, and the
dead network that must not be flailed at.
Single writer, no new timer. Preference is expressed as
ipv4.route-metric + device reapply, never ip route — NM stays the sole
author, acceptance #6. reapply rather than up/down, so a correction is not
itself an NM event, which is the loop that produced 652 recycles. The one route
sessiond owns is the WireGuard endpoint /32. The probe rides the existing
1 Hz clock every fifth tick with the state lock released.
The toggle stays the user's. tunnel_underlay() returns None when the
tunnel is off. This decides how traffic is carried, never whether the tunnel
exists.
The duplicate profile is resolved. wiufph is authoritative and wiuf is
deleted (backed up to /root/nm-profile-backup/ on the phone). The two were
not near-copies — wiuf carried ipv4.addresses 10.8.0.5/24, which is the
laptop's tunnel address (wiufarch), while wiufph carries 10.8.0.6, the
phone's. Bringing wiuf up would have put two devices on one WireGuard
address. wiufph also has the dns-priority 10 correction from 2026-07-31;
wiuf was still at 0.
wiufph is autoconnect=no, and that is left alone deliberately. It is
the real reason the tunnel does not return after a reboot, and flipping it is
the user's call, not this task's — the constraint above says the switch stays
the user's authority. Worth a decision, not a silent default.
Measured on the phone, 2026-08-01, running the real thing
home_ssids is set to ["Samaritan Solutions", "TheTholianWeb"]. It needed to
be a list: both lease from the home LAN — Samaritan Solutions gave
10.10.20.234 (the phone's documented home address) and TheTholianWeb gave
10.10.20.245. A single-SSID answer would have been wrong half the time. The
evidence is in NM's own lease files under /var/lib/NetworkManager/, which is
the identity check the /16 prefix test should always have been.
Acceptance #1 holds. On Mabee 19 with cellular also up:
default via 10.10.30.1 dev wlan0 metric 600, wifi profile at 600 and the
readout at applied: wifi, home: false, ssid: "Mabee 19".
ip route get 1.1.1.1 leaves by wlan0. That is the 2026-07-31 inversion
corrected.
Acceptance #2 was broken by the first action and is now fixed. Two defects, both only visible on hardware:
nmcli device reapply clatdestroys the carrier's routes. After onePreferLinkthe clat device was up with its192.0.0.1/32and zero routes — no default, and no205.151.11.13/32.ip route get 205.151.11.13went out wlan0.reapplyresets a device to its connection's config, and every clat route is written by the CLAT daemon outside NM. Restored withsystemctl restart blueline-clat.service; the code now reapplies onlywlan0. Never reapply a device whose routes NM did not write.- The
gsmconnection is the wrong lever. The modem is v6-only; the v4 default for the carrier isdefault dev clat scope link metric 2048, installed by the CLAT daemon. Aroute-metricon the gsm connection is a number nothing reads. Wifi's metric is the only lever there is — under 2048 wifi wins, over it the carrier does — which is also why 2048 belongs in the code as a named reference rather than a guess.
And a privilege defect the tests could not catch. The tunnel read off
while wiufph was up and handshaking (4.11 KiB received). wg show needs
CAP_NET_ADMIN and sessiond is a systemd user unit, so it can never read
peer data — "Unable to access interface wiufph: Operation not permitted".
Replaced with /sys/class/net/*/statistics/rx_bytes plus DEVTYPE=wireguard,
both world-readable and the same number. The endpoint lookup had it too, and
is now read from nmcli -g wireguard.peers — where it turns out to be a
hostname (sub.wiuf.net:51822), not the address the old gate hardcoded.
Still open: acceptance #3–#5, and dns=systemd-resolved, which nothing here
touches and which is what makes ~wiuf.net mean anything. dns=systemd-resolved remains untouched and is what makes
~wiuf.net mean anything; nothing here addresses split DNS.
Acceptance
- On foreign wifi with cellular also up, ordinary traffic leaves by wifi and
ip route get 1.1.1.1says so. - MMS still works:
205.151.11.13stays on clat and Fido's resolvers remain in the resolver set. gitea.wiuf.netresolves to10.10.20.120(the Pi-hole answer), not a public one, whenever the tunnel is up.- Dropping wifi mid-session moves traffic to cellular and back without the user touching anything, and without the tunnel being recycled.
wg showreceived-bytes is non-zero within 30 s of the toggle going on, and a tunnel that is up but deaf is reported rather than left looking connected.- No new timer, no new dispatcher hook, no second writer of route metrics.
Connects to
docs/substrate/VPN-WIUF.md (correct its "works on cell" claim when this
lands), NET_LAYER_PLAN.md (the per-consumer policy engine this is the
device-state half of; the plan predates doctrine §13 and is unreconciled),
DEVICE-STATE-MACHINE.md §10 (source health — "associated" vs "carrying") and
§12 (Action, not daemon), TASK-08 (the state machine itself), TASK-19 (a bearer
readout belongs on the same page as the sensor readout).