Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/docs/DEVICE-STATE-MACHINE.md
Fimeg bde961c6f2 saf: one spine — device, state, and work under the index
PAF becomes saf/device (history kept), STATE.md dissolves
into saf/state.md with the dated era archived, the substrate
SAF moves up from souveraine, and every agreement points at
saf/INDEX.md and nowhere else. one map, nothing to remember
2026-08-18 09:47:30 -04:00

49 KiB
Raw Permalink Blame History

Device state machine — unified design

Historical design and hardware audit. The living architecture is now ~/Projects/souveraine/saf/authority/02-device-state.md, with the shared evidence contract in saf/nervous/02-belief.md. This file retains the full chronology, measurements, and rule derivations.

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 saf/device/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)

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:

  1. It never became a SensorSource. It entered through note_charge, a side door, so it got none of the gate: no EvidenceSeen, no SourceHealth, no last-seen, no Absent-vs-Down. §10's gate only gates what routes through it.
  2. The authority probed. sessiond reached out and read /sys/class/power_supply on a 5-second clock. The decider became a driver; the afferent nerve has one job, and it is not deciding.
  3. 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.
  4. Nothing left tick(). No Action, 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::Charge on the wire; the reporter reads the supplies and the machine interprets; source health, freshness, last-seen and Absent/Down are 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

  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 (saf/device/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.