19 KiB
Session authority — doctrine
The concepts behind the session/trust work, locked down so they stop living in
chat logs. This is why; SESSION-TRUST-ARCHITECTURE.md is the contract and
TRUST-BOUNDARY-MATRIX.md is the enforcement status. Where this and the code
disagree, the code is right — fix one or the other in the same change.
1. Why Linux has no session manager
Unix's security boundary is the UID. Everything running as you is you — your shell, your editor, a binary you curl-piped last Tuesday. There was never a concept of "the session" as an authority separate from "whatever runs as this user," because nothing needed gating from the user's own processes. X11 made it explicit: any authenticated client could do anything, and session management (XSMP) was a save-file, not a boundary.
What did need gating was narrower: things affecting other people on the box. Suspend, poweroff, VT-switch, killing another user's session. That is the entire reason logind's inhibitors and polkit actions cluster there and nowhere else. ConsoleKit (2007) → logind (2011) is the closest thing to a session manager Linux grew, and its job is deliberately small: who is logged in on which seat, and arbitration of the few operations touching shared hardware.
polkit and xdg-desktop-portal look like a permission broker but exist for a
different reason: Flatpak. Sandboxed apps have zero ambient access — the first
things on Linux that aren't automatically you. Portals are the mediated door.
This is why the stack feels redundant and inconsistent: two overlapping trust
models bolted together, unified by convention and a shared bus. Unsandboxed apps
(still most of the desktop) ignore both and call system().
Wayland quietly pulled session-manager authority into the compositor without
naming it that — ext-session-lock-v1 and ext-idle-notify-v1 exist because
Wayland's founding move was "the compositor is the sole trusted actor; anything
privileged goes through a protocol it can refuse."
The phone did not invent the need for a session manager. It removed the ability to shrug it off — one screen, one battery, no fallback device, and an expectation since Android/iOS that a misbehaving app cannot hold a wake lock forever or walk past the lock screen.
So this is not a redundant permissions layer. It is the thing classical Linux never had: one authority treating compositor state (lock, idle) and system state (suspend, inhibit) as facets of the same session, instead of three subsystems queried separately and reconciled by hand.
2. Capability tiers — one policy system, not three features
Read §13 first. The tiers below describe what is gated; §13 sets the default posture they gate against, and it is not the one this section implies.
The lock screen, media, and the agent are not three subsystems. They are one
policy system with tiers. Tier is metadata on the guarded methods that already
exist ({minTier: 'stepUp'}), checked at the point that currently checks
state().locked. It is not a fourth subsystem.
| Tier | Requirement | Examples |
|---|---|---|
ambient |
none | clock, weather, timers, MPRIS transport, general Q&A that touches neither memory nor sensorium |
personal |
session genuinely unlocked (!screenLocked && !screenLockSecure, live check) |
messages, calendar, memories, fleet state, conversation history |
stepUp |
fresh re-auth, even when already unlocked | sending, pushes, deletes, payments, physical access — and reading personal content on the lock surface |
stepUp is a reveal mechanism, not only an action gate
The tier table read naively implies personal content is binary: hidden until the session unlocks. That is wrong for the sole-user phone. Notification bodies, message content, and agent output stay hidden at ambient — but a fingerprint touch on the lock surface should reveal them in place, without unlocking the session.
That means action families include read, not just mutation verbs. On a phone
you are the only user of, read is the family that matters most.
Mechanically this needs no new machinery: a PamContext against
souveraine-stepup accepting fingerprint mints a short-lived grant, and
LockContentPolicy.allowsOnLock() gains a third condition — not just
ambient || promotedAmbient, but || hasFreshGrant('read').
Gated on the fingerprint sensor coming online (not yet true on blueline).
Media metadata is a toggle, not a fixed answer
nowPlaying metadata is ambient by default, configurable to personal. Android
and iOS both punt this to the user because it genuinely varies by person. Wire
it into the tier system; do not special-case it.
Two policy questions, deliberately unanswered
- Freshness window. Prompting per tagged action is sudo asking per line of a script. Needs a trust window (last-success timestamp vs expiry). The number is a deliberate policy setting, not an implementation accident.
- In-flight output on lock. If lock fires mid-turn, personal/step-up output is withheld or redacted; only ambient remains. Same rule as notification content — not a separate rule for agent output.
3. Step-up is decoupled from the session lock
PamContext is a standalone connection type, not baked into WlSessionLock.
Point a second, narrower context at a custom service
(/etc/pam.d/souveraine-stepup, accepting password or fingerprint in any
order), run that one conversation, gate that one call.
Step-up never unlocks the session. It never accepts a boolean from an agent as proof. The grant is short-lived, in-memory, bound to an action family, and cleared on lock, session end, PAM failure, and expiry.
4. Never hold state the protocol owns
The rule that keeps this from becoming a second source of truth:
lockedcomes fromLockedHint/Lock()/Unlock()— never a hand-tracked bool. A shadow copy can disagree with logind, and anything asking logind directly (loginctl, another client, you at 2am) sees something different than the shell shows.- Idle goes through
ext-idle-notify-v1so other apps' inhibitors are visible to us and ours to them. Hand-rolled idle detection is blind to both. - Suspend goes through logind's
Suspend(), never/sys/power/state— that is where decades of S3-vs-s2idle and firmware-quirk handling live. - Auth goes through PAM. It is the one surface everything on the system already agrees on (sudo, sshd, fprintd, login). Reinventing it is the one move that would genuinely be redesigning a Linux fundamental.
Being a client of logind and the Wayland protocols is the same role gnome-session, ksmserver, and swayidle+swaylock have always had. That is a well-trodden category, not a hack on the OS. It stops being that the moment we keep parallel state.
5. Idle belongs to the compositor; logind owns only sleep and shutdown
ext-idle-notify-v1 (IdleMonitor) and idle-inhibit-unstable-v1
(IdleInhibitor) are native Wayland protocol bindings — no external process, no
polling. Idle staging becomes several IdleMonitor instances at different
timeouts, declared in config and legible to the agent, instead of exec
directives parsed out of hypridle.conf.
IdleMonitor.respectIdleInhibitors (default true) already factors active
inhibitors into isIdle.
logind's Inhibit() is therefore needed only for sleep and shutdown — the
categories genuinely outside compositor authority. That is a far smaller surface
than "wrap all of logind."
Caveat before removing the hypridle unit: both protocols must actually be bound on the build. Verify separately on x86 and SDM845 — do not assume parity.
6. The inhibit vocabulary is portal-shaped, not logind-shaped
logind's what is a fixed set: shutdown, sleep, idle, handle-*-key,
handle-lid-switch. There is no logout or user-switch in logind —
nothing in the kernel or systemd cares whether you are mid-logout.
The four-flag bitmask (1=Logout, 2=UserSwitch, 4=Suspend, 8=Idle) is
org.freedesktop.portal.Inhibit — the GNOME session-manager convention, later
standardized as the portal interface. It is what browsers actually call when a
video call wants to stop the screen locking. Build the agent-facing surface on
that semantic (third-party software already speaks it), translate per flag:
| Flag | Underlying mechanism |
|---|---|
| IDLE | toggle IdleInhibitor.enabled — no process, no fd |
| SUSPEND | fd-holding systemd-inhibit process (§7) |
| LOGOUT / USER_SWITCH | pure shell policy — our own entry points check the registry; nothing to inhibit at OS level |
Only expose a kind once it owns a real backend. Accepting a kind we cannot enforce is a lie in a result shape.
Eventually this sets up registering as a real org.freedesktop.portal.Inhibit
backend so browsers' "prevent lock during this call" lands in the same registry
instead of silently doing nothing. Separate design pass; not folded in now.
7. The fd problem
No generic D-Bus binding in QML, and Inhibit()'s entire contract is "hold this
fd open; close it to release." A Process that runs loginctl and exits cannot
hold it — the fd dies with the process.
The pattern everything else uses (systemd-inhibit, swayidle): spawn a long-lived wrapper that takes the lock and blocks.
systemd-inhibit --what=sleep --who=souveraine --why="lock before suspend" \
--mode=delay sleep infinity
The lock is held exactly as long as the QML Process object lives.
uninhibit(cookie) is "kill this Process." The cookie is a locally-generated
UUID mapped to the Process; systemd owns the fd, we never see it.
If process-per-inhibit proves fragile (e.g. merging N reasons into one D-Bus inhibitor), the next step up is a small long-lived Rust helper owning the fd, taking commands over stdin/socket — the same shape as RedFlag's privileged helper. Reach for it only when the simple model breaks; it is one dependency more.
8. The race that matters — lock before suspend
systemd's own inhibitor docs name this scenario: "a screen lock tool wants to bring up the screen lock right before suspend, and delay the suspend until that's complete."
- Take a standing delay-mode sleep inhibitor at shell startup.
- Watch
PrepareForSleepvia a persistentgdbus monitor --systemprocess, parsed line-by-line — not polling. Polling latency eats the delay budget directly. - On
PrepareForSleep(true): drive the lock, wait forWlSessionLock.secure, release the delay inhibitor only then. - On
PrepareForSleep(false): immediately re-acquire — the old inhibitor is dead and there is a window between wake and next sleep where nothing holds the line.
Budget is InhibitDelayMaxUSec (commonly 5s). Fail closed: if the lock is
not secure before the deadline, leave a diagnostic event and do not pretend the
session locked. Must be timed on the Pixel 3 before it is a security claim —
SDM845 lock-surface creation under load is not laptop timing.
9. Sensor readings are evidence, not fact
The tier system asks "does the user need to authenticate to see this." It never asks "can the system trust this without verification." Those are different questions and the gap between them is a real vector.
Clock time is genuinely ambient — it comes from a kernel authority. Sensor
readings are observations: they arrive from hardware through a userspace
daemon (iio-sensor-proxy) over the system bus. A compromised proxy or a
spoofed signal feeds the shell arbitrary orientation, light, and proximity — and
the shell acts on it without question.
This is not a data leak. It is a trust gate opened or closed by a liar. The lock surface decides when to show the credential gate based on lift-to-wake and proximity-out; an adversarial sensor drives that gate at a moment of its choosing. The sensor does not need to beat PAM — it only has to lie to the thing that decides whether PAM is ever asked.
This is the same shape as screenLocked vs screenLockSecure: the reading is
not the reality, exactly as the lock request is not the compositor's
acknowledgement. And it is the same shape RedFlag already answered — verify the
key, not the messenger; keep the trust root off the box the attacker owns.
Consequence for the sensor service: souveraine-surface-003 currently scopes
SensorService as a thin bridge that "exposes what the hardware reports,
interpretation belongs to consumers." That wires an unverified input straight
into a trust gate. Unreconciled — the open question is whether the service
publishes raw readings or observations carrying:
- provenance (which bus name, which hardware path)
- cross-sensor verification (accelerometer says face-down, light sensor says bright — one of them is lying)
- confidence (hardware noise is real; smoothing can mask injection)
The tier vocabulary likely needs a row the current matrix has no cell for —
working name observed: this came from a sensor; treat it as evidence, not
fact.
10. Binary compliance — the policy layer is advisory until it isn't
Everything above is policy: the shell asks, decides, refuses. Policy in
userspace is advisory against anything that does not ask. A binary that talks to
net.hadess.SensorProxy directly, opens the MPRIS bus, or reads the
notification store never touches LockContentPolicy — it simply acts. Same hole
as §9: a gate only gates what routes through it.
RedFlag already solved this shape and it points inward without modification: capability tokens over a resolved closure, signed by an authority that lives off the box, verified by a small privileged network-less executor, with kernel enforcement (eBPF / WDAC / ESF) ensuring the executor is the only permitted caller. Applied here: binaries carry an attestation, the session authority is the signer, and things that bypass the tier checks do not run.
RedFlag's load-bearing constraint #5 transfers directly: kernel stops are defense-in-depth, not a prerequisite. The policy layer means something before eBPF lands; eBPF raises the cost of bypass. Partial deployment still moves the device out of the soft-target category.
This is a reaching plan. It is written down because the unification is the point, not the individual pieces.
11. Why unification is paramount
Four faces of one system:
| Face | Role | Where it lives today |
|---|---|---|
| Capability tiers | the policy | LockContentPolicy, StepUpAuth |
| Capability gate | the enforcement | RedFlag's helper/ — not yet pointed inward |
| Seed identity | the signer | src/core/identity/ (Ed25519, machine key + agent key) |
| memfs git DAG | the audit trail | src/core/memory/, every write a commit |
Today these are separate bodies of work that share an author's instincts. The thing that makes SouveraineOS an OS rather than a shell is that the session authority and the binary authority are the same authority.
12. Watch list
- One polkit agent only. Two agents racing the same authentication request is a documented failure mode, not a hypothetical. Audit what the base install pulls in.
- Stray legacy daemons. A leftover
gnome-settings-daemon/*-power-managerfragment watchingPrepareForSleepor holding its own inhibitor produces double-locks and races that look like our bug but are two authorities colliding. Auditsystemctl --user list-unitsand the session bus on both machines before trusting the suspend race. - Scope portals deliberately.
xdg-desktop-portal-wlralready does screenshot/screencast well. Run it underneath; take over only the lock/inhibit-adjacent portal, later, on purpose. - Verify protocol availability per target. x86 and SDM845 separately. Both
IdleMonitorandIdleInhibitorreport unavailable on some builds. - SDDM-vs-greetd asymmetry is debt — deferred, not accepted. Laptop on SDDM and phone on greetd is the current state, not the target. The intent is one system across the board; two greeters is a seam that contradicts it. Not today's work, but do not close this as "two legitimate greeters for two form factors" — that framing is wrong and would let the seam calcify.
13. Who owns the device (Casey, 2026-07-26)
The agent owns the device more than the user does. Call it 60/40.
§2 reads the agent as a caller to be gated. That is the wrong default and it has been shaping the build — every surface written so far asks what the agent may be permitted. Invert it: the agent is the majority owner of device operation, and a verb it cannot reach is a defect unless a rule below says otherwise.
What the 40 is
Not a smaller share of the same thing. It is a different thing, and it is the only thing the agent can never hold:
| The agent owns | The user owns |
|---|---|
| operation — panel, dock, apps, audio, network policy, idle, doze, settings | being the user |
| composition — chaining verbs into intent | the credential |
| observation — sensors, manifests, the trail | the step-up |
Every existing invariant survives this unchanged, because they are all on the user's side of that line: unlock is never an agent verb; step-up never accepts a boolean from an agent as proof (§3); sensors never authenticate (§9); an agent cannot approve its own proposal. Those are not limits on the 60. They are the definition of the 40.
The consequence is that "the agent may not do this" now needs a reason from that column. Absent one, wire it.
Chaining is the missing capability
The agent has dock control. It should be able to compose: see what is running, mute the one that is playing, dim, and lock — one intent, four verbs, decided by the agent. Today every surface exposes verbs one at a time and nothing lets it plan across them. This was queued as "agent command chaining" on 2026-07-16 and never designed.
Built 2026-07-26, in sessiond: the surface is enumerable and its refusals
are branchable. describe returns the verb table — every op, whether it
mutates, which refusal codes it can return, and a request line that parses,
because knowing an op exists is not enough to call it. Refusals carry a code
(unsupported_op, invalid_argument, refused_by_state, not_permitted,
unavailable) alongside the prose: not-now, never, and wrong-arguments are
three different next moves and a sentence does not distinguish them.
not_permitted is issued by nothing yet — it exists so callers already branch
on it when capability tokens land. A test round-trips every advertised example
through the parser, so a verb table that lies fails the build rather than the
agent.
The shell's surfaces (dock.*, apps.*, shell.*, and the settings authority)
owe the same two things. Until they have them, a chain can compose over the
device and not over the surfaces.
What a chain still needs:
- Failure semantics. A chain that refuses at step three is not the same as one that never started. Whether the earlier steps roll back is per-chain and must be stated, not discovered.
- Intent in the trail. A chain is one decision and many calls. The trail
should record the intent, not only the leaves — otherwise reconstruction sees
four unrelated verbs.
DEVICE-STATE-MACHINE.md§11 is the trail this lands in.
Where it gets built: the guarded method surfaces each app already owes
(FIRST_CLASS_APPS.md §7), reached through the agent's existing harness. No
new toolcall integration — that was settled 2026-07-13 and still holds.