aboutsummaryrefslogtreecommitdiff
path: root/docs/specs
diff options
context:
space:
mode:
Diffstat (limited to 'docs/specs')
-rw-r--r--docs/specs/2026-07-11-bt-doctor-expansion-spec.org184
-rw-r--r--docs/specs/2026-07-11-net-doctor-expansion-spec.org197
2 files changed, 381 insertions, 0 deletions
diff --git a/docs/specs/2026-07-11-bt-doctor-expansion-spec.org b/docs/specs/2026-07-11-bt-doctor-expansion-spec.org
new file mode 100644
index 0000000..0775a18
--- /dev/null
+++ b/docs/specs/2026-07-11-bt-doctor-expansion-spec.org
@@ -0,0 +1,184 @@
+#+TITLE: Bt Doctor Expansion — name the blob, catch the boot-disable
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-11
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DRAFT Bt Doctor Expansion
+:PROPERTIES:
+:ID: 3d4d61c4-e5df-44e9-b8e0-40b31452c3f7
+:END:
+- [2026-07-11 Sat @ 00:08:41 -0500] DRAFT — drafted. Extends the existing bt doctor (=~/.dotfiles/bluetooth/=, shipped) using the bluetooth half of the failure taxonomy ([[file:../design/2026-07-10-net-bt-failure-taxonomy.org][2026-07-10-net-bt-failure-taxonomy.org]]). Grounded in a read of the live engine, not memory.
+
+* Metadata
+
+| Field | Value |
+|----------+-----------------------------------------------------------------------------------|
+| Status | draft |
+|----------+-----------------------------------------------------------------------------------|
+| Owner | Craig Jennings |
+|----------+-----------------------------------------------------------------------------------|
+| Reviewer | Craig Jennings |
+|----------+-----------------------------------------------------------------------------------|
+| Related | [[file:../design/2026-07-10-net-bt-failure-taxonomy.org][net/bt failure taxonomy]] ; the cross-panel run-time-privilege and copy+close tasks |
+|----------+-----------------------------------------------------------------------------------|
+
+* Summary
+
+The bt doctor walks a clean chain — adapter → rfkill → bluetooth.service → powered → per-device → audio profile — and applies four safe repairs (unblock, power-on, service-restart, a2dp). The failure taxonomy shows the chain is structurally right but blind in two specific spots the user hits most: when there is no adapter it says "no adapter found" without naming *why* (a missing firmware blob the dmesg log already names), and it never checks whether the adapter is configured to power on at boot, so an =AutoEnable=false= laptop that is dead every login reads as a healthy powered-off adapter. This spec adds those two probes and adopts the cross-panel privilege model. It is an expansion of a working doctor.
+
+* Problem / Context
+
+The taxonomy sorted ~55 real bluetooth failure modes into five clusters keyed to the doctor's chain. Read against the live engine (=~/.dotfiles/bluetooth/src/bt/doctor.py=), the doctor already handles cluster 2 well (unblock, service-restart, power-on are its exact auto-fix tiers) and cluster 5 partially (the =a2dp= repair forces the A2DP profile for a card stuck in HSP/HFP or with no sink). Clusters 3 (pairing) and 4 (connection stability) are deliberately light — the doctor treats connecting and pairing as user intents, not health repairs, and that is a correct design choice, not a gap. The two real gaps are at the ends of the chain.
+
+** "No adapter found" doesn't say why (cluster 1)
+
+=_adapter_step= reports a missing adapter as "no Bluetooth adapter found (hardware/driver)" and stops. But the taxonomy's cluster 1 is almost entirely *firmware* faults, and the kernel already logged the specific cause: "Direct firmware load for intel/ibt-…sfi failed", "mediatek/BT_RAM_CODE_MT7961… failed", "BCM: Patch …hcd not found", "Reading QCA version information failed". The doctor has that log a =journalctl -k= read away and doesn't consult it. A user who sees "no adapter — hardware/driver" is left to search; a user who sees "the Intel firmware blob ibt-20-1-4.sfi failed to load — update linux-firmware and reboot" has the fix. This is the direct parallel to the audio doctor's cluster-1 dmesg-hint proposal, and the same read-only, bounded shape.
+
+** A boot-disabled adapter reads as merely powered-off (cluster 2)
+
+=_powered_step= checks whether the adapter is powered *now* and offers =power-on=. But =power-on= via bluetoothctl doesn't persist: on an =AutoEnable=false= machine (bluez's static default when main.conf is absent), or a TLP laptop that disables bluetooth on startup, the adapter is dead again next boot. The doctor fixes the symptom every session and never names the cause. The taxonomy's cluster 2 has a whole sub-family here — AutoEnable off, service-not-enabled-at-boot, TLP-disables-on-startup, systemd-rfkill-restores-a-stale-block — that all present as "powered off / blocked" and all need a *persistent* fix the doctor doesn't distinguish from a one-shot power-on.
+
+** Pairing and connection clusters stay light on purpose (clusters 3, 4)
+
+The doctor doesn't auto-pair or auto-connect, and the destructive re-pair is always user-confirmed. That is right. The one addition worth considering is a *stale-bond signature* — a device that fails to connect with a bond present, repeatedly — so the doctor can *offer* the re-pair with confidence instead of the user guessing. That is a candidate, held as a decision, not a v1 commitment. The rest of clusters 3/4 (physical range, USB3 noise, coexistence, connection parameters) are Guide-only and mostly out of a health doctor's reach.
+
+* Goals and Non-Goals
+
+** Goals
+
+- When there is no adapter, name the specific firmware/driver cause by reading the kernel log, and print the matching fix — instead of a generic "hardware/driver."
+- Distinguish a *persistently* disabled adapter (AutoEnable off, service not enabled at boot, TLP-disabled) from a merely powered-off one, and offer the persistent fix rather than a one-shot power-on that dies next boot.
+- Adopt the cross-panel run-time privilege model for the new root-needing repairs (editing main.conf's AutoEnable, =systemctl enable=, editing tlp.conf), under the Confirm/Arm-default floor.
+
+** Non-Goals
+
+- Auto-pairing or auto-connecting devices. Connecting and pairing are user intents; the doctor's existing stance stands.
+- Fixing the firmware itself. The dmesg-hint probe *names* the missing blob and prints the update/reboot instruction; it does not install firmware or rebuild initramfs inside a diagnose run (that is a Reboot-tail Guide the user runs).
+- The physical/coexistence connection-stability faults (USB3 noise, range, 2.4GHz coexistence, connection parameters). These are Guide-only and stay out of the health chain.
+- Silent privileged action. Editing main.conf, enabling the service, or touching tlp.conf all default to Confirm/Arm, never silent Auto.
+
+** Scope tiers
+
+- *v1:* the dmesg firmware-hint probe for the no-adapter case (cluster 1); the boot-enablement probe distinguishing AutoEnable-off / service-not-enabled / TLP-disabled from a plain power-off (cluster 2), with the persistent fix; adoption of the run-time privilege model.
+- *Out of scope:* auto-pair/auto-connect; firmware installation inside a run; the physical/coexistence connection faults.
+- *vNext:* the stale-bond re-pair-offer signature (cluster 3, pending the decision below); a connection-parameter/coexistence hint tail for a "keeps dropping" verdict (cluster 4); the bluetooth-specific audio-profile expansion beyond the current a2dp repair (codec fallback, default-sink, absolute-volume — several overlap the audio taxonomy and want coordination with the audio doctor). All logged to =todo.org=.
+
+* Design
+
+** For the user
+
+Two verdicts that today under-inform start telling the user the actual cause.
+
+When there is no adapter, the wall stops at "no adapter" no longer. It names the blob and the fix: "the MediaTek BT firmware (BT_RAM_CODE_MT7961) failed to load — update linux-firmware and reboot," pulled straight from the kernel log the doctor now reads. When the log shows a clean absence (no firmware error, genuinely no controller), it says that instead — the same fail-honest distinction the audio doctor draws between "the server lost a device" and "nothing is attached."
+
+When the adapter is powered off, the doctor asks a second question: is it *supposed* to be on at boot? If AutoEnable is off, or bluetooth.service isn't enabled, or TLP is disabling it on startup, the verdict names that — "your adapter powers off every boot because AutoEnable is false" — and FIX offers the persistent fix (set AutoEnable, enable the service) rather than a power-on that will be undone by the next reboot. A plain "it's off right now" still gets the quick power-on.
+
+** For the implementer
+
+*** The dmesg firmware-hint probe
+
+When =_adapter_step= finds no adapter, run a bounded =journalctl -k -b --no-pager= (or =dmesg=) read, scanned for the known firmware-load-failure signatures per vendor (Intel ibt-*.sfi, MediaTek BT_RAM_CODE, Realtek rtl_bt, Broadcom BCM .hcd, Qualcomm QCA version-read). A match yields a specific =no-adapter-firmware= verdict naming the blob and the Reboot-tail Guide (update linux-firmware / symlink the blob / reboot). No match yields the existing generic =no-adapter=. The read is bounded and read-only; it runs only in the no-adapter branch, so it costs nothing on a healthy adapter.
+
+*** The boot-enablement probe
+
+When =_powered_step= finds the adapter powered off (or =_rfkill_step= finds a soft-block), consult three persistence signals: bluez =AutoEnable= (parse =/etc/bluetooth/main.conf= [Policy], defaulting to the bluez static default when absent), =systemctl is-enabled bluetooth=, and whether TLP's =DEVICES_TO_DISABLE_ON_STARTUP= lists bluetooth. A powered-off adapter with a persistence fault gets a =powered-off-persistent= verdict distinct from the plain =powered-off=; its fix is the persistent one (set AutoEnable / enable the service / edit tlp.conf), Privileged/Confirm. The existing =power-on= stays for the plain case.
+
+*** The privilege model
+
+The new repairs — editing main.conf, =systemctl enable bluetooth=, editing tlp.conf — are root-needing and adopt the cross-panel run-time model: run where passwordless sudo exists, prompt on a tty, default to Confirm/Arm, never silent Auto. Same shared implementation as the net and audio doctors. The existing four auto-fix tiers (unblock, power-on, service-restart, a2dp) are unchanged — they are already user-scope or already the doctor's safe tier.
+
+* Alternatives Considered
+
+** Print a generic "check firmware" hint without reading the log
+
+- Good, because it needs no journal read.
+- Bad, because "check your firmware" is exactly the uselessly-generic verdict this fixes; the whole value is naming the specific blob the kernel already identified.
+- Rejected. The log has the answer; read it.
+
+** Make power-on always persistent (set AutoEnable on every power-on)
+
+- Good, because the adapter would then stay on across boots without a separate verdict.
+- Bad, because it conflates a deliberate boot-disable (a user or TLP choosing bluetooth-off-by-default for battery) with a fault, and silently overrides a policy the user may have set on purpose. Persistence is a distinct decision that deserves a distinct, confirmed action.
+- Rejected in favor of a separate verdict the user confirms.
+
+** Build the stale-bond re-pair signature into v1
+
+- Good, because it would let the doctor offer re-pair with confidence.
+- Bad, because re-pair is destructive (removes the bond) and the signature ("fails to connect with a bond present, repeatedly") needs care to not fire on a merely-out-of-range device; getting it wrong offers a destructive fix for a transient condition.
+- Held as an open decision, not a v1 commitment.
+
+* Decisions [0/3]
+
+** TODO The dmesg firmware-hint probe
+Context: cluster 1 is almost all firmware faults the kernel already logs, and the doctor reports a generic "hardware/driver" instead of the named blob.
+Decision (proposed): we will read the kernel log in the no-adapter branch, match the known per-vendor firmware-load-failure signatures, and emit a =no-adapter-firmware= verdict naming the blob and its Reboot-tail Guide; no match falls back to the generic verdict.
+Consequences: the most common no-adapter cause becomes self-explaining; harder — a per-vendor signature table to maintain, and the read must stay bounded and only run in the no-adapter branch so it never slows a healthy run.
+Owner: Craig. By: before Phase 1 lands.
+
+** TODO The boot-enablement probe and the persistent-vs-transient split
+Context: =power-on= doesn't persist, so an AutoEnable-off / not-enabled / TLP-disabled adapter is dead every boot and the doctor fixes only the symptom.
+Decision (proposed): we will add a persistence probe (AutoEnable, service-enabled, TLP) and a =powered-off-persistent= verdict distinct from =powered-off=, whose fix is the persistent one under the Confirm floor; the plain power-on stays for the transient case.
+Consequences: the boot-disable cause is named and fixed once instead of every session; harder — the doctor must not override a *deliberate* boot-disable, so the persistent fix is always confirmed and the verdict names the specific persistence cause rather than blanket-enabling.
+Owner: Craig. By: before Phase 2 lands.
+
+** TODO Whether the stale-bond re-pair-offer lands in v1 or vNext
+Context: a device failing to connect with a bond present, repeatedly, is a re-pair candidate — but re-pair is destructive and the signature can misfire on an out-of-range device.
+Decision (proposed): defer to vNext. v1 keeps re-pair strictly user-initiated; the signature is designed and validated before the doctor ever *offers* it.
+Consequences: no risk of the doctor offering a destructive fix for a transient condition in v1; harder — the "keeps failing to connect" case stays unnamed for now, which is the one cluster-3 gap a user might reasonably expect the doctor to catch.
+Owner: Craig. By: before Phase 1 (scope confirmation).
+
+* Implementation phases
+
+Each phase leaves the tree green and independently useful, as the existing bt phases did.
+
+** TODO Phase 0 — the two read-only probes
+Pure engine, no repair changes. The dmesg firmware-hint reader (no-adapter branch) and the boot-enablement reader (AutoEnable / service-enabled / TLP), both reporting into the diagnose report. =bt diag --json= (or the equivalent) shows the new signals. Fakes: a canned journal buffer with each vendor's signature, and injected main.conf / =systemctl is-enabled= / tlp.conf states.
+
+** TODO Phase 1 — the firmware-hint verdict + privilege model
+=diagnose= emits =no-adapter-firmware= (naming the blob, Reboot-tail Guide) when the signature matches. The run-time privilege resolution lands (shared cross-panel code). No auto-fix — this verdict is a Guide.
+
+** TODO Phase 2 — the persistent-power verdict and its fix
+=powered-off-persistent= distinct from =powered-off=; the persistent fix (set AutoEnable / enable service / tlp) registers Privileged/Confirm. =bt doctor --fix= applies it under the Confirm floor; the plain power-on path is unchanged.
+
+** TODO Phase 3 — flip this spec to IMPLEMENTED
+And log the vNext items (stale-bond signature, connection-parameter hints, bt-audio-profile expansion) to =todo.org=.
+
+* Acceptance criteria
+
+- [ ] With no adapter and a MediaTek/Intel/Realtek/Broadcom/Qualcomm firmware-load error in the kernel log, the doctor names the specific blob and prints the update-and-reboot Guide.
+- [ ] With no adapter and no firmware error in the log, the doctor reports the generic "no controller attached" without inventing a firmware cause.
+- [ ] An adapter that is powered off with AutoEnable=false reports =powered-off-persistent= and FIX offers the persistent fix, not just a one-shot power-on.
+- [ ] An adapter powered off with AutoEnable on (a transient off) still gets the quick power-on.
+- [ ] Every new root-needing repair defaults to Confirm/Arm and never runs silently as Auto.
+- [ ] The existing chain (rfkill/service/powered/device/audio) and its four auto-fix tiers classify and repair exactly as today (regression).
+
+* Readiness dimensions
+
+- *Data model & ownership* — the diagnose report gains a firmware-hint field (generated from the kernel log) and boot-persistence signals (generated from main.conf / systemctl / tlp.conf). The doctor writes main.conf / tlp.conf / the service enablement only under the Confirm floor.
+- *Errors, empty states & failure* — an unreadable journal or main.conf yields "unknown," never a false firmware or persistence verdict. A no-match firmware scan falls back to the existing generic verdict (the safe direction).
+- *Security & privacy* — device MACs are already redacted by =redact.py=; the new probes read the kernel log and config files, no secrets. The firmware-signature scan must not surface unrelated journal content.
+- *Observability* — the wall names the blob and the persistence cause. The =--json= output carries the new signals.
+- *Performance & scale* — the journal read runs only in the no-adapter branch; the persistence read only when powered-off/blocked. Neither touches a healthy run.
+- *Reuse & lost opportunities* — reuses the existing chain, =btctl=, =sysio=, =priv.py=, and the shared cross-panel privilege model rather than a bt-local one. =diagnose= stays the single report authority.
+- *Architecture fit* — both probes are additive inside existing chain branches (=_adapter_step=, =_powered_step=), not new chain links. Weak point: the firmware-signature table is vendor-specific and will drift as new hardware appears; it degrades to the generic verdict rather than mis-naming, which bounds the risk.
+- *Config surface* — none new for the doctor; it *reads* bluez/tlp config and *writes* it only as a confirmed repair. N/A for its own knobs.
+- *Documentation plan* — module docstrings, as the package does today. The wall is the user documentation.
+- *Dev tooling* — =make test= and the bt panel smoke cover it; the new probes need a canned journal fixture and injected config states, fixture shapes the package can adopt.
+- *Rollout, compatibility & rollback* — additive; the existing chain is untouched. The persistent fixes change bluez/tlp config and service enablement, so all are Confirm-tier and reversible by the user.
+- *External APIs & deps* — =journalctl -k=/=dmesg=, =/etc/bluetooth/main.conf=, =systemctl is-enabled=, and =/etc/tlp.conf= layouts are verified against the live system before Phase 0. The per-vendor firmware signatures come from the taxonomy's cluster-1 sources; no new packages.
+
+* Risks, rabbit holes, and drawbacks
+
+The firmware-signature table is the maintenance rabbit hole: each vendor logs its failure differently and new hardware adds new strings. The mitigation is that a miss degrades to the existing generic verdict — the doctor is never *worse* than today, only sometimes not-better — so the table can grow incrementally without a correctness cliff.
+
+The persistent-power fix must not fight a deliberate choice. A user (or TLP on a battery-conscious laptop) may want bluetooth off by default; blanket-enabling AutoEnable would override that silently. The verdict names the specific persistence cause and the fix is always confirmed, so the user sees exactly what would change before it does.
+
+The bt-audio-profile expansion (vNext) overlaps the audio taxonomy's Bluetooth-mic cluster. When it is picked up, it needs coordination with the audio doctor so the two panels don't both claim the same A2DP/HFP diagnosis with divergent verdicts. Named here so the seam is known.
+
+* Review and iteration history
+
+** 2026-07-11 Sat @ 00:08:41 -0500 — Craig Jennings — Author
+- What: drafted the bt doctor expansion from the bluetooth half of the failure taxonomy. v1 adds the dmesg firmware-hint probe (cluster 1) and the boot-enablement probe (cluster 2); the pairing/connection/audio expansions are staged to vNext.
+- Why: the taxonomy showed the doctor's chain is structurally right but blind at its ends — a no-adapter verdict that won't name the firmware blob the kernel already logged, and a power-on that doesn't persist across boots.
+- Artifacts: [[file:../design/2026-07-10-net-bt-failure-taxonomy.org][the net/bt failure taxonomy]]; code read across =~/.dotfiles/bluetooth/src/bt/= (=doctor.py=, =repair.py=, =btctl.py=, =sysio.py=, =audio.py=, =priv.py=).
diff --git a/docs/specs/2026-07-11-net-doctor-expansion-spec.org b/docs/specs/2026-07-11-net-doctor-expansion-spec.org
new file mode 100644
index 0000000..aed22b8
--- /dev/null
+++ b/docs/specs/2026-07-11-net-doctor-expansion-spec.org
@@ -0,0 +1,197 @@
+#+TITLE: Net Doctor Expansion — the clusters the ladder doesn't yet name
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-11
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DRAFT Net Doctor Expansion
+:PROPERTIES:
+:ID: ce29b103-ed9d-4f56-bf8c-9ed8fe680ff3
+:END:
+- [2026-07-11 Sat @ 00:08:41 -0500] DRAFT — drafted. Extends the existing net doctor (=~/.dotfiles/net/=, shipped) using the network half of the failure taxonomy ([[file:../design/2026-07-10-net-bt-failure-taxonomy.org][2026-07-10-net-bt-failure-taxonomy.org]]). Grounded in a read of the live engine, not memory.
+
+* Metadata
+
+| Field | Value |
+|----------+-----------------------------------------------------------------------------------|
+| Status | draft |
+|----------+-----------------------------------------------------------------------------------|
+| Owner | Craig Jennings |
+|----------+-----------------------------------------------------------------------------------|
+| Reviewer | Craig Jennings |
+|----------+-----------------------------------------------------------------------------------|
+| Related | [[file:../design/2026-07-10-net-bt-failure-taxonomy.org][net/bt failure taxonomy]] ; the cross-panel run-time-privilege and copy+close tasks |
+|----------+-----------------------------------------------------------------------------------|
+
+* Summary
+
+The net doctor is the most mature of the three panel doctors: its probe ladder already walks link → IP → gateway → route → DNS → egress, and its classifier already names most of the taxonomy's failure clusters with the lightest fix. This spec closes the two clusters it does *not* reach — control-plane conflicts (two network managers fighting over one interface, a masked NetworkManager, a keyfile the daemon silently refuses) and the sharper naming of the terminal auth cluster — and adopts the cross-panel run-time privilege model. It is an expansion of a working doctor, not a rewrite.
+
+* Problem / Context
+
+The failure taxonomy sorted ~74 real network failure modes into eight symptom clusters. Read against the live classifier (=~/.dotfiles/net/src/net/classify.py=), the net doctor already reaches six of them: cluster 1 (=rfkill=, =manage-device=, no-hardware), cluster 2 (DHCP-failed), cluster 3 (=tunnel-down=, =vpn-policy=), cluster 5 (=resolved-restart=, DNS-not-resolving, =dns-test=/=dns-override=), and cluster 6 (=portal=, =clock-sync=, =proxy=, and the =upstream-not-local= terminal STOP). The classifier's terminal-first ordering already refuses to loop repairs against a wrong password, a held portal, or a VPN-owned route. That is a lot of the taxonomy, already built.
+
+Two clusters fall through.
+
+** The control plane can be broken while the radio looks fine (cluster 7)
+
+The taxonomy's largest untouched cluster is NetworkManager itself. When =dhcpcd.service= runs beside NM's internal DHCP client, or =systemd-networkd= and NM both claim one link, or =iwd= and =wpa_supplicant= are both active, the interface flaps or never leases — and every existing probe reads a plausible-looking radio with no verdict that names the fight. NM masked (=systemctl start= returns "Unit is masked") reads as "NetworkManager isn't running" today, which points =nm-restart= at a service that cannot start. A =.nmconnection= keyfile that isn't =600= root-owned is silently skipped by the daemon, so a saved network "just won't connect" with no signal the doctor surfaces. These are distinct root causes with distinct fixes, and the doctor currently has one verdict (=nm-restart=) covering the whole control plane.
+
+** The auth cluster is named too coarsely (cluster 4)
+
+=classify= detects an auth failure from NM state 120 / GENERAL.REASON and returns =needs-user-action= — correct, and correctly terminal. But the taxonomy shows the auth cluster is not one failure: a pure-WPA3/SAE association failure, a hidden SSID never probed, an enterprise cert mismatch, and a wrong regulatory domain are different faults with different next actions, and some (SAE key-mgmt, =wifi.hidden yes=) are one-line profile fixes rather than "re-enter the password." The doctor collapses them all to one message.
+
+** The flaky/drops cluster needs signal the one-shot doctor doesn't collect (cluster 8)
+
+Powersave disconnects, roaming stalls, USB autosuspend, no-reconnect-after-resume, firmware crashloops — these are intermittent, and a single-shot "why am I offline right now" probe cannot see them. Naming them needs event-log correlation the doctor doesn't do. This cluster is real but out of v1; it is named here so the boundary is explicit.
+
+* Goals and Non-Goals
+
+** Goals
+
+- Add a control-plane cluster: a probe that detects a second network/DHCP manager active alongside NetworkManager, a masked/failed NM distinct from a merely-stopped one, and a keyfile-permission fault, each with its own verdict and the lightest fix.
+- Sharpen the auth verdict: extract the specific auth cluster cause (SAE, hidden SSID, enterprise cert, regdom) so =needs-user-action= names the real next step, and apply the one-line profile fix where one exists.
+- Adopt the cross-panel run-time privilege model (Auto / Privileged / Reboot-tail / Guide, resolved from =sudo -n= + tty + GUI), so control-plane repairs that need root (=systemctl disable dhcpcd=, =chmod= a keyfile, =systemctl unmask=) run under the same Confirm/Arm-default floor the audio doctor defined.
+
+** Non-Goals
+
+- Rebuilding the existing ladder. Clusters 1/2/3/5/6 stay exactly as they classify today; this spec only adds where the taxonomy shows a gap.
+- The flaky/drops cluster (cluster 8). Naming intermittent faults needs event-log correlation that a one-shot doctor doesn't do. Logged to =todo.org=, not built here.
+- Turning the doctor into a NetworkManager profile editor. The auth-cluster fixes are limited to the one-line profile settings that get a stuck association online (SAE key-mgmt, hidden flag); it will not manage certificates, enterprise identities, or credential entry — those stay Guide.
+- Silent privileged action. Every Privileged/Reboot-tail control-plane repair defaults to Confirm/Arm, never silent Auto — same stance as the audio spec.
+
+** Scope tiers
+
+- *v1:* the control-plane conflict probe and its verdicts (rival-manager, NM-masked, keyfile-perms); sharper auth-cluster reason extraction plus the SAE/hidden one-line fixes; adoption of the run-time privilege model for the new root-needing repairs.
+- *Out of scope:* the flaky/drops cluster; certificate/enterprise credential management; any change to the six clusters already classified.
+- *vNext:* event-log correlation for cluster 8 (powersave/roam/suspend/autosuspend drop signatures); a DoT/DNSSEC-specific verdict distinguishing "the venue resolver mangles DNSSEC" from the generic DNS-not-resolving; per-profile autoconnect/duplicate-profile hygiene. All logged to =todo.org=.
+
+* Design
+
+** For the user
+
+Nothing changes for the six clusters that already work. What changes is that two failure shapes that today produce a wrong or vague verdict start naming themselves.
+
+When a second manager is fighting NetworkManager for the link, the wall says so by name — "dhcpcd is running alongside NetworkManager and they're fighting over the interface" — and offers the fix (stop the rival) rather than bouncing a connection that will flap again the moment the other daemon re-grabs it. When NM is masked, the verdict distinguishes "masked — it can't start until unmasked" from "stopped," so FIX unmasks rather than uselessly restarting. When a saved network silently won't load because its keyfile is world-readable, the doctor names the permission fault instead of leaving the user to wonder why a known-good network never activates.
+
+When association fails on auth, the wall stops saying only "authentication failed" and names which auth: a WPA3-only network the profile isn't set for, a hidden SSID that needs the hidden flag, an enterprise network missing its CA cert. The first two carry a one-press fix; the rest tell the user the specific thing to supply.
+
+** For the implementer
+
+*** The control-plane probe
+
+A new read-only probe tier, beside the existing ones, answering three questions the current ladder never asks:
+
+1. *Is a rival manager active?* Check whether =dhcpcd.service=, =systemd-networkd.service=, or a standalone =iwd.service= is active while NetworkManager is also active and owns (or wants to own) the link. This is a =systemctl is-active= read plus NM's backend setting — bounded, no hang. A rival that is active is the verdict; the fix is =systemctl disable --now <rival>= (Privileged), never a connection bounce.
+2. *Is NM masked or failed, as distinct from stopped?* =systemctl is-enabled NetworkManager= returns =masked=; the unit's =ActiveState=/=Result= distinguishes a crash-loop from a clean stop. A masked NM gets an =unmask= verdict (Privileged), not the existing =nm-restart=.
+3. *Does the active profile's keyfile have the wrong permissions?* For the selected connection, stat its =/etc/NetworkManager/system-connections/*.nmconnection=; a non-=600= or non-root-owned file is the silent-skip fault. Fix: =chmod 600= + =chown root= (Privileged).
+
+The probe runs before the existing "NetworkManager isn't running" rule, because a masked NM and a rival-manager fight are both more specific than "not running" and would otherwise be mis-verdicted by it.
+
+*** The classifier gains control-plane verdicts
+
+=classify= adds, in terminal-and-specificity order ahead of the generic =nm-restart= rule: =rival-manager= (needs-user-action → Privileged fix), =nm-masked= (fixable → unmask), =keyfile-perms= (fixable → chmod/chown). Each carries evidence naming the specific rival/unit/file. These are additive; the existing rules below them are untouched.
+
+*** The auth verdict takes a reason
+
+=gather_context='s existing =auth_failed_reason= (already extracted from GENERAL.REASON and the journal) grows a small classifier: SAE/PMF, hidden-SSID, enterprise-cert, regdom, or generic-PSK. The =needs-user-action= message is keyed off it. For SAE and hidden-SSID — the two with a deterministic one-line profile fix — the verdict becomes =fixable= with a Privileged/Auto profile-modify action (=key-mgmt sae= + PMF, or =wifi.hidden yes=) rather than terminal. The rest stay =needs-user-action= with a sharpened message.
+
+*** The privilege model
+
+The new repairs (=systemctl disable <rival>=, =unmask=, =chmod=/=chown= a keyfile, profile-modify) are the net doctor's first root-needing doctor repairs beyond the ones already in =priv.py=. They adopt the cross-panel run-time resolution the audio spec defined: Privileged remedies run silently where passwordless sudo exists (every archsetup install), prompt on a CLI with a tty, and default to Confirm/Arm — never silent Auto. This is the same standard, not a net-specific one; the shared implementation is the tracked cross-panel task.
+
+* Alternatives Considered
+
+** Fold the control-plane faults into the existing =nm-restart= verdict
+
+- Good, because it is zero new classifier surface.
+- Bad, because =nm-restart= is the wrong fix for all three: restarting a masked NM fails, restarting NM does not stop a rival =dhcpcd=, and it does nothing for a bad keyfile. A shared verdict would send the doctor's one fix at three faults it can't fix.
+- Rejected. Different root causes with different fixes are different verdicts — the same principle the audio spec used for =pulse-hung= vs =pulse-down=.
+
+** Make the doctor a full profile editor for the auth cluster
+
+- Good, because it could fix more auth failures automatically.
+- Bad, because credential and certificate entry is a genuine user decision, not a repair — the doctor cannot invent an enterprise CA or a password. Auto-editing profiles beyond the two deterministic one-liners risks writing a wrong setting the user then has to unwind.
+- Rejected in favor of fixing only SAE key-mgmt and the hidden flag, and guiding the rest.
+
+** Build the flaky/drops cluster now with a synthetic re-probe
+
+- Good, because it would catch powersave/autosuspend "works then dies" cases.
+- Bad, because a one-shot doctor invoked when the user is already offline has no drop history to read; catching intermittent faults needs the panel's event log correlated over time, which is a separate probe surface.
+- Rejected for v1; logged as vNext.
+
+* Decisions [0/3]
+
+** TODO The control-plane probe and its three verdicts
+Context: cluster 7 is the taxonomy's largest untouched cluster, and the current single =nm-restart= verdict mis-serves a masked NM, a rival manager, and a bad keyfile.
+Decision (proposed): we will add a read-only control-plane probe (rival-manager active-check, NM masked-vs-failed-vs-stopped, active-profile keyfile permissions) and three verdicts ahead of the generic =nm-restart= rule, each with the lightest specific fix.
+Consequences: the doctor names the fight instead of bouncing a link that will re-flap; harder — three new verdicts and a probe that reads =systemctl= state plus a stat, and the ordering has to sit ahead of the existing not-running rule without disturbing it.
+Owner: Craig. By: before Phase 1 lands.
+
+** TODO How far the auth-cluster fix goes
+Context: the auth cluster is terminal today; some members (SAE, hidden SSID) have deterministic one-line profile fixes, others (enterprise cert, credential) do not.
+Decision (proposed): we will extract the specific auth reason and make only SAE-key-mgmt and hidden-flag =fixable=; everything else stays =needs-user-action= with a sharpened, cause-named message.
+Consequences: two more auth failures self-heal; harder — the doctor now writes to a connection profile, which is a heavier action than a bounce, and the boundary between "fix" and "guide" inside one cluster has to be defended so it doesn't creep into credential management.
+Owner: Craig. By: before Phase 2 lands.
+
+** TODO Adopt the run-time privilege model as the cross-panel standard
+Context: the new control-plane repairs need root; the audio spec already defined the four-class run-time model and made it a cross-panel standard.
+Decision (proposed): we will adopt it verbatim — Privileged repairs run where passwordless sudo exists, prompt on a tty, default to Confirm/Arm, never silent Auto — sharing the implementation with the other panels rather than reimplementing it.
+Consequences: the net doctor's root repairs are consistent with audio/bt/maint; harder — it couples this spec to the shared privilege-model task, so the ordering across panels has to be settled (which panel lands the shared code).
+Owner: Craig. By: before Phase 1 lands.
+
+* Implementation phases
+
+Each phase leaves the tree green and independently useful, as the existing net phases did.
+
+** TODO Phase 0 — the control-plane probe (read-only)
+Pure engine, no classifier changes. A probe module that reports rival-manager state, NM masked/failed/stopped, and active-profile keyfile permissions into the diag context. =net diag --json= shows the new signals. Fakes: injected =systemctl is-active/is-enabled= results and a temp system-connections tree.
+
+** TODO Phase 1 — control-plane verdicts + the privilege model
+=classify= gains =rival-manager=, =nm-masked=, =keyfile-perms=, ordered ahead of the generic not-running rule. The run-time privilege resolution lands (shared with the cross-panel task) and the three new fixes register as Privileged/Confirm. =net doctor= names them; =net doctor --fix= applies them under the Confirm floor.
+
+** TODO Phase 2 — the sharpened auth verdict
+The auth-reason classifier; SAE and hidden-SSID become =fixable= profile-modify repairs; the rest get cause-named =needs-user-action= messages. Pairwise over (reason × profile-state).
+
+** TODO Phase 3 — flip this spec to IMPLEMENTED
+And log the vNext items (flaky/drops cluster, DoT/DNSSEC verdict, profile hygiene) to =todo.org=.
+
+* Acceptance criteria
+
+- [ ] With =dhcpcd.service= active alongside NetworkManager, the doctor reports =rival-manager= naming dhcpcd, and FIX stops it rather than bouncing the connection.
+- [ ] A masked NetworkManager reports =nm-masked= (distinct from stopped), and FIX unmasks it.
+- [ ] A non-600 keyfile for the active profile reports =keyfile-perms=, and FIX corrects the permissions.
+- [ ] A WPA3-only association failure reports the SAE cause and (with --fix) sets the profile's key-mgmt, rather than saying only "authentication failed."
+- [ ] An enterprise-cert auth failure stays =needs-user-action= but names the missing CA cert.
+- [ ] Every new root-needing repair defaults to Confirm/Arm and never runs silently as Auto.
+- [ ] The six existing clusters classify exactly as they do today (regression).
+
+* Readiness dimensions
+
+- *Data model & ownership* — the diag context gains control-plane signals (generated per-probe) and a richer =auth_failed_reason= (generated from GENERAL.REASON + journal, as today). The doctor never writes NM config except the two auth profile-modifies and the keyfile-perms fix, all under the Confirm floor.
+- *Errors, empty states & failure* — an unreadable =systemctl=/=stat= yields "unknown," never a false rival/masked/perms verdict (the safe direction). Partial reads degrade to the existing behavior.
+- *Security & privacy* — SSIDs and connection names are already redacted by =redact.py=; the keyfile probe reads permissions, not secrets. The auth-reason extraction must not leak the PSK (it reads NM's reason string, not the key).
+- *Observability* — the wall names the specific rival/unit/file. =--json= carries the new context.
+- *Performance & scale* — three =systemctl= reads and a stat; negligible beside the existing probe cost.
+- *Reuse & lost opportunities* — reuses =gather_context='s existing auth-reason extraction, =priv.py=, and the shared cross-panel privilege model rather than a net-local one. =classify= stays the single verdict authority.
+- *Architecture fit* — the new probe is additive beside the existing tiers; the classifier additions sit ahead of the generic not-running rule and leave the rest untouched. Weak point: ordering — the new verdicts must precede =nm-restart= or a masked/rival case mis-classifies.
+- *Config surface* — none new. N/A.
+- *Documentation plan* — module docstrings, as the package does today. The wall is the user documentation.
+- *Dev tooling* — =make test= and the net panel smoke cover it; the control-plane probe needs injected =systemctl= state, a fixture shape the package already uses elsewhere.
+- *Rollout, compatibility & rollback* — additive; =net doctor= with no new fault behaves as today. The auth profile-modify and keyfile-perms fixes change persisted NM state, so both are Confirm-tier and reversible by the user.
+- *External APIs & deps* — =systemctl is-active/is-enabled=, =nmcli=, and =/etc/NetworkManager/system-connections= layout are all already used by the engine; no new packages. The exact rival-manager set (dhcpcd/networkd/iwd) is verified against the live system before Phase 0.
+
+* Risks, rabbit holes, and drawbacks
+
+The rival-manager check can false-positive if a rival service is active but not actually contending for the same interface (e.g. =systemd-networkd= managing a container bridge while NM owns wifi). The probe must scope the conflict to the link the doctor is diagnosing, not merely "is networkd running" — otherwise it cries wolf on a legitimate split. This is the main correctness rabbit hole and wants a test with a bridge-only networkd.
+
+The auth-cluster boundary between "fix" and "guide" is a slope. SAE and the hidden flag are safe because they are deterministic and reversible; the temptation is to add "just one more" auto-fix until the doctor is editing enterprise profiles it shouldn't. The Non-Goal is the guardrail; hold it.
+
+Coupling to the shared privilege-model task means this spec can't fully land until that model exists somewhere. Sequence it: whichever panel lands the shared code first, the others depend on it.
+
+* Review and iteration history
+
+** 2026-07-11 Sat @ 00:08:41 -0500 — Craig Jennings — Author
+- What: drafted the net doctor expansion from the network half of the failure taxonomy. v1 adds the control-plane cluster (rival-manager, NM-masked, keyfile-perms) and sharpens the auth verdict; the flaky/drops cluster is staged to vNext.
+- Why: the taxonomy showed the net doctor already reaches six of eight clusters, and the two it misses (control plane, auth naming) are where its verdicts are wrong or vague today.
+- Artifacts: [[file:../design/2026-07-10-net-bt-failure-taxonomy.org][the net/bt failure taxonomy]]; code read across =~/.dotfiles/net/src/net/= (=classify.py=, =diag.py=, =doctor.py=, =repair.py=, =priv.py=).