A scripted edit stacked three doc comments onto panel_on() and left
set_panel() with none. Comments are load-bearing here — set_panel is the
report half and request_screen is the ask half, and the whole reason both
exist is that a report must not actuate and an ask must not silently edit
the machine's idea of the hardware. That distinction was sitting on the
wrong function.
The state machine could turn the screen off and had no way to turn it
back on. `apply_gesture` answers a power tap on a dark panel with
`Restore` — which is brightness — and there was no unblank in the action
table at all. That was invisible because the wake never came through the
daemon: hyprland.lua bound the physical key straight to
blueline-screen-toggle, so the compositor woke the panel and sessiond
only heard about it afterwards.
viewtop consumes that key on purpose — the control that locks the phone
must not be interceptable by whatever is fullscreen — so the gap became a
phone that could sleep and never wake. Measured on hardware: panel off,
presses arriving and logged, no way back short of ssh.
Action::Unblank closes it, and a power tap now returns [Unblank, Restore]
in that order, because the brightness a panel comes back at means nothing
until the panel is back.
Also a `screen` verb, because a wake bound to a keybinding in a
compositor config is not something the agent can reach and §13 says that
is a defect. It is not a bypass: `on` is immediate, `off` routes through
request_blank() like every other path, so she can turn the screen off and
still cannot darken an unlocked session. Operation is hers; the ordering
is a property of the machine, not a permission she lacks.
100 tests, four new ones pinning exactly that.
logind refuses SetLockedHint for any session that is not Class=user. When
that happens LockedHint is pinned at no, is_locked() is permanently false,
every request_blank() times out its ack budget, and the panel goes dark on
a session nobody could confirm was locked — the machine keeps working and
only the security half is gone.
That state ran for weeks on blueline undetected: greetd's default_session
is the greeter slot, so any restart produced it, and the only trace was
one blank-without-lock per blank, which reads like a timing problem rather
than a structural one. Now it is one line naming the cause at startup.
A warning, not a refusal to start: a phone that will not boot because its
session class is wrong is worse than one that boots and says so.
LockedHint was never set on this device, so sessiond's `locked` (which
comes from logind per doctrine §4) was permanently false, request_blank()
timed out LOCK_ACK_BUDGET on every blank, and the panel went dark on a
session nobody could confirm was locked. LOCK-DPMS-LESSONS §1 held in the
code and not on the glass — every blank recorded blank-without-lock.
Three faults, measured 2026-08-02:
- Session.qml wrote the hint through /session/auto, which is the caller's
own session. The shell is not in the session that owns the seat:
viewtop in logind 66 (seat0/tty1), qs in 70. Written to one session,
read from another.
- lockhint.rs resolved via User.Display, which an ssh login is enough to
point at a seatless remote session — and which then went stale when
that session ended. Both sides now ask seat0 for its active session.
- The report was dropped outright. hasLoginctl starts false and only
flips when a Process probe returns; secure=true landed 30s earlier, so
the one edge that mattered hit the guard and was never retried, because
the shell locks once at boot and never changes again. Fourth
edge-vs-level bug after locked_ack, ChargeRate and bootBloomActive.
Verified on hardware: LockedHint now goes yes ~2s after a power tap.
`ensure_surfaces` returned early unless `state.locked`, and `locked` is
what the compositor sends once lock frames are up. ext-session-lock-v1 is
explicit: "The locked event must not be sent until a new 'locked' frame
has been presented on all outputs." So a compositor honouring the spec
waits for the surfaces this function refused to create until it had heard
from the compositor. Neither side can move.
Hyprland hides it by acking `locked` before any lock surface exists,
which is why this stood. Measured against viewtop on blueline today:
sessiond sat in `blocking_dispatch` waiting for a verdict that could not
arrive, and the session crash-looped every eleven seconds.
The wait loop was also deaf to the control channel for its whole 60s
budget, so a shell registering during it got `lock session did not
release in time` (server.rs waits 5s) and then asked the compositor for
a lock this thread still held — TASK-48's crash, reached from the daemon
side. It now drains Release before each dispatch and draws while it
waits, which is the order the protocol describes.
408 tests pass.
`shell_ready` blocks in the daemon for up to 5s waiting for its
lock-session thread to drop its Wayland connection, because the
compositor refuses a second locker while the first is alive
(server.rs `wait_timeout_while`). The shell's timer was 3s, so it gave
up on a handshake sessiond was still answering, assumed locked, and
asked for a lock that had not been released — straight into TASK-48's
"Tried to show lockscreen surfaces without active lock".
Under Hyprland the release usually lands in milliseconds and the race is
won. Measured against viewtop on blueline today it loses every time: the
shell crash-looped every 11 seconds and the session never came up.
Same latent bug, a compositor that exposes it.
7s, so the daemon's own deadline expires first. Its answer for that case
is a refusal (`lock session did not release in time`), and hearing a real
refusal beats inventing a verdict — "assuming locked" is the shell
holding state the authority owns.
The 5s retry timer only arms after this one fires, so nothing overlaps.
Both executors recorded success via record_error, so the daemon's own working
actions landed in the trail's error channel - visible on the phone as
{"event":{"error":{"component":"bearer","error":"preferred wifi"}}}. That makes
the one signal a reader scans for useless.
They also discarded every exit status, so a failed nmcli was indistinguishable
from a successful one and the trail would claim a metric that was never set.
Both now check status: record_decision on success, record_error with what
actually failed otherwise. The pin failing is expected without CAP_NET_ADMIN
and says so - the tunnel still works, it just follows the default route
instead of the chosen link, and those are different states.
Measured on the phone. After one PreferLink the clat device was up with its
192.0.0.1/32 address and *zero* routes: no default, and no 205.151.11.13/32,
which is the only path to the MMS proxy. ip route get 205.151.11.13 went out
wlan0. Restored by restarting blueline-clat.service.
nmcli device reapply resets a device to its connection's config, so it is
destructive to precisely the routes a sidecar daemon owns - and the CLAT
daemon owns all of clat's. TASK-49 acceptance #2 says carrier services ride
the carrier; this broke it on the first action it ever took.
The gsm connection was the wrong target too. The modem is v6-only and the v4
default for cellular is installed by the CLAT daemon as
'default dev clat scope link metric 2048', not by the gsm connection, so a
route-metric set there is a number nothing reads. Wifi's metric is the only
lever: under 2048 wifi wins, over it the carrier does. One connection
modified, one device reapplied, and the carrier's routes left alone.
Found on the phone: the bearer verb reported tunnel "off" while wiufph was up
and handshaking (4.11 KiB received). wg show needs CAP_NET_ADMIN for
WG_CMD_GET_DEVICE and sessiond is a systemd *user* unit, so it gets "Unable to
access interface wiufph: Operation not permitted" and every tunnel reads off,
forever. A daemon that cannot see the instrument it is built around.
/sys/class/net/<if>/statistics/rx_bytes is world-readable and is the same
number without the parse or the privilege; DEVTYPE=wireguard in uevent finds
the interface, and NM removes the link entirely when the profile goes down, so
its presence is the up/down signal.
The endpoint lookup had the same defect. NetworkManager hands wireguard.peers
to an unprivileged caller, and the endpoint is configured as a hostname
(sub.wiuf.net:51822), not the address the old gate hardcoded - so it is
resolved before pinning, and a failed resolution writes no pin rather than a
wrong one.
A policy field with no verb behind it is unreachable. set_policy takes both,
get_policy reports both, and a zero settling window is refused the same way a
zero lock-ack budget is - it is the event-speed controller by another name.
Nothing owned which link carries traffic. It was the residue of a DHCP metric,
a NetworkManager penalty and a shell script's assumption, and when those
disagreed the phone silently lost the network - every IPv4 destination leaving
over a degraded clat while wifi sat associated.
The thing this replaces, wiuf-vpn-gate, is worth stating because its three
defects are the design constraints:
- it was driven by the NM dispatcher AND a 90s timer while calling nmcli
up/down, which is itself an NM event. 652 tunnel recycles in 90 minutes,
~7/min - a loop, not a bad interval. Here the probe only reads, the decision
is a pure function, and the only egress is an Action from tick().
- its home test was ' inet 10.10.' - a /16 match that reads the foreign network
this phone lives on (10.10.30.213) as home. home_ssids is identity; an empty
list means never claim to be home.
- it managed CONN=wiuf while two profiles answer to that name.
LinkHealth keeps 'associated' apart from 'carrying', which is DEVICE-STATE-
MACHINE §10's source-health lesson applied to links: a wifi link that
associates and cannot route is a sensor that heartbeats and lies, and it is
what NM's +20000 penalty mislabels. A carrying cellular link therefore beats a
merely associated wifi one; wifi only wins ties.
TunnelHealth reads wg's received-byte counter, which nothing read before. Up
and deaf is a real state - measured over clat as 368 B received against 5.30
KiB sent - and every other readout on the device calls it connected.
Anti-flap is a settling window, not a rate limit: a changed preference must
hold BEARER_SETTLE (20s) before it produces a command, and the clock restarts
whenever the candidate itself changes. A steady state emits nothing, so it is
safe to evaluate every tick. Tests cover the flap that never settles, the
settled state that must not re-emit, and the dead network that must not be
flailed at.
NetworkManager stays the single writer of routes (acceptance #6): preference
is expressed as ipv4.route-metric plus device reapply, never ip route, and
reapply rather than up/down so a correction is not itself an NM event. The one
route sessiond owns is the WireGuard endpoint /32, which NM has no opinion
about.
The tunnel toggle stays the user's. tunnel_underlay() returns None when the
tunnel is off - this decides how traffic is carried, not whether the tunnel
exists.
Probed off the existing clock every 5th tick, with the lock released; no new
timer (acceptance #6) and no subprocess spawned under the state mutex.
Reports down/up edges to sessiond and decides nothing. Wire it in
hyprland.lua as a bind/bindr pair — reporting only the press leaves the
machine believing the button is held forever.
blueline-power-button decided policy in shell script — read its own panel
state, asked the shell to lock over qs ipc, polled, then blanked the panel
itself. That was a path to a dark panel outside request_blank(), so §1's
lock-then-blank invariant had a hole in the most-used control on the device.
Edges in, gestures out: tap, double, triple, hold, long-hold, per button.
Only the power tap is bound; the rest are recognised, recorded and inert
until there is a binding table to point them at.
PanelLoader is a LazyLoader gated on Config.ready, behind a second one.
On reload both are shut when quickshell propagates, so LazyLoader has no
item to hand its successor and the new WlSessionLock never adopts the live
compositor lock. Lock is a direct child of ShellRoot now, as the boot-order
doc already asked.
Ungated means Config may not have loaded, and touchKeypad reads false from
the adapter defaults — which would bind the desktop surface on the phone and
cannot be changed once the lock is active. The choice rides the reload too.
A held connection receives one trail entry per notable edge: transitions,
source-down/recovered, violated guarantees. Readings, wakes and heartbeats
never cross — the filter is the point, not the transport.
UnixStream::peer_cred is still unstable, so it only failed at the aarch64
build after rust-test had gone green. Use the same getsockopt machined
uses.
rust-test never built souveraine-sessiond at all — it is
required-features and the feature is not default. Build and test it.
sessiond admits shell_ready from the lease holder's own pid; the shell
carries its lock request through the reload so WlSessionLock adopts the
existing lock instead of taking the unlock branch on it.
It read "On" for an hour through a dead tunnel — NM said activated while wg had
368 B received against 5.3 KiB sent. Now off/connecting/limited/online, decided
by a 2s probe of the far-side resolver rather than by the interface being up.
Sailfish models the same distinction (Online/Limited/Connecting); Android won't
let an unvalidated network win at all.
GroupButton called it unguarded while releaseAction two lines up was guarded.
altAction is null for every toggle without a menu, so a long-press threw. Only
showed once the toggles that had been filtered out of the panel started
rendering.
TASK-08(f)/TASK-19: state, panel, evidence, confidence, per-source health, the
sensors_degraded flag and the recent decision trail were legible only through
forensic.jsonl. DeviceEvidence gains a read path (polled only while watched);
the Device page renders it. Readout only — the confidence gates are still
computed and never branched on, so controls over them would lie.
It is not a git repo, it gets edited on the device, and this path had no
snapshot while the local compose path has had one all along. Now it snapshots
to ~/souveraine-surfaces/.quickshell-previous, copies by checksum, and only
reports phone-only files instead of destroying them.
Tier 2 was written, deployed and mounted by nothing — its own header named the
gap. The chat sidebar hosts it now: ticker long-press, or the psychology
indicator by the input box, which works when no pass is running.
The hand-written allowlist in AndroidQuickPanel had drifted from it, so the
phone rendered 12 of its 17 configured toggles and said nothing about the rest.
Adds the missing fullScreenshot and waydroidTerminate, and a deploy guard so
the phone chooser can't fall behind the base one again.
selectAgent cleared conversationId even when the agent hadn't changed, and the
60s inventory poll re-selects the persisted agent forever — so any message sent
a minute after the last one opened a fresh conversation. 27 of them yesterday,
four messages each. An empty curl body no longer counts as "no conversations".
The old wiuf profile shared its address and private key with the laptop, so
WireGuard could only hold one of them, and the key is no longer listed
server-side — it sent and never received. wiufph is 10.8.0.6, port 51822,
keepalive 25. Proven off-LAN: handshake 8s, gitea 200 in 0.126s.
Deliberate, never automatic. The autonomous gate it replaces recycled the
tunnel 652 times in 90 minutes and could not be switched off, because
'nmcli connection down' fired the dispatcher that brought it back up.
Reads NM for state rather than tracking a bool. No privilege needed: polkit
already lets the seat user activate a system connection. Registered in the
ii-phone chooser as well as ii-base, or the phone overlay wins and the
delegate never appears.
The ticker instantiation in AiChat referenced qs.modules.souveraine.
subconscious, but those files were absent from the deploy MANIFEST, so a
deploy left the import unresolved. Add SubconsciousTicker/EventPanel +
a qmldir mirroring the dial/selection modules.
Revises the prior fix: the model's reply is already complete in
final_content before the paced stream loop, so truncating to
needlessly dropped the unstreamed tail. On SSE receiver-drop, stop
streaming but commit the FULL reply (interrupt flag skips the pointless
N+1 and marks the turn); the surface recovers the whole reply on resume.
turn.rs streaming loop returned early on tx.send failure (receiver gone)
before the session commit, orphaning the turn: the assistant's partial
reply never landed, so the next turn's history snapshot read the user's
follow-up with no preceding reply -> a user->user gap. The model then
answered as if from nowhere (the 'whatever it was...' non-sequitur),
model-independent and intermittent. Commit the partial like an interrupt
instead, the way the cancel branch already does, so the thread links.
Best-effort mirror step after the edge publish: PUT each built pkg to
/api/packages/Fimeg/arch/edge so the Packages page stays current. Never
fails the publish; 409 = bump to republish. Runs only on public (same
gate as the publish job).
The subconscious three-tier surface (SubconsciousTicker + SubconsciousEventPanel)
is Souveraine-original, not upstream ii. It lived under ii-base/modules/ii only
because it had never been re-homed, so the ii -> souveraine shell migration
silently stranded it: the files survived, but the running shell (qs -c souveraine)
never loaded them, and the feature dropped off the device.
Relocated into modules/souveraine/subconscious/, decoupled from the ii overlay
system it was riveted to:
- SubconsciousTicker: was clean of ii imports but opened the panel through the
ii OverlayContext singleton. Now emits requestOpenPanel(); a host connects it.
- SubconsciousEventPanel: was a StyledOverlayWidget (qs.modules.ii.overlay). Now
plain content a host instantiates; the pin/close/drag affordances were the
overlay host's job, not the content's.
The model (services/Ai.qml) was already substrate-neutral and never moved; only
the views were stranded. GAP NAMED: no souveraine overlay host mounts these yet,
so they do not reach the glass until one exists — the ii overlay host was
intentionally not ported. The dangling ii references (AiChat, OverlayContext,
OverlayWidgetDelegateChooser) are removed.
Speech resolves voice per active agent (agents[].voice_id from the list),
with the system voice as fallback — two agents on a host need not match.
Souveraine.qml carries voice_id into the agents map and adds a turn clock
(turnStartedAt/turnElapsedMs) + a 60s agent-inventory refresh. Ai.qml
declares pendingFilePath and imports qs.modules.common.functions so
StringUtils.ttsClean no longer aborts the stream-finished handler.
AgentSummary carries voice_id, sourced from agent.json (_souveraine) via
get() rather than the DB config_json column, which is write-once at create
and drifts on later edits. The list endpoint is the surface's only
unauthenticated view of an agent, so voice travels there; /v1/agents/:id
is token-gated.
The masked-fullscreen version was completely untappable on device. Rather than
keep guessing at the mask, the window is now sized to the card and moved by
margins, so every pixel of it is the chip. Also stops an invisible fullscreen
surface sitting over the app's own selection UI.
Two bugs from the first on-device run.
The card sized itself from a layout it also centered, which resolved to zero, so
mask claimed a zero-size input region: the chip painted but no tap landed. Card
now sizes from the layout's implicit size and the background is a plain Item
child, not layout-managed.
wl-paste --watch replays the existing selection on start, so every watcher
restart (every unlock) resurrected a stale selection that could never be cleared
because the primary buffer never changed again. The first emission after a start
now only sets the baseline, and a dismissed selection is remembered so a re-read
cannot revive it.
Overlay layer following DialHost, keyboardFocus None throughout, and an input
mask limited to the card so taps elsewhere reach the app underneath. Anchors to
the pointer hint above the touch point, clamped on screen.
Chip shows a character count, never a preview: the surface floats over the app
that owns the selection and the content may be a password, so it stays ambient
and discloses nothing. Read Aloud is live via Speech; agent and reference
actions render with the reason they cannot act yet.
Adds DeviceEvidence, reporting input to the state machine's existing
Request::Input with an intent label, so a new input surface is not another
isolated actor per DEVICE-STATE-MACHINE 1. Opt-in: nothing loads or watches
until Config.options.selection.enable.
Verified on blueline that Hyprland advertises primary-selection plus both
data-control managers, so wl-paste --primary --watch sees every selection with
no per-app hooks. Settles TASK-18's selection-detection question as
compositor-level; viewtop is not needed.
The protocol gives no selection rectangle, so anchor is the pointer position at
selection time. Opt-in by default: the watcher observes every highlight on the
device, including passwords, so it is killed while locked and selection text is
never persisted, logged, or trailed.
It authenticated through PAM, called unlock_and_destroy(), and returned
Unlocked — and then the shell, whose GlobalStates.screenLocked is a separate
bool, re-registered, was told must_lock=true, and locked again. The protocol
has a lock directive and no unlock directive, so the fallback could open the
compositor's lock and had no way to tell the session it had. Casey hit it
exactly that way: "it logs in, and then qs says Locked still."
It was also not a last resort in practice. It is raised whenever shell_alive is
false, and that flag lied for 90 minutes on 07-29 because a scene reload's
re-registration was refused and the shell gave up (45fbbea). So it was the
first thing reached, on a bad signal, to do a job it could not finish — the
fallback shape the doctrine forbids.
Gone: auth.rs, the PIN pad, the keypad geometry and digit font, the
failed-attempt counter, SessionOutcome::Unlocked, Msg::AuthDone, and the
--pam-service plumbing down to the CLI.
Kept: acquiring ext-session-lock before any shell exists, which was never a
fallback but the ordering invariant from LOCK-DPMS-LESSONS.md §1, and the
Released handoff. A locked session with no shell now stays locked and says so
in the journal. Recovery is the shell returning, or a reboot.
79 tests pass.
bootBloomActive defaults true on every scene construction and bootDismissed
resets with it, but the only thing that cleared them was the secure EDGE. A
scene reload during an already-secure lock never moves `secure`, so nothing
cleared the bloom and the phone sat under a full-screen white overlay until the
shell was restarted. Casey hit it and reported it as "the hyprland error
screen"; `grim` returned a solid white 9KB frame, and hyprctl configerrors was
empty the whole time.
Third instance of this exact shape: locked_ack after a sessiond restart, the
ChargeRate stale-scene reload, and now this. Check the level at construction as
well as the edge.
Deliberately not a timeout — a bloom outliving its reason is a bug to locate.
A scene reload re-runs SessiondBridge while the outgoing connection is still
open, so the reload's shell_ready races its own predecessor's EOF and sessiond
refuses it — correctly, since the lease is genuinely held. The bug was the
shell then giving up for good: it read the refusal as "another shell owns the
lease, we are not the authority", cleared nothing, and left registerRetry
idle. A second later that EOF landed, shell_alive dropped, and sessiond spent
the rest of the session believing there was no shell — while a live one sat on
a connected socket.
Observed 2026-07-29: refused 09:14:35, predecessor closed 09:16:36, and
`{"op":"status"}` reported shell_alive=false for 90 minutes afterwards.
Now the refusal sets needsRegistration and lets the 5s retry run. If the lease
really is another live shell's, each retry is refused again for free — and we
take it the moment that shell dies. Verified on hardware: retrying (1) ->
registered -> locked_ack sent, sessiond back to phase=released.
It existed only in archdev's checkout — never added, not gitignored, absent
from the laptop. Two sections had gone stale against the code and are corrected
in the same change: modules/ is no longer "coming", and deploy.sh composes the
whole config rather than swapping Ai.qml.
~/souveraine-surfaces/quickshell on the phone is not a git repo and was the
only copy of five files. Brought back verbatim:
- Gestures.qml, SystemGestureRail.qml: TASK-38 osk-swap detent (DUMP §5, §6)
- OnScreenKeyboard.qml: showOsk asks the bus instead of pgrep+sleep 1
- DockAppButton.qml, DockStack.qml: suffix-tolerant AppSearch.resolveEntry
resolveEntry itself had been added to the phone's live ~/.config/quickshell/ii
tree, which deploy.sh rsyncs from ii-base — the next deploy would have deleted
it and left the two dock callers referring to nothing. It lands in ii-base here.
29eef336 was the tip of dtolnay's stable branch when pinned. That branch
force-moves each Rust release, orphaning the commit — reachable from no
ref, so act resolves 'reference not found' and rust-test dies before
compiling. That skipped aarch64-artifact and stopped edge publishing
with nothing saying why. master is append-only.
Active Edge becomes InputTrigger::Squeeze. It resets the idle budget
and lands in the trail like any real input, but unlike a power button
it is a sensor reading and a tight pocket is a squeezed chassis, so it
carries the same proximity veto as tap-to-wake (DEVICE-STATE-MACHINE
4).
note_input_gated applies the veto and records the refusal via
record_decision — a silently dropped report is what 10 exists to
prevent. The input op now advertises refused_by_state.
Producers cooperate rather than being enforced; TASK-41 is the gate.