Four docs recorded 'nothing starts it' as settled fact and concluded don't use the target, where the conclusion available was light the target. viewtop's session launcher starts it now.
8.9 KiB
TASK 40 — Inference tiering for the sensor surfaces
Status: open, gating. Raised 2026-07-27 (Casey, explicitly: the "based on
what they might infer" lens). Size: decisions first, then small code.
Repos: souveraine (sensord, sessiond, dial QML), Pixel3Arch
(blueline-edge-sense). Gates the grip producer's raw stream (TASK-13
shipped without it) and TASK-31's dial IPC.
The lens, in its own words
SECURITY-AUDIT.md P1 — inference-first tiering:
Permission classes are defined by what a capable agent could infer from a stream, not by what the field literally is. A contact count, an app-launch timing log, an ambient noise level, a raw accelerometer reading — each is "just data" per-field, and each is an inference substrate for a sufficiently capable model.
AOSP retrofitted the 200Hz sensor cap and
HIGH_SAMPLING_RATE_SENSORSafter realizing motion sensors infer keystrokes, gait, identity — it was a gap, then a patch, not first-class design. We model inference as first-class.Application: every tier assignment must be justified against the inference lens. "Looks innocent" is not a defense.
Three things landed or moved in the last day that this has never been applied to. Two are about to start emitting for the first time, which is exactly the moment P1 says to do the work — before the stream flows, not after.
1. The sensor stream is about to exist for the first time
souveraine-sensord has been enabled and dead from every boot (its unit
hung off graphical-session.target, which nothing on this device started —
fixed 2026-07-27; the target itself is started by souveraine-session-viewtop
as of TASK-76, so hanging off it is now the correct idiom). The forensic trail proves the consequence: 2093 wake, 138
transition, 121 decision, 29 error, and not one sensor entry. So the
device state machine — and through doctrine §13, the agent — has never had
sensor evidence at all.
Turning it on is therefore not "restoring" a stream. It is opening one.
What the current shape gets right, apparently by accident
sensord reports edges, not levels (de3a332, "sensord: report edges, not
every sample"):
- light →
{"changing": bool}, never lux - accel →
{"moving": bool}, derived from iio-sensor-proxy orientation changing, not from raw acceleration - proximity → near/far, debounced
That is close to the minimum an inference-first design would have chosen. The agent learns that something changed, not the waveform. Accel in particular is orientation-only, which is the difference between "the phone was picked up" and the 200 Hz stream AOSP had to cap.
This should be written down as a property to preserve, not left as a side-effect of an unrelated commit. Nothing currently stops a future change from passing lux through "because the machine needs it".
Which is exactly what auto-brightness proposes to do
DEVICE-STATE-MACHINE.md §12 (auto-brightness) requires lux to enter as
sensor_input so brightness can leave as an Action. That converts light
from a boolean edge into a continuous level, and P1 says that is a tier
change requiring justification, not an implementation detail.
Ambient light over time is the sleeper in this set. Individually it looks like the most innocent value on the device; as a series with timestamps it carries sleep and wake times, indoor/outdoor transitions, commute shape, and whether the house is occupied. Combined with proximity and orientation edges it is a timeline of a day — P1's "intersection attack" line, verbatim.
The decision this task owes: lux may be needed by the machine without
being readable by the agent. Those are different consumers and the code does
not currently distinguish them — sensor_evidence is on the snapshot and
doctrine §13 makes snapshots agent-readable. Splitting "what tick() computes
on" from "what describe/snapshot exposes" is the concrete work.
2. Grip is the sharpest case on the device
elmyra_raw is six strain gauges at 100 Hz (libssc patched to take the
fastest advertised rate, TASK-13). Against P1 this is not a near-miss of the
AOSP precedent — it is the precedent:
- Keystrokes. Every tap flexes the chassis. A 100 Hz six-channel strain
array on the frame is a typing side-channel, which is the literal thing the
HIGH_SAMPLING_RATE_SENSORSpermission was retrofitted for. - Identity. Where and how hard a person holds a phone is a biometric.
Google's own factory calibration (
edgesense_roller_cal) exists because the gauges differ per unit and per grip — that sensitivity is the feature and the leak. - Health. Hand tremor is in that band.
TASK-13 has been, correctly, a bring-up task. It contains zero tier
analysis, and its own producer is the leaky shape: blueline-edge-sense
opens elmyra_raw and decodes six floats per sample into AP userspace at
100 Hz, which is the highest-inference stream we have ever run.
The architecture that makes it work is also the architecture that fixes it
This is the part worth not missing. TASK-36 established that squeeze cannot be
configured from the AP at all: the AFE control surface
(ashConfigureElmyraFilters) is reachable only from inside a CHRE nanoapp, and
elmyra.so is that nanoapp. It consumes the raw gauges on the SLPI and
emits a gesture.
So the correct implementation is also the private one:
| raw stream | what crosses to the AP | |
|---|---|---|
blueline-edge-sense (today, diagnostic) |
100 Hz × 6 channels, in AP userspace | everything |
elmyra.so nanoapp (TASK-36) |
stays on the SLPI | "a squeeze happened" |
That is the tiering argument made in hardware. The rule this task should
set: the raw elmyra_raw stream is a bring-up instrument, not a product
surface. blueline-edge-sense keeps existing as a diagnostic, gated and
not running by default; the shipping path is the nanoapp's gesture event.
Doing it the other way — shipping the raw reader because it works sooner — would be P1's "gap, then a patch" repeated knowingly.
3. The dial enumerates capabilities, and enumeration is not gated
TASK-31 already says the dial "does not appear over a locked screen with
anything personal on it". That governs painting. It does not govern the
IPC, and the IPC is the agent-facing surface.
modules/souveraine/dial/DialHost.qml today:
IpcHandler {
target: "dial"
function open(): void { scope.dialOpen = true; ... }
function close(): void { ... }
function toggle(): void { ... }
function entries(): string { /* label, icon, enabled, reason for every entry */ }
}
None of the four checks lock state, and GlobalShortcut { name: "dialToggle" }
does not either. Two distinct problems:
entries()is reconnaissance without exercise. P3 says capability use is visible, not just the grant — but enumeration is neither a grant nor a use, so it is invisible. The returnedreasonstrings are designed to be explanatory ("no focused window to close"), which makes the refusal itself an information channel: it answers "is an app open right now" to any caller, at any lock state, leaving no trace.- The contents are the leak, not the ring. Today's entries are generic (lock, screenshot, kill window, health, agent, keyboard). TASK-31's whole design is that entries come from the verb tables (TASK-30) — at which point the dial's contents become "every mutating verb currently permitted", which is precisely a capability map, and a per-contact or per-app entry would carry personal data into the ring.
The decision this task owes: entries() must answer at the caller's tier,
not the device's — an ambient caller sees ambient verbs. That has to be settled
before TASK-30 feeds the dial, because afterwards it is a filter retrofitted
onto a list, which is the shape P1 warns about.
Acceptance
- The edges-not-levels property of
sensordis documented as a requirement with a rationale, so a later change has to argue with it. - Lux reaches the state machine for auto-brightness without widening what the agent can read, or the widening is explicitly justified and recorded.
elmyra_rawis designated a bring-up instrument; the shipping squeeze path is the nanoapp gesture (TASK-36), andblueline-edge-sensedoes not run by default.dial entries()anddial open()answer at the caller's tier, and the refusalreasonstrings are checked for what they disclose.- Each of these is justified in writing against P1, per its own "Application" clause — "looks innocent" is not a defense.
Connects to
SECURITY-AUDIT.md P1/P2/P3, TRUST-BOUNDARY-MATRIX.md,
SESSION-AUTHORITY-DOCTRINE.md §9 (evidence not fact) and §13 (agent-readable
context), DEVICE-STATE-MACHINE.md §4 and §12, TASK-13 (grip), TASK-36 (the
nanoapp that makes the tiering physical), TASK-30 (verb tables — the thing that
will feed the dial), TASK-31 (the dial).