The r506 build had ChargeEvidence reading sysfs from sessiond's clock with conclusion() inside the driver type — the rule violated four times in one object. Recorded here: the ruling (§4), the task reconciliation, and the bearer audit under the same rule. Also the corrected cutover answer: the laptop is the test bed, the chooser is phone-shaped, mechanism undecided.
49 KiB
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/Blankthrough 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 panelas 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.0–1.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.3–0.6 | Suppress DPMS wake (prevent pocket-dial). Do not promote idle tier. |
| 0.6–0.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)
Charge is evidence, and the reporter never interprets (settled 2026-08-15)
Charging entered the machine as ChargeEvidence — a struct that read sysfs
and computed conclusion(), probed by sessiond itself on the clock
(TASK-33 §2's first build, r506). The review found it against the rule this
machine lives by, four times in one type:
- It never became a
SensorSource. It entered throughnote_charge, a side door, so it got none of the gate: noEvidenceSeen, noSourceHealth, no last-seen, noAbsent-vs-Down. §10's gate only gates what routes through it. - The authority probed. sessiond reached out and read
/sys/class/power_supplyon a 5-second clock. The decider became a driver; the afferent nerve has one job, and it is not deciding. - The driver interpreted.
conclusion()lived on the type that read sysfs. Belief's one load-bearing rule — interpretation may consume evidence but never a driver — was broken inside a single object. - Nothing left
tick(). NoAction, no executor table, just a trail line and JSON on the wire. §12's shape, absent.
And the comment justified it by citing bearer — another instance of the
same divergence, not doctrine. The disease had already propagated once and
was on its way to becoming precedent.
The ruling: charge is a sensor source like any other. SensorSource::Charge
SensorValue::Chargeon the wire; the reporter reads the supplies and the machine interprets; source health, freshness, last-seen andAbsent/Downare free because they are the gate.conclusion()lives on the machine, as the machine's one decision, and the driver type is gone.
Audit of bearer under the same rule: bearer is the same shape — a probe
run by the authority on the clock, with preferred()/tunnel_underlay()
interpretation inside the driver type. Its actions do leave tick()
(§12's shape is present), which is the one difference from charge. The same
correction applies: the probe moves to the reporter, the interpretation to
the machine. That is a follow-up, not a debate.
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:
{"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
-
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.
-
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.
-
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.
-
The audit trail is unified. SessionAudit receives all events — transitions, sensor inputs, errors. The hash chain covers everything.
-
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
-
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."
-
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. -
What is the freeze granularity? Answer: freeze
apps.slice, allowlistapps-wake.slice. TASK-15: "A slice freeze is one command and no bookkeeping; per-app lets Chatty stay live for SMS-adjacent work. Likely: freezeapps.slice, keep an allowlist slice for the few that may run locked." -
How does drop detection fit? Dropped. No existing OS does it. Novel but not needed for v1.
-
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 2–3 (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
-
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.
-
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.
-
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)
-
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.
-
Proximity debounce matters — and the direction is ours, not Android's.
DisplayPowerProximityStateControlleruses 0 ms positive and 250 ms negative: believenearat once, makefarwait. 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
nearmeans 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_DEBOUNCE700 ms /PROXIMITY_FAR_DEBOUNCE0, 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 realnearby 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: truefor hours, and the first client to claim a sensor segfaulted it. - 06:04 incident — proxy answered
falseafter 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-securitysource-downon the outage edge, once, not per tick.source-recoveredon 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_degradedon every forensic snapshot. This is the part that makes the log diagnostic rather than merely complete: apanel-offrecorded 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
prevon 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:
- Smooth the input. A hand passing the sensor is a one-second event. Same argument as §9.5, same fix.
- 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.
- 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_changingshould 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.