Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/docs/DEVICE-STATE-MACHINE.md
2026-08-10 17:00:23 -04:00

969 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Device state machine — unified design
**Status:** actuating on hardware since 2026-07-25. **Created:** 2026-07-24.
`b4b30b1` landed the shape: the §2 enum (all 8 variants), a 20-pair
`LEGAL_TRANSITIONS` guard, §4's confidence arithmetic with exactly the weights
below, and the forensic log. It was **entirely inert** for a day — no caller
for `transition()` outside `update_sensors`, no sensor feeder, no clock, so
the daemon never left `Active` and the forensic log stayed at `count: 0`.
`aa30278` gave it the missing half and it now drives the device:
- a 1 s tick loop (`tick_at()` takes the clock, so rules test without sleeping)
- real actuators — `Dim`/`Restore`/`Blank` through brightnessctl,
`blueline-undim`, and the DPMS executor
- input truth from `ext-idle-notify` (`idle.rs`), not inferred, which is what
keeps a long swipe from being blanked mid-gesture
- `panel` as a *field*, not a ninth state: the enum had no cell for "locked,
screen dark", and DozeLight is not a dark glance
Verified on the phone: `panel-dim` then `panel-off` in `forensic.jsonl`,
including an `error-operational` entry when `brightnessctl` exited 1 — the
§5 contract working end to end.
**Ordering is now an invariant, not a coincidence** (2026-07-25, second pass).
Every path to a dark panel routes through one function that locks first, waits
`LOCK_ACK_BUDGET` (2 s) for the compositor ack, and blanks anyway on timeout
while recording an `error-security` — the panel fails open, the *claim* never
does (LOCK-DPMS-LESSONS §1 + doctrine §8). hypridle's 300 s lock and 600 s
screen-off listeners are **deleted**, not disabled: they were the competing
writer that made §1 hold only because 300 < 600.
Still open: doze tiers (TASK-15, blocked on slice separation), and both
confidence gates are still computed and logged without being branched on.
Source health landed 2026-07-26 (§10) — the machine now knows when a sensor is
absent rather than merely quiet.
## 0. Audit, 2026-07-26 — what the running machine actually did
Measured on hardware, 40 minutes after a cold boot. Kept here rather than in a
new task file: this section IS the audit.
**FIXED — the machine kept a shadow copy of lock state, and it drifted.**
`device_state: locked, locked: true, panel_on: false` while
`loginctl show-session 1 -p LockedHint` said `no`. The screen was dark on an
unlocked session. `locked_ack` was the only lock ingress and there was no
unlock ingress outside sessiond's own fallback PIN surface, so after the first
unlock the machine believed it was locked forever. Every `is_locked()` rule
then ran against an in-use phone: proximity blanked it (that rule's
`is_locked` guard was correct — the *state* was lying), and blanks never asked
for a lock first because one was "already held". This is doctrine §4 verbatim:
"never a hand-tracked bool. A shadow copy can disagree with logind." Fixed by
`lockhint.rs` — logind is read, not mirrored.
**FIXED 2026-07-26 — proximity is debounced, and not the way §9.5 said.** 69
transitions in 40 minutes, `Locked → Observed → Locked` on a sensor with no
hysteresis. The fix was specified here on 07-24 as Android's 0 ms positive /
250 ms negative and was never built. When it came time to build it, the trail
said the specification was wrong — see §9.5.
**FIXED 2026-07-26 — the trail was on tmpfs, unbounded, and chained to
nothing.** `$XDG_RUNTIME_DIR/souveraine/forensic.jsonl` was RAM on a 3.5 GB
phone, erased every reboot, growing without limit — 104 KB in this audit's
first 40 minutes. §5 called it tamper-evident; it carried a sequence number,
which catches a deleted line and not an edited one. Both decisions the audit
owed are taken, and the third it implied. See §11.
One divergence left: the `Locked → Observed` transition keys on **raw
`proximity_near`**, not on the §4 threshold table.
The other is settled. §4 called near+moving hand-held and refused to suppress
the wake; the code computed 0.5 ≥ 0.3 and suppressed. Neither was right —
motion cannot tell a pocket from an ear from a hand. The session can, and now
does. See §4.
**FIXED 2026-07-26 — evidence-source health is device state.** On 2026-07-25
SLPI took a CHRE fatal, remoteproc recovered it, `blueline-hexagonrpcd-sdsp`
exited "successfully" and was never restarted — every sensor was dead for hours
and nothing noticed, because a consumer of a dead sensor looks identical to one
whose sensor is quiet. See `PAF/slpi.md`. "No evidence" and "evidence
says nothing is happening" must not be the same state. They no longer are —
see §10.
**Supersedes:** the scattered state machines in IdleCoordinator.qml (7 states),
sessiond server.rs Phase (4 states), blueline-screen-toggle (2 states), and
blueline-proximity-lock (2 states). Those are projections of this model, not
separate systems.
---
## 1. Why one machine, not seven
The phone is in one state at any moment. It is either in your hand, in your
pocket, on a table, or asleep. Each of the seven actors we built sees one facet
and acts on it blindly:
- IdleCoordinator sees idle timers → locks at 300s
- sessiond sees heartbeat → retakes lock on EOF
- blueline-screen-toggle sees DPMS commands → serializes panel power
- blueline-proximity-lock sees proximity sensor → blanks locked screen
- SessionEvents sees PrepareForSleep → lock-then-release inhibitor
- hypridle sees idle timeout → dispatches lock/DPMS
- GlobalStates is a flat bag of booleans with no transition logic
None of them can see the others. Three of them can turn the screen off. Two of
them can request a lock. None of them knows whether the phone is in a pocket or
on a table — they just know "the user stopped touching it."
The doctrine already says this (SESSION-AUTHORITY-DOCTRINE §9): "Sensor readings
are evidence, not fact." The tier vocabulary needs a row the current matrix has
no cell for — `observed`. This doc defines that row.
---
## 2. The unified state
One enum. The device is in exactly one state at any time.
```
enum DeviceState {
Active, // Screen on, user present, unlocked or lockable
Dimmed, // Screen dim, user idle, not yet locked
Locked, // Screen locked, compositor secure, user may/may not be present
Observed, // Sensor says user present (proximity, accel, light) —
// EVIDENCE, not FACT. The shell acts on it but never
// trusts it for lock/unlock decisions.
DozeLight, // Locked + idle N min. App tier frozen. Wi-Fi power-save.
DozeDeep, // Locked + idle M min. Network fetchers stopped. RTC wake only.
Suspending, // PrepareForSleep(true). Inhibitor held. Waiting for lock secure.
Asleep, // s2idle. RTC + modem IRQs only.
}
```
### What each state means
**Active** — the user is interacting with the device. Screen is on. The session
may be unlocked (personal tier) or locked (the user is looking at the lock
surface). `displayActive = true`. All pollers run at full rate.
**Dimmed** — the user stopped touching the device but hasn't been idle long
enough to lock. Screen brightness lowered. The native IdleMonitor fired at
`dimAfterSeconds`. `displayActive = false` — pollers that produce stale data
should back off.
**Locked** — the compositor has acknowledged the lock surface (`screenLockSecure`).
The session is secure. The user may or may not be physically present — the lock
persists across pocketing, table-placing, and suspend/resume. Only PAM auth
unlocks.
**Observed** — one or more sensors report user presence, but this is EVIDENCE,
not FACT. The device is locked (Observed is a sub-state of Locked), and the
sensors are saying "someone is here." This state gates:
- DPMS wake (proximity-near suppresses wake to prevent pocket-dial)
- Touch input suppression (face-down on table shouldn't accept accidental touches)
- Idle tier promotion (proximity-near + locked = promote to Doze faster)
Observed NEVER gates:
- Lock/unlock decisions (only PAM does that)
- Security tier transitions (only sessiond does that)
- Personal data disclosure (only screenLockSecure does that)
**DozeLight** — locked and idle for N minutes. The app tier is frozen
(`systemctl --user freeze app-*.scope`). Wi-Fi drops to power-save. UPower
polling coasts. The lockscreen glance data (media, agent, notify cards) still
updates on wake — cards read on unlock, they don't need background freshness.
**DozeDeep** — locked and idle for M minutes. Network fetchers stopped. Only
RTC wakeups and modem IRQs can bring the device out. The coordinator wakes
every K minutes to check for notifications, then re-suspends.
**Suspending** — logind's `PrepareForSleep(true)` arrived. The delay inhibitor
is held. The lock is being requested (if not already secure). This state is
transient — the device moves to Asleep once the inhibitor is released.
**Asleep** — s2idle. The panel is off. The touch controller is in gesture mode
(dt2w). Only hardware wake sources work: power button, RTC alarm, modem IRQ.
---
## 3. Transitions
Every transition is guarded. Illegal transitions are refused and logged.
```
┌─────────┐ idle timeout ┌────────┐
│ Active │ ───────────────→ │ Dimmed │
└────┬─────┘ └───┬────┘
│ │
│ idle lock timer │ idle lock timer
│ external lock signal │ external lock signal
│ │
▼ ▼
┌─────────────────────────────────────┐
│ Locked │
│ (compositor secure, PAM required │
│ to leave) │
└────┬──────────┬──────────┬──────────┘
│ │ │
│ proximity │ idle N │ PrepareForSleep(true)
│ near │ min │
▼ ▼ │
┌──────────┐ ┌───────────┐ │
│ Observed │ │ DozeLight │ │
│ (sensor │ │ (frozen, │ │
│ evidence)│ │ Wi-Fi │ │
└────┬─────┘ │ save) │ │
│ └─────┬─────┘ │
│ │ │
│ proximity │ idle M │
│ far │ min │
│ ▼ │
│ ┌───────────┐ │
│ │ DozeDeep │ │
│ │ (network │ │
│ │ off, RTC │ │
│ │ wake) │ │
│ └─────┬─────┘ │
│ │ │
└─────────────┘ │
(prox far → back │
to Locked) ▼
┌───────────┐
│Suspending │
│(inhibitor │
│ held) │
└─────┬─────┘
│ lock secure
│ inhibitor released
┌───────────┐
│ Asleep │
│(s2idle) │
└─────┬─────┘
│ PrepareForSleep(false)
│ power button / dt2w / RTC
┌───────────┐
│ Locked │
│(wake, lock│
│ persists) │
└───────────┘
```
### Transition table
| From | To | Trigger | Guard | Action |
|---|---|---|---|---|
| Active | Dimmed | native IdleMonitor (dimAfterSeconds) | no inhibitor, no lock requested | lower brightness |
| Active | Locked | native IdleMonitor (lockAfterSeconds) | no inhibitor | request lock |
| Active | Locked | external lock signal (logind Lock) | — | request lock |
| Dimmed | Active | user input (touch, key) | not locked | restore brightness |
| Dimmed | Locked | native IdleMonitor (lockAfterSeconds) | no inhibitor | request lock |
| Locked | Active | PAM auth success | stepUp tier | unlock session |
| Locked | Observed | proximity-near detected | screen locked | suppress DPMS wake |
| Locked | DozeLight | idle N min while locked | screen locked | freeze app tier, Wi-Fi save |
| Observed | Locked | proximity-far detected | screen locked | allow DPMS wake |
| Observed | DozeLight | idle N min while locked | screen locked | freeze app tier |
| DozeLight | Locked | user input (dt2w, power button) | — | thaw app tier |
| DozeLight | DozeDeep | idle M min while locked | screen locked | stop network fetchers |
| DozeDeep | Locked | RTC wakeup, modem IRQ, user input | — | resume network |
| Any pre-sleep | Suspending | PrepareForSleep(true) | — | request lock, hold inhibitor |
| Suspending | Asleep | lock secure + inhibitor released | screenLockSecure | logind proceeds |
| Asleep | Locked | PrepareForSleep(false) | — | reacquire inhibitor |
### The Observed transition in detail
Observed is not a destination — it's a modifier on Locked. When the device is
Locked and proximity goes near, the device enters "Locked + Observed" — the
lock persists, but DPMS wake is suppressed and the idle tier promotion path
activates.
When proximity goes far, the device returns to "Locked" — DPMS wake is
re-enabled, and the idle timer resumes from where it left off.
This means Observed is really a boolean flag on the Locked state, not a
separate state in the enum. The enum above lists it separately for clarity,
but the implementation should model it as:
```
struct DeviceState {
state: DeviceStateEnum, // Active, Dimmed, Locked, DozeLight, DozeDeep, Suspending, Asleep
observed: bool, // sensor evidence of user presence
observed_confidence: f32, // 0.01.0, cross-sensor verified
observed_sources: Vec<SensorSource>, // which sensors contributed
}
```
---
## 4. Sensor evidence model
The doctrine §9 says: "Sensor readings are evidence, not fact." This section
defines how sensor inputs feed the state machine without becoming authorities.
### Sensor sources
| Sensor | What it reports | Confidence | Can it lie? |
|---|---|---|---|
| Proximity (iio-sensor-proxy) | near/far | binary, high | yes — a finger over the sensor |
| Accelerometer (iio-sensor-proxy) | device orientation + motion | continuous, medium | yes — vibration, vehicle |
| Light sensor (iio-sensor-proxy) | ambient light level | continuous, medium | yes — flashlight, shadow |
| Touch (FTS controller) | user input | binary, high | yes — pocket touch |
| Power button | user intent | binary, absolute | no — hardware signal |
| dt2w (FTS gesture mode) | user wake intent | binary, high | yes — false positive |
### Evidence aggregation
A single sensor saying "user present" is weak evidence. Two sensors agreeing
is stronger. The `observed_confidence` field aggregates:
```
confidence = 0.0
if proximity.near: confidence += 0.4
if accel.moving: confidence += 0.3
if light.changing: confidence += 0.2
if touch.active: confidence += 0.1
// Cap at 1.0
```
The thresholds for action:
| Confidence | Action allowed |
|---|---|
| < 0.3 | No sensor-driven action. Device stays in current state. |
| 0.30.6 | Suppress DPMS wake (prevent pocket-dial). Do not promote idle tier. |
| 0.60.8 | Suppress DPMS wake. Promote to DozeLight faster (halve the idle timer). |
| > 0.8 | Suppress DPMS wake. Promote to DozeLight/DozeDeep at normal rate. |
### Cross-sensor verification
If sensors disagree, the system logs the disagreement and uses the lower
confidence:
- Accelerometer says face-down, light sensor says bright → one is lying.
Log: `[sensor] cross-sensor disagreement: accel=face-down light=bright`.
Confidence drops to 0.3 (no action).
- Proximity says near, accelerometer says moving → ambiguous, and it stays
that way. A pocket, an ear and a hand all read like this; motion does not
separate them. Confidence drops 0.2 and no rule branches on the pair.
### Proximity is evidence, not an actuator (settled 2026-07-26)
This section used to read near+moving as "hand-held, so let the wake through"
while the code read it as 0.5 confidence and suppressed. Both used the wrong
instrument, and the argument they were having was too small. Casey's call:
proximity should barely control anything.
A covered sensor is a pocket, a face, a table or a thumb, and nothing in the
machine can tell which. So:
- **It does not blank the panel.** The rule that blanked any locked screen on
proximity-near is gone — that was `blueline-proximity-lock`'s old job moved
inward and kept too powerful. The idle budget blanks a locked screen soon
enough anyway.
- **It vetoes tap-to-wake, and nothing else.** A double tap is the one wake a
pocket can produce by itself, so a covered sensor is the right veto for it.
A power button is intent (§4: "no — hardware signal") and is never refused.
`DeviceStateMachine::suppress_wake(trigger)` is that rule.
- **It stays evidence.** It drives `Observed`, it is on every snapshot, and it
is context the agent can read (doctrine §13).
**Owed: the call.** A call is the one case where proximity *should* turn a
panel off, and it should do so whether or not the session is locked — an
unlocked in-call phone still wakes against a cheek. That needs a call-state
input (ModemManager / callaudiod) as another source: a factor, never an
authority.
### What sensors NEVER do
- Unlock the session (only PAM does that)
- Change screenLocked or screenLockSecure (only sessiond does that)
- Serve as a factor in authentication
- Override a manual lock (user pressed power button to lock)
---
## 5. Error and audit contract
### One trail, not two
Today, SessionAudit tracks state transitions (lock, unlock, sleep, wake) and
QML console.log tracks everything else. They never meet. This means:
- A brightnessctl failure is invisible to the audit trail
- A PAM timeout is logged to console but not to the tamper-evident log
- A D-Bus parse error in SessionEvents is a console.log line that vanishes
The unified model says: **every event that affects device state goes to one
trail.** State transitions, sensor inputs that triggered transitions, errors
that prevented transitions, and errors that degraded guarantees.
### Event categories
| Category | Example | Severity | Trail? |
|---|---|---|---|
| State transition | Active → Dimmed | info | yes |
| Sensor input | proximity-near, confidence=0.7 | debug | yes (if triggered transition) |
| Error — operational | brightnessctl restore failed | warn | yes |
| Error — security | PAM timeout, lock not secure before suspend | error | yes |
| Error — system | D-Bus parse error, gdbus monitor exited | error | yes |
| Error — degraded | sleep inhibitor exited unexpectedly | warn | yes |
| Audit — hash chain | chain resumed, chain reset | info | yes |
### Error severity definitions
**debug** — information useful for development. Not shown to user. Logged to
trail only if it triggered a state transition.
**info** — normal operation. State transitions, successful auth, successful
inhibitor acquire/release. Always logged to trail.
**warn** — degraded operation. The system is working but a guarantee is weaker
than intended. Examples: brightnessctl restore failed (screen stays dim), sleep
inhibitor exited unexpectedly (suspend will proceed without delay). Logged to
trail AND to console.warn.
**error** — broken operation. A guarantee is violated. Examples: PAM timeout
(lock not secure before suspend deadline), D-Bus monitor exited (PrepareForSleep
signals will not be received). Logged to trail AND to console.error AND surfaced
to the user if security-relevant.
### The trail format
The existing SessionAudit hash-chained JSONL format is correct. Extend it with
new event types:
```jsonl
{"seq":42,"prev":"sha256...","ts":1234567890,"event":"state-transition","data":{"from":"active","to":"dimmed","trigger":"idle-monitor","confidence":null}}
{"seq":43,"prev":"sha256...","ts":1234567891,"event":"sensor-input","data":{"source":"proximity","value":"near","confidence":0.7,"triggered":true}}
{"seq":44,"prev":"sha256...","ts":1234567892,"event":"error-operational","data":{"component":"idle-coordinator","action":"brightness-restore","error":"exit code 1"}}
{"seq":45,"prev":"sha256...","ts":1234567893,"event":"error-security","data":{"component":"session-events","action":"lock-for-suspend","error":"WlSessionLock.secure not received before deadline"}}
```
---
## 6. Mapping to existing code
### What stays
| Component | Role in unified model |
|---|---|
| IdleCoordinator.qml | Projection: owns the QML-side state enum, publishes displayActive, drives brightness. Becomes a consumer of the unified state, not the source. |
| sessiond server.rs Phase | Projection: owns the lock handoff. Phase maps to DeviceState.Locked (Holding/Released are sub-states of Locked). |
| blueline-screen-toggle | Executor: serializes DPMS transitions. Receives commands from the unified state machine, not from independent actors. |
| blueline-proximity-lock | Sensor input: feeds proximity evidence into the unified state machine. Stops making DPMS decisions directly. |
| SessionEvents.qml | Ingress: feeds PrepareForSleep and external lock signals into the unified state machine. |
| SessionAudit.qml | Trail: receives all events from the unified state machine. |
| GlobalStates.qml | Projection: flat booleans consumed by QML surfaces. Driven by the unified state machine. |
### What changes
1. **The unified state machine is the authority.** It lives in Rust (sessiond or
a sibling daemon) because it needs to be alive before the shell starts and
survive shell crashes. QML projects it; Rust owns it.
2. **Sensors become inputs, not actors.** blueline-proximity-lock stops calling
blueline-screen-toggle directly. It reports proximity-near to the unified
state machine, which decides whether to suppress DPMS wake based on
confidence and current state.
3. **DPMS becomes an executor, not an authority.** blueline-screen-toggle keeps
its flock mutex and state file, but it receives commands from the unified
state machine instead of from three independent callers.
4. **The audit trail is unified.** SessionAudit receives all events —
transitions, sensor inputs, errors. The hash chain covers everything.
5. **Error handling is standardized.** QML uses console.error for errors,
console.warn for warnings, console.log for info. Each log line has a
canonical tag. Errors that affect device state go to the audit trail.
---
## 7. Comparison targets
This design should be compared to:
- **AOSP PowerManagerService** — the Android power state machine
(Interactive → Dream → Asleep). How does it handle sensor fusion? Does it
have an "observed" state? How does it handle drop detection?
- **LineageOS doze** — any modifications to AOSP's doze implementation
- **SailfishOS mce** — the mode control entity (libwakelock.c + datapipe.c
pub/sub). Cited in TASK-08 as prior art.
- **FuriLabs batman** — dual-source idle: logind IdleHint + wlr-output-management
- **DankMaterialShell** — independent idle monitors for dim/DPMS/lock/suspend,
AC-vs-battery thresholds
Research complete — findings in §9 below.
---
## 8. Open questions
1. **Where does the unified state machine live?**
**Answer: sessiond.** Doctrine §11 names it as the natural home. It already
owns lock state, survives shell crashes, and the boot-order doc (Phase C)
says "sessiond is then the natural home for the rest of the authority
surface." TASK-15 agrees: "likely a sessiond sibling or sessiond itself."
2. **How does the shell read the unified state?**
**Answer: extend `session.state()` IPC.** The current response
`{locked, shell_alive, phase}` expands to include the device state enum,
observed confidence, and doze tier. GlobalStates stays as a QML projection
for backward compatibility.
3. **What is the freeze granularity?**
**Answer: freeze `apps.slice`, allowlist `apps-wake.slice`.** TASK-15:
"A slice freeze is one command and no bookkeeping; per-app lets Chatty stay
live for SMS-adjacent work. Likely: freeze `apps.slice`, keep an allowlist
slice for the few that may run locked."
4. **How does drop detection fit?**
**Dropped.** No existing OS does it. Novel but not needed for v1.
5. **What is the RTC wakeup schedule?**
**Answer: M is a config value.** TASK-15: "wake every M min → thaw the
network fetchers for a bounded window." The coordinator needs a "fetch
complete" signal, not a fixed sleep. The wake window is bounded by the
fetch, not by a timer. Charging can skip tiers 23 (desk-clock case).
---
## 9. Prior art — what others do (research findings, 2026-07-24)
### AOSP PowerManagerService
Android's top-level power state machine has four states:
```
WAKEFULNESS_ASLEEP (0) ↔ WAKEFULNESS_AWAKE (1) ↔ WAKEFULNESS_DREAMING (2) ↔ WAKEFULNESS_DOZING (3)
```
Managed per `PowerGroup` (one per display group). Transitions are dirty-flag
driven — `updatePowerStateLocked()` is called whenever wakefulness, user
activity, display power, wake locks, proximity, battery, or settings change.
Guards are eventTime ordering (`wakeUpLocked` requires `eventTime >= lastSleepTime`).
The **Sandman** (dream manager) asynchronously starts/stops dreams and doze.
`canDreamLocked()` checks battery, charging, dream settings. `canDozeLocked()`
checks doze enabled, screen-off timeout, ambient suppression.
### DozeMachine (SystemUI)
A separate 12-state machine inside the DOZING umbrella, managing the ambient
display (AOD) experience:
```
UNINITIALIZED → INITIALIZED → {DOZE, DOZE_AOD, DOZE_AOD_DOCKED}
DOZE ↔ DOZE_AOD ↔ DOZE_AOD_PAUSING ↔ DOZE_AOD_PAUSED
{DOZE, DOZE_AOD} → DOZE_REQUEST_PULSE → DOZE_PULSING → DOZE_PULSE_DONE → {DOZE, DOZE_AOD}
```
Proximity is the key guard. Before any pulse, `proximityCheckThenCall()` checks
if the device is in-pocket — if proximity reports NEAR, the pulse is dropped.
In DOZE_AOD, proximity NEAR transitions to PAUSING then PAUSED (AOD off).
Proximity FAR transitions back to DOZE_AOD.
### DozeTriggers — sensor bridge
Registers 9 sensor types for doze interactions:
| Sensor | Trigger | Gated by |
|---|---|---|
| Significant Motion | pulse on movement | proximity check |
| Pick-up Gesture | gentle wake on pickup | proximity check |
| Double Tap | pulse on double-tap | proximity check |
| Tap | pulse on single-tap | proximity check |
| Long Press | pulse on long-press | proximity check |
| UDFPS Long Press | fingerprint long-press | proximity check |
| Wake Display | presence detection to wake AOD | proximity check |
| Wake Lock Screen | reach detection to wake lock | proximity check |
| Quick Pickup | fast pickup for UDFPS devices | proximity check |
All gated by proximity. Sensors are selectively registered based on doze state.
### AttentionDetector — camera-based "Smart Stay"
Uses the front-facing camera to detect if the user is looking at the screen.
Extends the screen timeout (starts 2 seconds before dim). Has a maximum
extension limit to prevent security issues (device never locking). Tracks
consecutive timeout extensions.
**This is the closest thing to our "observed" state** — but it only extends
screen timeout, never affects lock/unlock. No confidence scoring, no
cross-sensor verification, no fusion with proximity or accelerometer.
### DisplayPowerProximityStateController
Low-level proximity-display controller:
- States: PROXIMITY_UNKNOWN (-1), PROXIMITY_NEGATIVE (0), PROXIMITY_POSITIVE (1)
- Positive debounce: 0ms (immediate)
- Negative debounce: 250ms
- Threshold: 5.0cm
- When positive, display can be blanked to prevent accidental touches
### Drop detection — does NOT exist in AOSP or LineageOS
**No stock Android or LineageOS has drop detection as a state machine input.**
Vendor-specific implementations found:
| Vendor | Implementation | Purpose |
|---|---|---|
| Vivo (OPPO/OnePlus parent) | `TYPE_DROP_DET` sensor (type ID 66548) in `PopupCameraManagerService` | Retract popup camera on drop. Hardware protection only. Not a power state input. |
| HarmonyOS (Huawei) | `SENSOR_TYPE_DROP_DETECTION = 7` in `CategoryMotion` | First-class sensor type in their API. Purpose: hardware protection. |
| Apple Watch | `CMFallDetectionEventUserResolution` in CoreMotion | Fall detection for health/safety. Not phone power management. |
Open-source research implementation (`sense-android-library` FallDetector):
- Phase 1 (free fall): acceleration < 0.6g for > 60ms
- Phase 2 (impact): acceleration > 2.0g within 200ms after free fall
- Phase 3 (inactivity): acceleration < 1.3g for > 2000ms within 3500ms after impact
This is a health app algorithm, not an OS integration. No mobile OS treats
drop detection as a power state machine input.
### What this means for our design
1. **Our "observed" state is novel.** No existing OS combines proximity +
accelerometer + light into a confidence score for device state decisions.
Android uses proximity as a binary gate (suppress/allow), never as evidence.
2. **Our sensor fusion model is novel.** Android's DozeTriggers gates every
sensor through proximity, but never cross-validates sensors against each
other. Our "accelerometer says face-down, light says bright — one is lying"
pattern doesn't exist anywhere.
3. **Drop detection as a state machine input is novel.** The three-phase
free-fall/impact/inactivity algorithm exists in research, but no OS
integrates it into power management. Our design can add it as a sensor
source with high confidence (free-fall is hard to fake) that triggers:
- Suppress touch input (prevent accidental taps during fall)
- Lock immediately (if screen was on)
- Log to audit trail (drop event with timestamp + sensor data)
4. **The two-machine pattern (PowerManager + DozeMachine) validates our
approach.** Android separates the top-level wakefulness from the doze UI
layer. Our design separates the unified device state from the shell
projections (IdleCoordinator, GlobalStates). Same shape, better coverage.
5. **Proximity debounce matters — and the direction is ours, not Android's.**
`DisplayPowerProximityStateController` uses 0 ms positive and 250 ms
negative: believe `near` at once, make `far` wait. This section told us to
copy that. **Measured on the trail 2026-07-26, it would have done nothing.**
44 near-episodes over 2.4 hours: median dwell **1 second**, 15 of them
sub-second, median 115 s of quiet between them. Not a sensor bouncing around
a threshold — isolated one-second blips. A negative debounce delays believing
`far`, so each 1 s blip would have become a 1.25 s blip and all 88
transitions would have stayed.
The mirror image is what this data asks for: debounce the **positive** edge.
Android debounces the negative one because there `near` means *screen off at
the ear, now*, and a positive delay is felt. That constraint left the moment
proximity stopped actuating (§4) — it now only vetoes tap-to-wake, where
waiting is imperceptible and a false veto is the worse failure.
Built as `PROXIMITY_NEAR_DEBOUNCE` 700 ms / `PROXIMITY_FAR_DEBOUNCE` 0, both
policy fields. Nine of the 44 episodes ran ≥5 s (max 173 s) — pocket, ear,
deliberate cover — and pass through untouched. Resolution happens on the
reading *and* on the tick, so a reporter that heartbeats every 30 s cannot
delay a real `near` by half a minute. The edge that wins is written to the
trail with how long it held; the blips that lose are not written at all,
which is most of the log volume §0 was complaining about.
**The lesson is the method, not the number.** The prior art was read
correctly and applied to a device whose constraints had changed underneath
it. Measure the thing before adopting the answer.
---
## 10. Evidence-source health (built 2026-07-26)
§4 says what a reading is worth. This section says whether there is a reading
at all, which §4 assumed and never checked.
### Why staleness was not enough
`EVIDENCE_TTL` (30 s) already expired old readings, and its comment already
named the SLPI outage. It still could not have caught it. `expire_stale_evidence`
only acts on a source whose flag is currently `true` — it exists to stop a
30-second-old "near" from suppressing a wake. A proximity sensor resting at
`far` has a flag of `false`: nothing to expire, nothing anomalous about the
silence, no entry in any log. That is the shape of the outage. Four hours of
dead sensors produced exactly zero lines.
The two questions are different and both are needed:
| | question | threshold | what it protects |
|---|---|---|---|
| `evidence_fresh` | may this *reading* be believed? | `EVIDENCE_TTL` 30 s | a rule acting on a stale value |
| `sensor_health` | is this *source* there? | `SOURCE_DOWN_AFTER` 90 s | a rule acting on absence as if it were a negative |
### Three states, and why `Unknown` is not a failure
`Unknown` → never heard from. `Live` → reported within the window. `Down`
reported once and then stopped.
Only a source that spoke and then went silent has failed. Accel, light and touch
have no reporters on this device today, so they sit at `Unknown` forever, which
is correct: a signal that fires for hardware nobody wired up is a signal nobody
reads. Down is rare by construction, and therefore worth reading.
### Health is not read from `net.hadess.SensorProxy`
`HasProximity` / `HasAccelerometer` is the obvious source of truth and it is
wrong. It lies in both directions, both measured (`PAF/slpi.md`):
- 2026-07-25 12:19 — stack dead, proxy answered `HasProximity: true` for hours,
and the first client to claim a sensor segfaulted it.
- 06:04 incident — proxy answered `false` after remoteproc had already
recovered SLPI.
A property that is wrong in both directions is not an authority. What the
machine can actually trust is its own experience: whether readings arrive. This
is the same move as `lockhint.rs` making logind the lock truth — read the thing
that cannot be wrong about itself, not the thing that summarises it.
### Silence had to be made meaningful
`monitor-sensor` emits only on change, so a phone on a table is silent for hours
and is byte-for-byte indistinguishable from a CHRE fatal. Without fixing that,
the only options were never detecting a dead stack or crying wolf every quiet
afternoon.
So reporters heartbeat: `blueline-proximity-lock` re-sends its last reading
every 30 s, well inside the 90 s threshold, and seeds that value from the
startup probe banner so a stack that was dead from boot is still visible. As
long as the reporter and the sensor behind it live, sessiond hears from
proximity every 30 s. If it stops hearing, something is actually wrong.
The repeats are idempotent — the machine re-derives identical evidence — and
they do **not** append to the trail, or proximity alone would add ~2,900 lines a
day saying nothing changed to a log that §0 records as unbounded and on tmpfs.
The dedupe skips the trail write only, never the evaluation: an unchanged
reading can still change the answer, because proximity held `near` across a lock
flips `should_be_observed` with no change in the evidence at all.
### What the trail can now answer
- `error-security` `source-down` on the outage edge, once, not per tick.
- `source-recovered` on the way back, which is what *bounds* the window. An
outage with no closing entry tells you when the sensors died and never when
they returned.
- `sensors_degraded` on **every** forensic snapshot. This is the part that makes
the log diagnostic rather than merely complete: a `panel-off` recorded during
four dead hours no longer reads like a healthy one. Reconstruction after the
fact can ask "what was the machine deciding on?" and not just "what did it do?"
### Delivery order matters, and it is TASK-28's case exactly
The two halves ship by different routes — sessiond in the `souveraine` package,
the reporter in `rootfs-overlay/`. New sessiond with the old reporter would
declare proximity down every 90 s, forever, because nothing heartbeats. The
reporter went to the device first, deliberately. That asymmetry is the argument
TASK-28 is made of.
### Still not covered
Health answers "is the source there". It does not answer "is the source telling
the truth" — a proximity sensor stuck reporting `near` heartbeats perfectly and
reads as `Live`. §4's cross-sensor disagreement machinery is where that would
live, and it is still computed and logged without being branched on.
---
## 11. The trail itself (built 2026-07-26)
§5 said what goes in the trail. §10 made the entries diagnostic. This is about
the file.
**Where.** `$XDG_STATE_HOME/souveraine/forensic.jsonl``~/.local/state`, next
to `crashes.log`. Durable, and inside the encrypted home. No fallback path: with
no home the trail is memory-only and says so at startup. The old code fell
through to `/tmp`, which nobody would look in. Nothing is migrated; there was
never anything in the tmpfs file to migrate.
**Bounds.** 4 MiB a file, two rotated generations, 12 MiB in all. At §0's
3.6 MB/day that is three days; at the 0.5 MB/day measured since §10's heartbeat
dedupe, three weeks. Proximity debounce (§9.5) cuts it again — the flap is most
of what is left.
**The chain.** Each entry carries `prev`, the SHA-256 of the one before it, and
each written line ends with its own `hash`. Same contract as
`SessionAudit.qml`, so one verifier reads both: strip the trailing
`,"hash":"<hex>"`, close the object, hash it, compare.
Three properties, each a test:
- Rotation does not break it. The new file's first entry carries the old file's
last hash. A file read alone shows a non-empty `prev` on line 1 — correct, its
predecessor is the next file along.
- Restart does not break it. The daemon reads the tail and continues the seq and
the hash. Every run opens with `trail-opened`, which names the path, says
whether the chain resumed, and proves the file is writable before anything
needs it to be.
- **A torn tail is a hard reboot, and the chain resumes past it.** Written
first as "rotate the whole file aside"; hardware corrected it the same day.
Casey's cold boot left the trail at exactly 4096 bytes — a page boundary —
with the final entry cut in half, because the last write never reached disk.
Rotating that aside restarted the chain at seq 0, losing continuity at the
one event most worth reconstructing. Now the torn bytes are discarded, the
file is truncated to the last intact entry, and the chain continues from it,
loudly. A file with *nothing* parseable is a different claim and is still
rotated aside and kept. A bad hash in the middle is a third claim, and
nothing here hides it — the verifier reads every line.
Seq, chain head and write now sit under one lock. Two locks and a later write
was survivable for a counter; two threads taking the same `prev` verifies as
tampered.
**Failed writes are loud.** Every file call was `let _ = ...`. Now the first
failure warns with path and error, and recovery says so — one line per edge, as
§10 does for source-down. The chain head advances only on a line that landed.
Advance it on a failed write and the next entry points at a `prev` no file
holds, which looks exactly like deletion.
**The verifier exists:** `souveraine-verify-trail` (Pixel3Arch
`rootfs-overlay/usr/bin/`). Checks that every entry's hash covers its own body
and that each `prev` is the previous hash; takes generations oldest-first to
verify across a rotation. Proven on the device 2026-07-26 — 14 entries verify,
and editing one byte of entry 2 reports `seq 1 hash does not cover its body`
and exits 1. The same contract reads `SessionAudit.qml`'s chain, which has
never been checked either.
---
## 12. Ambient light — where it belongs in the chain (2026-07-26)
Light is already half-modelled: `SensorSource::Light` exists, `light_changing`
carries +0.2 in §4's arithmetic, and §10 tracks its health. It has no reporter,
so it sits at `Unknown` forever. Adding auto-brightness means answering two
separate questions, and conflating them is how this goes wrong.
### Two consumers, one input
| | question | already exists |
|---|---|---|
| evidence | is the light *changing*? (someone is here) | yes — §4, unfed |
| actuation | how bright *should* the panel be? | no |
A change-detector cannot drive brightness and an absolute lux reading is weak
evidence of presence. Both come off the same sensor; neither substitutes.
### It must be an Action, not a daemon
The tempting shape is a small auto-brightness service reading lux and calling
`brightnessctl`. That is an **eighth blind actor** and §1 already says what
happens: sessiond owns the panel's brightness today — `Dim` captures the
current value, `Restore` puts it back, and the code comment on that capture
records what it cost to get right. A second writer makes the captured value a
lie the moment it writes, and the failure looks exactly like the one already
recorded there: "a tap-to-dismiss came back at a different level than it
started."
So: lux enters through `sensor_input` like proximity, and auto-brightness
leaves as an `Action` computed in `tick()`, executed by the same executor
table that owns `Dim`/`Restore`. One writer, one trail.
### The interaction that has to be stated
`Dim` is a deliberate departure from the correct brightness, so auto-brightness
must be **suspended between `Dim` and `Restore`**, not merely rate-limited.
Otherwise the dim is fought by the ambient controller, and `Restore` puts back
a value auto-brightness has since superseded.
The rule: auto-brightness proposes only while the panel is undimmed and the
device is not `Locked`. On `Restore` it re-evaluates from current lux rather
than replaying the captured number — the capture exists to undo *our* dim, not
to pin the panel to a stale room.
### Hysteresis, for the same reason proximity has it
§9.5's lesson applies directly: an unsmoothed sensor driving an actuator
produces exactly the flapping proximity produced. Lux is continuous and noisy —
a hand passing over the sensor is a 1-second event. Ramp, do not step, and
require a sustained delta before moving at all. The numbers should come off the
trail the way `PROXIMITY_NEAR_DEBOUNCE` did, not out of this document.
### The reporter is the same open question as proximity's
`blueline-proximity-lock` is a shell script on a `sleep 30` loop feeding
`sensor_input`, and §10's heartbeat requirement is implemented inside it. A
light reporter needs the identical contract — heartbeat inside
`SOURCE_DOWN_AFTER`, seed from a startup probe — which is the second copy of
that logic and the argument for one reporter process serving every
iio-sensor-proxy source instead of one script per sensor. Decide that before
writing the second script, not after the third.
### Manual override: anchors, not a bias (settled 2026-07-27)
This section's open question — "Android uses the manual value as a bias on the
curve; that is probably right here too" — has a better answer, and it is the one
iOS uses. From Apple's brightness-control patents: the system does not apply a
global offset. It keeps a **piecewise curve anchored at 0%, 100%, and the points
the user has actually set**. A manual correction at a given ambient level
becomes an anchor at *that* level, and the controller interpolates between
anchors.
The difference is not cosmetic. A bias is global: nudge the screen up in a dim
room and you have also brightened every sunlit reading. Anchors are local, which
matches what the person meant — they made a statement about *this* light, not
about all light. It also explains the behaviour everyone recognises from iOS:
correcting brightness indoors does not wreck it outdoors.
Two consequences worth stating:
- **The ceiling is honoured.** Pinned at manual maximum, further ambient rise
moves nothing. There is no headroom left to grant and pretending otherwise is
how a controller feels like it is fighting you.
- **Two to four anchors is the whole model.** This is a handful of `(lux,
brightness)` pairs, not a learned curve. It persists next to the other
settings, and a user who wants their old behaviour back deletes them.
### Response shape
Three separable pieces, and conflating them is how this goes wrong:
1. **Smooth the input.** A hand passing the sensor is a one-second event. Same
argument as §9.5, same fix.
2. **Ramp the output, asymmetrically.** Animate toward the target rather than
stepping, and do not use the same rate in both directions — walking into
sun and walking into a dark room are not the same experience. **Which
direction gets the faster ramp is a measurement, not a quote.** §9.5 is the
standing warning here: the prior art was read correctly and applied to a
device whose constraints had changed underneath it. Take the numbers off the
trail.
3. **Fit the curve** from the anchors above.
### Verified 2026-07-27: the input is already lux
A real concern with ambient sensors is that the raw part reports RGBC channels
and `intensity_clear` is not illuminance. **Not our problem here** — measured on
the device, iio-sensor-proxy hands us lux directly:
```
=== Has ambient light sensor (value: 0.000000, unit: lux)
Light changed: 0.665771 (lux)
```
No conversion step. The same run also proves the ALS itself is healthy — which
matters, because the reason nothing has ever consumed it is that the *reporter*
was dead (see `SourceHealth::Absent`), not the sensor.
### It is still not a QML singleton, and that is the whole point
The natural-looking shape — a Quickshell singleton subscribing to
`net.hadess.SensorProxy` and writing brightness — is the one thing this section
already forbids, and it is worth restating because it is what any outside
adviser will propose. sessiond owns the panel's brightness: `Dim` captures the
current value and `Restore` puts it back. A second writer makes that captured
value a lie the instant it writes, and the failure mode is already recorded in
the code comment on that capture — "a tap-to-dismiss came back at a different
level than it started."
Reference daemons in this space (`yabd`, `lightsd`) are worth reading for the
ramp and clamp mechanics and worth copying nothing from architecturally: both
are the standalone-daemon shape, because neither has a device state machine to
belong to. We do. Lux enters as `sensor_input` like every other source;
brightness leaves as an `Action` from `tick()`, through the executor table that
already owns `Dim`/`Restore`. One writer, one trail.
### Open
- Whether lux belongs in the confidence table at all once it drives an
actuator, or whether `light_changing` should be derived from the same
smoothed signal auto-brightness uses rather than a second raw one.
- How long a manual anchor lives. Anchors that never expire eventually encode a
room the user left a year ago; anchors that expire silently undo a stated
preference. Neither is obviously right and the trail cannot answer it yet.