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.
LOCK-DPMS-LESSONS §1 is "Ordering: lock, then off". It held only because
hypridle's 300s lock listener fired before its own 600s screen-off listener —
two numbers in a config file, not a guarantee. Anything that skipped the lock
(idle inhibitor, native coordinator off, dead shell) still met the blank, and
the panel went dark on an unlocked session with nothing said about it.
Every path to a dark panel now routes through request_blank():
- locked -> Blank, unchanged, no added round trip
- unlocked -> Action::Lock, blank withheld, pending_blank deadline armed
- ack lands -> Blank
- budget expires -> Blank anyway + error-security in the forensic trail
The panel fails open because a lit unlocked phone in a pocket is worse than a
dark one (§1); the *claim* fails closed because doctrine §8 forbids pretending
the session locked. Input inside the ack window cancels the blank outright.
Action::Lock does not go through the executor table — the lock is not a shell
command. While a live shell owns steady state the directive goes down the
heartbeat connection and the shell raises its rich surface; with no shell alive
sessiond raises its own. Same relationship DEVICE-STATE-MACHINE §6 gives the
DPMS executor: the authority decides, the surface executes. A directive that
cannot be delivered takes the lock here rather than timing out into a dark
unlocked screen, and is recorded either way.
Policy gains lock_ack_budget (2s, refuses 0) and unlocked_blank_after (None by
default — the shell's IdleCoordinator owns the unlocked idle timer through
ext-idle-notify per doctrine §5, and a second one here would recreate the
competing-owner disease). Both are on the GetPolicy/SetPolicy seam so Settings
is a view over the owning daemon, per TASK-19.
Unknown directives are loud on the shell side: an older shell silently
dropping one would leave the daemon waiting out its budget and blanking
unlocked.
38 tests pass, 5 new covering the ordering.
The state machine landed in b4b30b1 and never ran. Verified on the phone
after 2.5h of use: device_state=active, observed_confidence=0.0,
forensic_log count=0, forensic.jsonl never created. Not one transition.
Root cause was not a missing caller. The daemon had no timebase at all —
every Duration in it was a one-shot handshake deadline — so the machine
could answer requests but nothing ever told it time had passed. It could
not be the proactive half of anything.
- tick loop (1s) drives every timed rule; tick_at() takes the clock so the
rules are testable without sleeping
- panel is a field, not a ninth state: the enum had no cell for "locked,
screen dark", and DozeLight (frozen apps, Wi-Fi save) is not a dark glance
- lock-blank rule, two stages: dim as a visible pre-warning, blank one grace
window later. Nothing owned this before — the only backstop was hypridle's
600s screen-off timer shared with the desktop case, so glancing at the
clock lit the panel for ten minutes
- dim/restore via brightnessctl + blueline-undim. The dim listener was
dropped from hypridle.conf by accident and never restored; it now lives
here, so brightness has one writer and the save happens exactly once —
which is the actual fix for the 10/255 wedge the old -s/-r pair hit
- input truth from ext-idle-notify (new idle.rs), not inferred. Tracking the
compositor's active/quiet boolean instead of stamping last-input is what
keeps a long swipe from being blanked mid-gesture: continuous input emits
no events at all
- evidence staleness, 30s TTL per source, clearing toward safety: a stale
proximity stops suppressing wake, a stale accelerometer stops buying the
longer budget. Measured need — hexagonrpcd-sdsp exited 0, systemd left it
dead, and iio-sensor-proxy went on reporting HasProximity=true for hours
- proximity-near while locked blanks now, taking that decision back from
blueline-proximity-lock
- get_policy/set_policy IPC, Auto-Lock shaped with 0 = never, so Settings can
be a view over the owning daemon instead of a switch that looks like it worked
- fix: PAM unlock was refused as an illegal transition from Observed,
DozeLight and DozeDeep. Unlocking a phone whose proximity had fired hit it
33 tests. aarch64 cross-build verified on archdev.