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.
Every failure path in shellReady answered cb(false) — no lock owed — so a
socket that was not up, a duplicate handshake, or a 3s timeout all concluded
the session was open. The timeout logged 'proceeding without sessiond' and left
the phone unlocked at exactly the moment the authority was unreachable, while
the daemon side of the same contract retakes the lock on heartbeat EOF
'whether or not the session was locked at the time. Fail closed.'
Not knowing the session is locked is not knowing it is not. All of those now
assume locked. Refusals still answer false only for 'already registered', which
means another shell holds the lease and this process is not the authority.
A timed-out handshake is also retried instead of abandoned: a shell that merely
started while sessiond was restarting stayed unregistered for its whole life,
so sessiond saw no heartbeat and raised its fallback surface over ours.
SessionEvents likewise retries session-path resolution rather than disabling
lock-signal monitoring for the life of the process, and logs the gap as an
error while it is open.
onScreenLockSecureChanged is an edge. After a sessiond restart our lock surface
is already secure, so the edge is in the past and the ack owed by the new
handoff was never sent — sessiond logged 'shell never confirmed its lock after
handoff' and retook the lock 15s later.
SessionEvents asked loginctl for -p ObjectPath, which is not a property — it
returned empty on every boot, so external lock signals were never monitored and
the shell had no unlock ingress. Resolve the graphical session the way
lockhint.rs does; not GetSession(auto) (this shell is a session-less user unit)
and not the /session/auto alias (PropertiesChanged only fires on the concrete
path).
SessiondBridge read root.registered to decide whether to re-register on
reconnect, but the disconnect branch had already cleared it, so the shell never
re-registered after a sessiond restart. Latch it.
Accel inferred moving from orientation edges, so Moving(false) was only
reachable from the startup banner — boot 0 carried 165 true to 8 false and the
keepalive re-sent a stuck true every 30s, pinning +0.3 into observed_confidence
for the life of the process. Motion now decays over MOTION_WINDOW and the
keepalive re-derives it.
Light crossed the lux threshold on one sample and fell back on the next,
pulsing confidence 0.50->0.70->0.50 in 1.2s and toggling promote_idle across
the 0.6 band. An excursion must now persist: 2000ms brightening, 4000ms
darkening, taken from config_autoBrightness*LightDebounce in blueline's vendor
RRO. Reversal restarts the clock.
ParserState is shared and survives monitor-sensor respawns.
sensord hung off graphical-session.target, which nothing on this device
starts, so it was enabled and dead from every boot. Down starts from a
last-seen stamp and structurally cannot see that. Bind the reporter to
sessiond and make the machine say so.
Two halves of the same defect. The lux threshold compared each sample to the
previous sample, so a slow drift never tripped it — it now compares to the
last value actually reported. And no source had any rate floor at all, which
is how light managed ~8 reports/sec; 250ms per source, enforced separately so
a busy light sensor cannot delay a proximity edge.
Light drifts continuously; monitor-sensor emitted ~8 lines/sec and each was
a socket round trip re-asserting the same Changing(false). The keepalive is
what keeps silence meaningful, so only changes need reporting.
Session.qml and Lock.qml both registered target 'session'; quickshell keeps
whichever loads first and drops the other. Lock.qml's handler is a strict
superset, so the duplicate goes.
blueline-proximity-lock is 113 lines of shell implementing a subtle contract
(heartbeat inside SOURCE_DOWN_AFTER, seed from the probe banner, report both
edges) that would have been hand-copied for light and again for accel. One
binary, one contract, shipped in the package instead of the rootfs overlay.
Action::Restore bound the brightness inside an if-let, so the MutexGuard
lived for the whole body and run_executor locked again. Non-reentrant: the
state lock was held forever and every later request thread queued behind it
(measured: 347 threads in futex_wait, 691/1024 fds, accept loop healthy, no
request answered in 3h). Bind first, then branch.
Also close connections idle for 120s, exempting the shell heartbeat.
The page asked the same question twice because two daemons answer it, and
split the blank budget by held-vs-resting — which makes the user arbitrate a
guess the accelerometer is making. Held-ness belongs in the confidence
arithmetic as an adjustment to one budget, not as a second budget here.
The fullscreen overlay sat above squeekboard's layer and owned the whole
screen's input region, so taps in the keyboard area hit the dialog. It now
yields the bottom third while the OSK is up. Exclusive keyboard focus was
tried and reverted — it stopped the keyboard taking touch entirely.
UPower's ChargeType (fork, read off the charger) reaches the lock line as
fast/slow charging. Discharging with the cable in is the charger resting
between top-ups, not a drain — it now reads Charged/Plugged in.
squeekboard hides itself when input-method focus drops, so the password
field was left with no keyboard. A hold re-asserts through that and
releases on dismiss; a manual close drops the hold.
Measured: the hard reboot left the trail at exactly 4096 bytes with the last
entry cut in half. Rotating that aside cost chain continuity at the one moment
it is worth most.
SetPolicy only mutated memory. Every lock-screen timer set in Settings
reverted to the built-in 15s on the next restart, silently — the page was
honest about reading the daemon, the daemon was forgetting.
Saved to $XDG_CONFIG_HOME/souveraine/device-state-policy.json, write-then-
rename. serde(default) on the container so an older file loads with new
fields defaulted instead of dropping the user's settings on upgrade.
A failed save returns ok:false rather than a success-shaped switch.
A source that reported and then went silent past 90s is DOWN, not quiet,
and says so as an error-security entry. Sources that never reported stay
unknown — a light sensor with no reporter is correctly silent forever.
Every forensic snapshot now carries sensors_degraded, so a decision taken
during an outage doesn't read like a healthy one. This is the 2026-07-25
SLPI case: hexagonrpcd-sdsp exited 0, every sensor dead for hours, nothing
in any log.
Health is not read from SensorProxy Has* — measured lying both ways.
Also: evidence_fresh meant "ever seen" and read true through the outage;
repeated readings no longer append to the trail, since reporters heartbeat.
55 sessiond tests.
set_session_locked(false) discarded transition()'s return. There is no
Suspending/Asleep -> Active edge, so an unlock reported while asleep left the
state put but voided pending_blank, blank_requested and dimmed anyway. Now
refused, loudly, with the state intact.
update_sensors hardcoded SensorSource::Proximity in the forensic entry, so
accel/light/touch readings were written to the trail labelled as proximity.
Takes the source now; update_sensors() stays as a proximity wrapper.
47 tests, 1 new.
IdleConfig: ConfigSelectionArray presets (30s/1/2/5/10 min for the shell's
timers, 15s/30s/1/5 min/Never for the lock screen) instead of seconds
spinboxes. Nobody reasons about a lock screen in 5s increments; the policy
struct already said "iOS Auto-Lock shape". Wire values stay seconds.
Header comment corrected — hypridle no longer owns screen-off.
set_panel(false) clears pending_blank. A panel darkened by any other route
left it armed, so the next tick emitted a second Blank or hit the
blank-without-lock error path for a blank nobody was waiting on.
SetPolicy refuses dim_grace >= lock_blank_after. dim_at saturates to zero, so
the panel dimmed the instant it went idle with no lit period.
Verified by rendering the page, not by parse alone.
Measured on device 40min after boot: device_state=locked, panel_on=false, while
LockedHint=no. Screen dark, session unlocked.
locked_ack was the only lock ingress and there was no unlock one outside the
fallback PIN surface, so after the first unlock the machine believed it was
locked forever. Every is_locked() rule then fired against an in-use phone —
proximity blanked it, and blanks never asked for a lock because one was
"already held". Doctrine §4: never hold state the protocol owns.
New lockhint.rs watches LockedHint via gdbus monitor (§8's pattern, not
polling). Resolve via User.Display, NOT GetSession(auto) — auto returns the
CALLER's session and sessiond is a session-less user unit; over ssh it answered
_310 while the graphical session was _31.
set_session_locked() moves the base tier and voids lock-screen intent on
unlock: pending blank cleared, dim restored.
46 tests, 8 new.
SessiondBridge: handle pushed {"directive":"lock"} on the heartbeat line and
raise the lock surface. Unknown directives are console.error — a silent drop
leaves the daemon blanking unlocked when its budget expires.
SessiondPolicy: new singleton, reads/writes DeviceStatePolicy over its own
short-lived connection (not the bridge socket — that EOF is shell-death
detection).
IdleConfig: new "Lock screen (device authority)" section bound to it. SetPolicy
had zero callers, so the old spinboxes moved a JSON file the daemon never read.
Says so in the error colour when sessiond is unreachable.
New file needs a deploy.sh manifest line or the services qmldir fails whole.