Watch
1
0
Fork
You've already forked SouveraineOS
0

SouveraineOS umbrella: docs, dumps, handoffs, cross-repo STATE

This commit is contained in:
Fimeg 2026-07-16 08:59:26 -04:00
commit dc4fd14bbd
13 changed files with 2360 additions and 0 deletions

88
STATE.md Normal file
View file

@ -0,0 +1,88 @@
# SouveraineOS — cross-repo state
Souveraine is an OS, an agent substrate, and the name of an agent.
Ani + Aster = Annie (primary consciousness + subconscious = composite).
Hard constraint everywhere: no GNOME or KDE library dependencies.
This repo is the umbrella: cross-repo docs, decisions, session dumps, and
this state file. Component repos keep their own repo-local PAF/ docs; what
crosses repo boundaries lives here. Update this file when component state
moves; it is the resume anchor.
Last updated: 2026-07-16.
## Component repos
### souveraine (`~/Projects/souveraine`)
Branch `experimental/session-trust-phase1` @ `04a8390`.
- Session Trust Phase 1 is live on phone + laptop under `qs -c souveraine`:
sessionctl arbiter, first-party lock/nav (pill retired), SessionEvents
logind ingress + delay inhibitor, StepUpAuth, break-glass, hash-chained
audit. Freshly green — the last five commits of Jul 14 were runtime fixes;
see `docs/DUMP-session-trust-2026-07-14.md`.
- `souveraine-secrets`: org.freedesktop.secrets daemon behind the `secrets`
feature, rooted in `SeedId` (no gnome-keyring/KWallet). Committed, not yet
build-verified. Intended consumers: Chatty/libcmatrix via libsecret, and
keyring-core.
- Design docs for the shell live HERE in `docs/`, not in the repo tree
(moved Jul 15; deletion committed `0f50a3e`).
### Pixel3Arch (`~/Projects/Pixel3Arch`)
`main` @ `862202a`. Repo == phone for deployed config as of 2026-07-16.
- Mic: hexagonrpcd now serves the ADSP audio PD (verified live — fluence/ECNS
opens succeed at boot). Open blocker is the SLIM IRQ-read NACK loop
(~1090 hits/boot); state in `PAF/audio.md`. Capture route moved to
MultiMedia2.
- MMS: works end-to-end via the `netsvcs` APN (`PAF/mms.md`). Open: mmsd-tng
has session-autostart via hyprland.lua but no systemd unit; Chatty can't
read the MMSC (daemon doesn't expose org.ofono.mms.ModemManager).
- USB: usb-signaller 0.3.1 built + staged; the RUNNING phone still uses
usb-gadget-net (cutover severs the SSH link — do it with console fallback
or at reprovision). Old gadget archived in `archive/usb-gadget-net/`.
- Untracked by design: `pkgs/pulseaudio-q6/` (working clone; delta preserved
in `pkgs/pulseaudio-q6-overlay/`), `pkgs/*/pkg/` intermediates.
- Undecided: two full kernel worktrees `kernel/linux-blueline-mic-order{,-g56}`
(~GBs, mic experiments, each has STATUS.md) — push branches to Gitea or drop.
### Chatty (`~/Projects/Chatty`) + libcmatrix (`~/Projects/libcmatrix`)
Chatty branch `identity-layer` @ `1b94cf9`; libcmatrix branch `sas-fixes`
@ `0eb5f81`.
- Built and live-verified on the phone: history v6 (persons tables),
ChattyPerson + identity API, Known/Unknown re-keyed (Matrix rooms no
longer auto-Known), E.164 normalize, four SAS verification fixes (emoji
verify completes).
- Design + build order: `PAF-chatty-identity-layer.md` in the Chatty repo.
EDS was investigated and REJECTED (GNOME stack). Contacts backend is
undecided pending qtpim work — see below.
- Phone runs hand-installed chatty/libchatty/libcmatrix binaries over
pacman-owned files (stock backups: `chatty.orig-pacman`,
`libcmatrix.so.0u.orig-pacman`). PKGBUILDs owed.
### qtpim / contacts (`~/Projects/qtpim`, phone `~/Projects/qtpim`)
Full state: `docs/qtpim-contacts-handoff.md`.
- qtpim dev (`d03b5f6`) built and installed on the phone at /usr
(6.12-versioned libs on Qt 6.11.1, version check overridden; untracked —
PKGBUILD owed). QML `QtContacts` module loads.
- Only the in-memory backend exists. qtcontacts-sqlite port stalled: it
targets Sailfish's qtpim fork (`IgnoreAccessConstraints`). Three paths in
the handoff; direction not chosen.
## Decisions on record
- No GNOME/KDE library deps. EDS/libebook rejected for contacts.
- qtpim (upstream dev branch) is the contacts library direction.
- Fido MMS rides the `netsvcs` APN, patched into the carrier DB overlay.
- ETHOS-style guarantees are not runtime knobs; identity is a precondition
(SeedId::load never generates).
- Contacts store under qtpim: undecided.
## docs/ map
- `SESSION-AUTHORITY-DOCTRINE.md` — session/power/lock authority model (§58
verified implemented on phone, 2026-07-15).
- `SESSION-TRUST-ARCHITECTURE.md`, `TRUST-BOUNDARY-MATRIX.md`,
`INTERFACE-ARCHITECTURE.md`, `HOW-IT-WORKS.md`, `REFERENCE-EXTRACTION.md`,
`SETTINGS-APP-PLAN.md`, `README.md` — shell design set (moved from
souveraine repo).
- `DUMP-*.md` — per-session bootstrap dumps; write one per working session
that changes cross-repo state.
- `qtpim-contacts-handoff.md`, `session-trust-map.html` — moved from
~/Projects and ~/ on 2026-07-16.

View file

@ -0,0 +1,168 @@
# Souveraine power/idle/lock — session dump (2026-07-15)
Scope: laptop + Pixel 3, both running `qs -c souveraine`. Not a design doc —
next-session bootstrap so today's findings aren't re-derived from scratch.
---
## TL;DR
Fixed three real bugs today (laptop lockscreen unclickable, phone stale
suspend-key config, phone's redundant sleep lock path, a runaway
`hypridle``polkitd` crash-loop). Confirmed the Android power-button/idle
model this project is measuring itself against, and confirmed
`SESSION-AUTHORITY-DOCTRINE.md` §58's design (logind delay-inhibitor +
`ext-idle-notify-v1`) is **actually implemented and running** on the phone
today, not just designed — verified live via `systemd-inhibit --list` (5
inhibitors: ModemManager, NetworkManager, RealtimeKit, UPower, Souveraine
Shell, all delay-mode). Two real open threads carried forward below.
---
## Fixed today
1. **Laptop right-sidebar unclickable** — root cause was `Config.qml`
missing `sidebar.width`/`widthExtended` (dropped when Souveraine forked
ii's `Config.qml`), collapsing the `PanelWindow` to 1px. Also restored
`lock.dpmsTimeout`, `lock.unlockHook`, top-level `autoIdleInhibit`, and
`Idle.qml`'s `autoIdleInhibit` property (ii-stock `IdleInhibitorToggle.qml`
reads it; Souveraine's rewritten `Idle.qml` never defined it). All are the
same failure class: phone-fork dropped a key ii-stock consumer code
expects. A `source: null` in Souveraine's PulseAudio `Audio.qml` (correct
for the Pixel 3's absent mic capture) was a related but separate crash in
`QuickSliders.qml` — fixed with a real stub object, not a null-guard patch.
2. **Phone: stale `HandleSuspendKey=ignore`** in
`rootfs-overlay/etc/systemd/logind.conf.d/50-phone-powerkey.conf` — its
justification (DSI hold-rails DPMS hazard) is fixed per `ROADMAP.md`
2026-07-14. Removed; `HandlePowerKey=ignore` kept (unrelated, still-valid:
modem state doesn't survive poweroff). Deployed to device; needs
`systemctl restart systemd-logind` (or reboot) to take effect — **not yet
tested against a real suspend-key cycle.**
3. **Phone `hypridle.conf`** — repo copy was stale relative to the device;
synced from live device (it already used the IPC lock call, no
`before_sleep_cmd`, ahead of what either repo checkout showed).
4. **Laptop runaway `hypridle` → `polkitd` crash-loop (this evening).** This
morning's `hypridle.conf` edit (stripped `|| hyprlock` fallback) was
correct on disk, but the *running* `hypridle` process (started 08:24)
never reloaded — hypridle reads its config once at launch. Something kept
firing `lock_cmd` in a tight loop all day, so every trigger executed the
**stale in-memory** `loginctl lock-session; pidof qs quickshell ||
hyprlock` — dozens of processes/sec, each a real `loginctl` D-Bus round
trip. This hammered `polkit_system_bus_name_get_user_sync`, crashing
`polkitd` with SIGTRAP every ~30s for 2+ hours (634+ restarts) — same
crash site/shape as CVE-2021-3560 (client disconnects from D-Bus mid
identity-resolution), though that CVE is old and should be patched in
polkit 127-3, so this may be an unpatched regression of the same class,
not a reopened old bug. Load average hit 32+; dbus-broker/polkitd/qs were
the CPU offenders reported. **Fix applied:** killed stale hypridle (988)
+ all runaway children, relaunched clean (now on the fixed config, no
lock triggered by the fix itself). Confirmed stable (polkitd held 50s+,
load average dropping). **Not yet root-caused: what was actually firing
`lock_cmd` repeatedly in the first place** — an idle-timeout misfire, or
something calling it directly. Needs checking before calling this closed;
could recur.
---
## Android reference model (for calibrating what's a real gap vs. already-designed)
Source-verified this session (AOSP docs, kernel.org wakeup-sources,
`source.android.com/docs/core/power/platform_mgmt`):
- **Suspend is opportunistic, not commanded.** Kernel autosuspends whenever
zero wakelocks/wakeup-sources are held (`/sys/power/autosleep`). Nothing
decides "sleep now" — sleep is the default state; holding a
`PARTIAL_WAKE_LOCK` is what postpones it.
- **Radio/modem during suspend:** a RIL interrupt is itself a registered
wakeup source — it wakes the CPU, the modem driver holds a wakelock long
enough to hand off to userspace, then releases; kernel re-suspends within
seconds. "Asleep" never means "unreachable," it means "reachable only by a
small allowlisted set of interrupt sources."
- **Doze** (two-stage: Light on screen-off, Full on screen-off + stationary)
is a *policy* layered on top of suspend, not the suspend mechanism itself —
throttles what apps may do, not whether the kernel can sleep.
- **Pocket/proximity:** Android uses the proximity sensor to keep the screen
*off* while obstructed and to suppress accidental touch input during a
call or in-pocket — proximity-cleared is a *display* signal, never treated
as an authentication/unlock signal on its own.
Mapping onto this stack: `logind.Inhibit()` (delay-mode, fd-held) is the
wakelock equivalent; `ext-idle-notify-v1` is the PowerManagerService-idle
equivalent. **Confirmed live and running** on the phone tonight — this is
not a gap, it's implemented per `SESSION-AUTHORITY-DOCTRINE.md` §58.
---
## Open threads for next session
### 1. `InhibitDelayMaxSec` — collective 5s budget, unverified against a real cycle
`/etc/systemd/logind.conf` has it commented out (systemd default = 5s), and
**five subsystems share that one window**, not 5s each: ModemManager,
NetworkManager, RealtimeKit, UPower, Souveraine Shell (all confirmed live via
`systemd-inhibit --list` on the phone tonight). If ModemManager's device
reset genuinely needs more than its slice, systemd suspends anyway — the
inhibitor is advisory-with-a-timeout, not a hard block. This is the leading,
still-unverified hypothesis for "modem in a bad state after resume."
**Next step:** pull the journal across a real `PrepareForSleep(true)`
`(false)` pair on the phone and measure how long ModemManager's reset
actually takes vs. the 5s budget. If it's close or over, that's the fix
target (raise `InhibitDelayMaxSec`, or reduce what's sharing the window).
### 2. Pocket/proximity wake bypasses the lock screen — real, observed, security-relevant
Casey: phone pulled from pocket has woken on its own **and skipped the lock
screen** — observed twice. This is not Android's model (proximity-cleared
is a display-on signal there, never treated as unlock) — something on this
stack is currently treating proximity-cleared (or the wake it triggers) as
equivalent to authenticating. This is the exact failure mode
`SESSION-AUTHORITY-DOCTRINE.md` §9 already names in the abstract ("sensor
readings are evidence, not fact... an adversarial sensor drives that gate at
a moment of its choosing") — it just hasn't been checked against what's
actually wired on the phone today. Likely candidates to check first: does
the lift-to-wake/proximity-out path in the lock surface call anything that
actually *unlocks* (vs. just wakes the display), and is `screenLockSecure`
being read correctly on that path or is something checking `screenLocked`
(the request, not the compositor ack) per the doctrine's own §on this
distinction.
**Next step:** trace the actual QML/service path proximity-clear takes on
wake and confirm whether it's touching lock state at all, or whether
something downstream is treating "display woke" as "session unlocked."
### 3. The physical power button itself — still not mapped end-to-end
We fixed the *logind* layer today (`HandleSuspendKey`/`HandlePowerKey` in
`logind.conf.d`), but what the button actually drives compositor-side —
screen-off vs. lock vs. suspend, and whether that's the same code path
proximity-wake hits — hasn't been traced. This is the original question that
opened tonight's investigation and is still open.
### 4. Why did `lock_cmd` fire in a tight loop on the laptop today?
Root cause of the crash-loop (stale hypridle config) is fixed, but *what was
triggering repeated lock calls in the first place* is not identified. Could
be an idle-timeout misconfiguration, could be something calling `loginctl
lock-session` directly. Worth a quick check next session so it doesn't
silently recur now that hypridle is back on a clean config.
---
## Also shipped today, unrelated to power/idle/lock
`souveraine-secrets` — a new `[[bin]]` in the souveraine crate: a Secret
Service D-Bus daemon (`org.freedesktop.secrets`) rooted in the existing
`SeedId` Ed25519 identity instead of gnome-keyring/KWallet, to fix Chatty's
Matrix cross-signing key not persisting across restarts. Loads the existing
seed only (`SeedId::load`, added as a pure addition to `seed.rs` — no
generation path, refuses to start if no seed exists, per explicit ETHOS
direction this session: identity creation stays deliberate/guarded, never
silent). DH-1024/AES-128-CBC OpenSession transport implemented against the
actual Secret Service spec + libsecret's `secret-session.c` (not memory).
Compiles clean, unit tests pass (DH round-trip, AES round-trip, existing
seed.rs tests untouched). **Not yet tested against a real D-Bus bus or
Chatty** — that's the next step, on the phone.

View file

@ -0,0 +1,193 @@
# Souveraine `surfaces/quickshell` — session-trust work dump (2026-07-14)
Branch: `experimental/session-trust-phase1`
Tip: `6532572` — all committed except `Audio.qml` (M) and `volumeMixer/` (untracked).
Scope: the QuickShell shell that runs on **both the Pixel 3 (blueline) and the laptop**.
---
## TL;DR — what landed today
Two things shipped in one day:
1. **Shell restructure — "retire the pill."** The separate pill nav shell is gone.
The shell now runs as one config (`qs -c souveraine`), with an owned first-party
lock/nav/session layer on top of the borrowed `ii` shell. Three guarded IPC
surfaces for the agent (`dock.*`, `shell.*`, `apps.*`), and a real IPC bug fixed
(returns were silently dropping their payload — see below).
2. **Session Trust Phase 1** — the security model. Step-up auth, lock-time response
redaction, break-glass override, sleep-before-lock coordination, and a
hash-chained audit trail. This is phase 1 of the SouveraineOS homogenization
(one shell, laptop + phone).
**Caveat for whoever this goes to:** the feature stabilized *right up to the last
commit*. Five consecutive QML-runtime fixes close the day, and the very last one
(`6532572`, 22:26) fixed a `ReferenceError` that was **crashing the lock-redaction
path itself**. So: freshly-green, on an `experimental/` branch, not yet
battle-tested. Verification checklist at the bottom is not all ticked.
---
## The core security model (from SESSION-TRUST-ARCHITECTURE.md + TRUST-BOUNDARY-MATRIX.md)
### Authorities we consume, never replace
| Question | Authority | Our role |
|---|---|---|
| Can this machine suspend/hibernate/poweroff/reboot? | logind `Can*` | query + expose + execute matching verb |
| Has the compositor secured the display? | `WlSessionLock.secure` | mirror as `screenLockSecure`; never infer from a button |
| Does the shell want its lock shown? | `GlobalStates.screenLocked` | maintain request state, persist across crash |
| Has the user authenticated? | PAM + lock's `LockContext` | start PAM; only it releases `WlSessionLock` |
| Why is idle sleep suppressed? | active idle mechanism | reason/cookie registry; report only applied mechanisms |
**The central invariant:** `screenLocked` (shell's *request*) and `screenLockSecure`
(compositor's *acknowledgement*) are kept distinct. A failed/delayed Wayland lock
shows as `lockRequested: true, locked: false` — never mistaken for success.
Personal-data disclosure gates on `screenLockSecure`, not on a button press.
### Capability tiers (one policy system for lock screen, media, and agent)
| Tier | Requirement | Examples |
|---|---|---|
| `ambient` | none | clock, weather, timers, transport controls, non-personal answers |
| `personal` | `!screenLocked && !screenLockSecure` (live check, not cached) | messages, calendar, memories, fleet state, history |
| `stepUp` | recent successful reauth (TTL window) | sending, pushes, deletion, payments, physical access |
On lock, in-flight personal/step-up agent output is withheld/redacted; only ambient remains.
### Trust boundary matrix — enforcement status
- **enforced:** lock/unlock, suspend/hibernate/poweroff/reboot (capability-probed),
idle+sleep inhibitors (reason required), all read-only projections, step-up PAM
conversation, personal-context send gate (`LockContentPolicy`), in-flight response
redaction on lock, media controls/metadata on lock, sleep transition
(delay inhibitor + `WlSessionLock.secure`), audit trail.
- **`session.unlock()` is refused over IPC by design** — the lock is the credential
gate; no IPC caller routes around the PIN pad.
- **gaps (not yet gated):** agent delete/push and physical-access ops —
`StepUpAuth` is built but the per-op `minTier` metadata isn't wired yet.
- Matrix is **local-only**: all callers are same-machine over the quickshell IPC
socket. If a network-reachable caller is ever added, every row needs re-evaluation.
---
## Commit-by-commit (chronological, full messages)
### `c78c551` 15:03 — quickshell: run as qs -c souveraine + dock/shell/apps method surfaces
Own config name composed by deploy.sh from our files + pristine-ii dir borrows; ii
tree stays untouched. One SouveraineFamily, two modes gated on `souveraine.phone`.
Three guarded IPC surfaces for the agent (via Souveraine's harness):
`dock.manifest/pin/unpin/restack`, `shell.surfaces/state`, `apps.list/get/find/categories`.
Refusals are result shapes, not errors. Settings split into per-domain pages.
### `cecde3b` 18:31 — quickshell: session arbiter + fix IPC returns silently dropping payloads
**The IPC bug is the important half.** Quickshell marshals exactly five types
(string/int/bool/double/color) and maps a `var` return to VOID, discarding the
payload with no error. `dock.*`, `shell.*`, `apps.*` were all `: var` → registered
as `(): void` and returned nothing. The `{ok, reason}` contract had **never once
reached a caller.** All now return JSON as a string.
Session: upstream fires `systemctl X || loginctl X` detached and throws the exit
code away — fine on a desktop, not on a phone where the shell *is* the session
manager. Now: probe once, run verbs through a Process that logs exit codes, refuse
honestly (swapless phone → hibernate says so instead of no-opping). Inhibits carry a
mandatory reason + cookie; `state()` lists who holds the machine awake and why.
`unlock()` refused by design. Named `sessionctl` (ii owns `session`).
### `e31c3aa` 20:00 — quickshell: first-party lock/nav/session layer, retire the pill
Session arbiter probes logind `Can*` over **busctl** (carries polkit tier
yes/challenge/na) — swapless phone reports hibernate `na` and refuses. busctl parsed
with **awk, not a sed regex** (the sed version returned nothing on the phone, left
every capability "unknown" — invisible on laptop where timing masked it).
Lock trust: `screenLocked` distinct from `screenLockSecure`; cards gate on secure.
`LockContentPolicy` centralizes ambient/personal/step-up tiers. New first-party
`modules/souveraine/`: LockMediaCard, LockSurfaceHost, SystemGestureRail. Pill
retired: `pill/shell.qml` + PillConfig gone → NavigationConfig + gesture rail.
Hyprland starts `qs -c souveraine` directly.
**Verified on phone:** session.* reports challenge/na, hibernate+unlock refuse,
inhibit round-trips with its reason.
### `4d5690b` 20:17 — quickshell: Idle & sleep settings page
Live idle-stage readout (polled over session IPC), native-coordinator toggle,
dim/lock timers (greyed while coordinator off). New `Audio.qml` service.
### `a8fd510` 20:48 — session trust phase 1: logind ingress, sleep inhibitor, step-up auth
The three highest-priority gaps. New: `SessionEvents.qml` (logind PrepareForSleep +
session Lock via gdbus monitor; delay-mode sleep inhibitor held from startup,
released only after `WlSessionLock.secure`), `StepUpAuth.qml` (PAM step-up, short-
lived in-memory grants per action family), `TRUST-BOUNDARY-MATRIX.md`. Modified:
IdleCoordinator (+Suspending/Asleep/Waking), Session.qml (sleep inhibitor kind,
stepUp/sleepInhibitor in IPC projection, boot-time IPC audit), Config (lock.stepUp),
LockConfig, deploy.sh.
### `61bdc53` 21:13 — session trust: step-up send gating + lock-time response redaction
`Souveraine.send()` returns `'step-up'` when auth required; `Ai.sendUserMessage()`
triggers `StepUpAuth.requestAuth('send')`, retries on success. Lock-time redaction:
`Ai.qml` watches `screenLocked`; on lock mid-stream, replaces displayed content with
`[content hidden until unlock]`, preserves `rawContent` for post-unlock.
### `377b829` 21:24 — break-glass: one-time emergency grant with journaling
`StepUpAuth.breakGlass(family, reason)`: mandatory reason (logged prominently),
60s TTL (not 5min), one-time (consumed on use), can't be issued while locked,
cleared on lock (fail-closed), tracked separately for audit. `send()` checks
break-glass before blocking on step-up; it bypasses PAM — user declared an emergency.
### `ae9ef41` 21:31 — hash-chained audit trail for session transitions
`SessionAudit.qml`: tamper-evident JSONL of all state changes (lock/unlock,
secure/insecure, idle transitions, PrepareForSleep, inhibitor lifecycle, step-up
success/failure, break-glass issued/consumed/expired, grant expiry/revocation).
Each entry carries md5 of the previous; sequence numbers detect gaps; chain validated
on startup. Uses `Qt.md5()` (no SHA256 in QML) — **advisory tamper-evidence, not
crypto.** Log: `~/.local/share/souveraine/session-audit.jsonl`.
### `ffe4157` 21:39 — settings: show sleep inhibitor state, update stage names
IdleConfig shows full stage names (incl. Suspending/Asleep/Waking), sleep inhibitor
held/released, stepUp grant TTL. **hypridle.conf changes are NOT in repo** (apply to
`~/.config/hypr/`): lock_cmd → `loginctl lock-session`; before_sleep_cmd REMOVED;
inhibit_sleep REMOVED; suspend_cmd prefers loginctl (SessionEvents owns these now).
### The closing five fixes (21:58 → 22:26) — it didn't run clean
- `10c206c` `Component.onCompleted` doesn't fire on QML Singletons → `Timer{interval:0}` in Session/SessionAudit/SessionEvents
- `bc08149` missing `pragma Singleton` on SessionAudit
- `e17f02b` missing `QtQuick` import (Timer) in Session
- `13cded0` Timer unavailable in a functions module → use Process
- `6532572` **Ai.qml missing `import qs` — GlobalStates ReferenceError was crashing lock redaction** (the security-critical path was down until this)
---
## Key mechanisms (how the important bits actually work)
**Sleep-before-lock (SessionEvents.qml):** one `systemd-inhibit --what=sleep
--mode=delay sleep infinity` held from startup. On `PrepareForSleep(true)`: if
already `screenLockSecure`, release immediately; else `Session.lock()` and wait for
`WlSessionLock.secure`, then release. Reacquire on `PrepareForSleep(false)`. Fail
policy: **fail-closed** — if not secure before logind's `InhibitDelayMaxUSec`
(~5s) deadline, leave a diagnostic and don't pretend it locked. Lock persists across
suspend/resume; only the human PIN pad unlocks. External `loginctl lock-session`
caught via a per-session-path gdbus monitor (echo-guarded against our own lock).
**Step-up (StepUpAuth.qml):** action families send/delete/payment/physical/admin.
Grant = `{granted, expires}`, 5min default TTL (`Config.lock.stepUp.grantTtlMs`).
One PAM conversation at a time. Backend: prefers `souveraine-pam-auth` helper
(password via env `SOUVERAINE_STEPUP_PASSWORD`, not argv), falls back to `pkcheck`
(polkit) at runtime. **The `/etc/pam.d/souveraine-stepup` file is NOT shipped**
root-owned, install/audit separately. Grants cleared on lock, session end, PAM
failure, expiry (30s coarse timer). Re-request within window does NOT refresh clock.
**Audit chain (SessionAudit.qml):** append-only JSONL, `md5(prev)` chained, seq
numbers for gap detection, validated on startup. Explicitly advisory, not crypto —
production should swap for sha256sum/dedicated binary.
---
## Verification status (from SESSION-TRUST-ARCHITECTURE.md)
- [x] `session.capabilities()` matches logind `Can*` — verified phone
- [ ] `session.lock()` reaches `locked:true` only after `WlSessionLock.secure` — needs timing on Pixel
- [ ] `loginctl lock-session` and suspend take same Wayland lock path
- [x] step-up grants can't survive lock/timeout/restart/family-change (StepUpAuth revokes on lock, 30s expiry)
- [ ] lock-during-personal-output hides it; lock-during-ambient-timer leaves timer — **redaction path only stopped crashing at last commit; re-verify**
- [ ] **delay budget (~5s) timed on Pixel 3** — required before this is a security claim, not yet done
## Open items for next session
- Wire per-op `minTier` metadata so agent delete/push/physical actually hit stepUp (built, not connected)
- Time the suspend delay budget on the Pixel 3
- Ship/audit `/etc/pam.d/souveraine-stepup`; confirm one polkit agent owns the session
- Verify native Wayland idle-notify on the Pixel compositor before flipping `nativeCoordinatorEnabled`
- Uncommitted: `Audio.qml`, untracked `volumeMixer/`

55
docs/HOW-IT-WORKS.md Normal file
View file

@ -0,0 +1,55 @@
# How the phone shell works
Three surfaces: **bar** (top), **dock** (bottom, above the navigation rail),
and Souveraine's integrated **navigation rail** (bottom edge).
Edit here → `./deploy.sh --phone` → restart. Deployed via symlink; live edits = git-tracked.
## Navigation rail (`modules/souveraine/navigation/SystemGestureRail.qml`)
- **Always visible.** `WlrLayer.Overlay` + `ExclusionMode.Ignore` + `margins.bottom:0`. Survives fullscreen, dock, OSK. Never lower the layer.
- **Owned by Souveraine.** It is loaded by `SouveraineFamily` on phone mode;
there is no second Quickshell configuration, external IPC hop, or Hyprland
layer rule to keep in sync.
- Gestures (MouseArea — pointer handlers don't get touch here):
- **double-tap** → toggles the active app's named `fullscreen` mode (whole
display, no border/gaps). Targets `Hyprland.activeToplevel.address` — not
hyprctl's focus (the tap focuses the shell).
- **swipe up** → reveals the dock.
- **swipe down** → dismisses the dock.
- **No keyboard on the rail.** OSK = 3-finger hyprgrass swipe only.
## Dock (`modules/ii/dock/Dock.qml`)
- **On `WlrLayer.Overlay`** so the navigation rail can reveal it over fullscreen apps.
- **Hidden = layer unmounted** (`visible:false`), not just tucked — else it paints over fullscreen.
- **Visibility** (`computeDockState`, first match wins):
1. fullscreen app on focused monitor → **Hidden** (unless rail-revealed)
2. pinned → **Pinned** (only state that reserves exclusive zone)
3. preview-hover → **Shown**
4. empty desktop / no focused app → **Shown**
5. else (normal app focused) → **Hidden**
- **Reserves the navigation rail height** at the bottom visually *and in its
layer-shell input mask*, so the dock cannot intercept rail touches.
- **Bar height is content-driven**: the window sizes itself to the button row (64px buttons) + row margin + rail height; `Config.options.dock.height` is only a floor. Don't tune the config height to "fix" icon clipping.
- **App list width is capped** to the screen (`DockApps.maxWidth`); past that the icon row scrolls horizontally by touch (flick is only enabled when overflowing, so drag-to-combine keeps working when everything fits).
- **DockStack renders with the same content block as DockAppButton** (icon + half-reserved dot strip, centered as one unit). Keep them structurally identical or they drift apart on the bar.
- Depends on **`GlobalStates.dockRevealed`** — lives in the surface tree (`GlobalStates.qml`, stock ii + patch) and the deploy manifest. If ii updates its GlobalStates, re-diff and re-apply the patch.
## souveraine-settings (planned, not built)
A future settings app for the phone shell. Planned sections:
- **Dock** — pinned apps, height floor, monochrome icons, ignored-app regexes (today: hand-edited in `~/.config/illogical-impulse/config.json`).
- **Stacks** — create/rename/reorder app stacks and their members (today: `dock.stacks` strings in config.json, or drag-to-combine on the dock).
The dock already reserves its entry points: the long-press menu's "App settings…"
and the `dockSettings` IPC target (`openApp(appId)` / `open()`) in `Dock.qml`.
Both are STUBS — they log and pulse the dock, nothing opens. That's deliberate:
the IPC name stays stable so the settings app can take it over without touching
the dock.
## Fullscreen API (Hyprland 0.55, Lua dispatch)
- Dispatch: `hl.dsp.window.fullscreen({ window="address:0x…", mode="fullscreen", action="toggle" })`.
- **`fullscreen`** = whole display, no borders or gaps. ← the navigation rail uses this.
- **`maximized`** = keeps the normal workspace layout margins.

View file

@ -0,0 +1,182 @@
# Phone shell — interface architecture
The phone has four primary interfaces the user actually touches: the **pill**,
the **dock**, the **keyboard**, and **touch/gesture**. This doc names what each
one is *meant to be*, what it is *today*, and the single seam where a hardcode
should become a small framework so the surface can grow without rewrites.
Principle: **clean beats feature-rich.** Each surface has one job and one place
its behaviour is tuned. A new feature should be a new entry in an existing
table, not a new hardcoded branch. Where a value is tuned in more than one
place today, that is the bug — not the value.
Truth hierarchy: this doc describes intent; `HOW-IT-WORKS.md` describes the
current wiring; the code is the reality. When they disagree, fix the code or
fix the doc in the same change — never leave a third story.
---
## 1. Pill — the always-on gesture bar
**Meant to be:** the one surface that is *always* reachable, on any app, in any
state. A thin, dumb input strip at the bottom edge that recognises a small
fixed vocabulary of gestures and forwards them — it owns no app state and makes
no decisions beyond "which gesture was this."
**Today (`pill/shell.qml`):** `WlrLayer.Overlay`, `ExclusionMode.Ignore`,
`margins.bottom:0`. Gesture arbitration is one MouseArea (double-tap, swipe up,
swipe down) → `dock` IPC. This part is clean and well-reasoned.
**The seam / the bug:** the pill's z-order guarantee is *documented but not
enforced*. `pill/shell.qml:23-27` sets namespace `quickshell:pill` and points
at "the `order` layerrule in hyprland.lua" — **that layerrule does not exist**
(hyprland.lua only has a commented-out generic `^my-overlay$` example). So
after an `ii` restart the pill can be buried under the dock and stop taking
touch — the exact failure the comment says is solved. Fix: add the real
`order` layerrule for `quickshell:pill`, or drop the promise from the code and
docs. Do not add more pill gestures until stacking is enforced — an always-on
surface that silently dies is worse than a plain one.
**Growth rule:** new pill gestures are new cases in the one MouseArea, each
forwarding to a *named* IPC target — never doing app logic inline.
---
## 2. Dock — the app switcher and stack surface
**Meant to be:** the surface that shows and switches running/pinned apps and
lets the user group them into stacks. It owns app-presentation state; it does
not own gesture recognition (the pill and hyprgrass feed it via IPC).
**Today:** interaction layer (`Dock.qml`, `DockAppButton.qml`, `DockStack.qml`)
is strong — drag-to-combine, fan-out arc, visibility state machine
(`computeDockState`). The weak layer is **state/config**:
- Stacks are a hand-parsed string `"stackId|appId,appId"` (`TaskbarApps.qml:25`).
A `|` or `,` in a name corrupts it silently; parsed/encoded in ~6 places.
→ move to **one JSON blob per entry** (array-of-strings the adapter tolerates,
but each string is JSON): kills the delimiter-corruption class, keeps the
adapter happy. This is the agreed direction.
- Stack ids are count-based (`nextStackName` = `"Stack " + (count+1)`,
`:87`) → collide after a delete, and `id` is the lookup key. Two stacks can
share an id and cross-contaminate members. → stable uid, display name
separate from id.
- Combining into a new stack suppresses the target's standalone pin at *render*
time (`apps` getter) but never removes it from `pinnedApps` in config
(`:99-108`) → the pin lingers; unstack works by luck. → remove-on-combine so
config matches what's shown.
- Tap-cycle `lastFocused` index (`DockAppButton :196`) is never reconciled when
windows open/close and is also written by hover (`:180`) → tapping can focus
the wrong window. → clamp/reconcile against the live toplevel model.
- Magic numbers live in the components: `pillStripHeight 32` (`Dock.qml:161`),
dwell `500` (twice: `DockAppButton:133`, `DockStack:116`), double-tap `350`,
arc geometry (`DockStack:33-36`). → lift into `Config.options.dock` next to
the `height`/`monochromeIcons`/`hoverRegionHeight` that already live there.
- `reorderStackMember` (`TaskbarApps:113`) is dead — superseded by
`setStackOrder`. → remove, or wire the menu reorder it was meant for.
**The seam:** `Config.options.dock` already exists — the framework anchor is
already there. "More structure" here means *finishing* that namespace (all
tuning knobs + a real stack schema under it), not inventing a new one.
**Growth rule:** dock feel/config knobs go under `Config.options.dock`; stack
members are structured entries, not delimited strings. Rename + ungroup are
table-stakes and should exist before any fancier stack feature.
---
## 3. Keyboard (OSK) — squeekboard
**Meant to be:** an on-screen keyboard that appears on text focus and can be
summoned by one deliberate gesture, driven through **one** IPC target so its
visibility and any focus-grab shield always move together.
**Today:** squeekboard, launched from `hyprland.lua`, auto-shows via
input-method-v2, toggled by 3-finger swipe-up routed through ii's `osk` IPC.
The *routing* is right (single `osk` target).
**The seam / the drift:** the comments and the retained
`quickshell-ii-patches/OnScreenKeyboard.qml` still describe **wvkbd**
(retired 2026-07-10) and its focus-grab shield — the `osk` IPC now drives
squeekboard, but the code comments and the history-only patch file still tell
the wvkbd story. Same doc-vs-reality drift as the pill layerrule. → update the
comments to squeekboard reality; confirm whether `OnScreenKeyboard.qml`'s
GlobalStates.oskOpen plumbing is still the thing `osk toggle` hits, or vestigial.
**Growth rule:** one `osk` IPC target owns show/hide; layout/theme is
squeekboard config, addressable later from souveraine-settings. No second path
to toggle the keyboard.
---
## 4. Touch / gesture — the input spine
**Meant to be:** one routing layer that maps a physical touch gesture to a
named interface action. Today this is the messiest surface because it is spread
across four dispatch styles.
**Today (`hyprland.lua`):**
- `hl.gesture{fingers=3,horizontal}` → workspace (Hyprland-native)
- `hl.plugin.hyprgrass.bind{...}` → close / terminal / edge-swipes →
`qs -c ii ipc call <target>`
- the pill's own MouseArea → `dock` IPC
- `touchdevice.output = "DSI-1"` binds touch to the panel for rotation
Two input sources (pill swipe-up, hyprgrass edge-swipe-up) both drive the dock;
no single arbiter. Gesture → action bindings are inline `exec_cmd` strings
scattered through the file.
**The seam:** this is the memory note "don't hardcode bindings blindly; need a
routing system to avoid overlap." The clean form is **one gesture table**
edge/finger/direction → named IPC target — read top-to-bottom so overlaps are
visible in one place. Every binding already ends in a `qs ... ipc call <target>`;
the framework is just making that table explicit and single-sourced instead of
sprinkled through `hyprland.lua`.
**Growth rule:** a new gesture is a new row in the gesture table pointing at a
named IPC target. Two rows may not target the same action from different edges
without being adjacent in the table (so overlap is obvious).
---
## Cross-cutting: the one recurring defect
Three of four surfaces have the **same** flaw — *a documented guarantee the
code doesn't enforce*: the pill layerrule (doc says pinned, code doesn't pin),
the keyboard (comments say wvkbd, code runs squeekboard), the dock stack pin
(render says suppressed, config says still pinned). Cleaning these up is mostly
making the code match its own stated contract, then giving each surface exactly
one place it's tuned.
## Status (2026-07-13)
1. ~~Pill layerrule~~ DONE — `pill-above-dock` rule added to hyprland.lua
(`order = 1` on `^quickshell:pill$`). VERIFY ON GLASS: `order`
passthrough in the Lua layer_rule wrapper is untested on this build.
2. ~~Gesture table~~ DONE — hyprgrass binds are one `touch_gestures` table
in hyprland.lua; the two other gesture sources (hl.gesture workspace
swipe, the pill's MouseArea) are listed in its header so overlap stays
visible in one read.
3. ~~Dock state layer~~ DONE — JSON-per-entry stacks (legacy pipe entries
still parse, rewritten on next write), minted `stack-N` ids that never
reuse after delete, name separate from id (+ `renameStack()`, no UI
yet), pin actually removed from config on combine, tap-cycle starts
from the truly focused window, dead `reorderStackMember` removed,
`dragDwellMs`/`pillStripHeight` lifted into `Config.options.dock`.
4. ~~Keyboard~~ DONE — hyprland.lua comments tell the squeekboard story;
`OnScreenKeyboard.qml` (the "osk" IPC owner) moved into this surface +
deploy manifest. Remaining hand-deployed patches (BarContent, Network,
QuickSliders, switchwall.sh) noted in overlays/QUICKSHELL-MOVED.md.
## Open decision — two-stage swipe-up was removed, not lost by accident?
Souveraine commit `6e21c50` ("resume agent conversations from server")
reworked the dock state machine: the Peek state, `dockRevealPulse`, and the
two-stage swipeUp (reveal → escalate to overview) all went away in favor of
a flat reveal/hide contract — under a commit title that says nothing about
the dock. Pixel3Arch CLAUDE.md still records two-stage as SHIPPED
(2026-07-11). The replacement comment is articulate, so this reads as a
deliberate redesign, but it needs Casey's confirmation: keep the flat
contract (overview = the dock's apps button), or restore the escalation.
Comments in hyprland.lua/HOW-IT-WORKS now describe the flat behavior that
actually runs.

98
docs/README.md Normal file
View file

@ -0,0 +1,98 @@
# Souveraine quickshell surface
The desktop shell as a Souveraine surface — the primary visual frontend for
SouveraineOS, with the TUI remaining the dive-in instrument.
## Architecture
- `services/Souveraine.qml` — the substrate singleton. The ONE server
connection every shell module hangs off: agent inventory, conversation
lifecycle, the SSE turn stream (raw events re-emitted via
`streamEvent(var)`), the backchannel (`cancelTurn()` / `interject(text)`),
and the desktop sensorium — every send carries ambient context (active
window, open apps, cursor position) so she perceives the room she is being
spoken to in. Device sensors (SouveraineOS positional data from the Pixel
3 kernel path) extend `collectAmbient()`.
- `services/Ai.qml` — ii-compat adapter. Keeps the API the illogical-impulse
sidebar expects; owns no transport. Shapes wire events into the message
objects the existing chat UI renders.
- `modules/` (coming) — presence (portrait PNGs from memfs, posture state
machine), cockpit (subconscious pane), agents (masterdetail manager),
settings, schedules. Each subscribes to the Souveraine singleton.
## What changes
- "Models" in the sidebar are **Souveraine agents** (`GET /v1/agents`).
Picking one starts a conversation with that agent — memory, sensors,
subconscious and all.
- Messages stream over the server's SSE endpoint
(`POST /v1/conversations/:id/messages`), authenticated with the agent's
bearer token from `~/.souveraine/server/agents/<id>/api_token`.
- Subconscious **surfacings**, **reflection**, and **archivist** pressure
render in the chat as interface notes (dedicated widgets later).
- Reasoning and sensor activity render inside collapsible `<think>` blocks.
- Keys/providers/temperature are owned by `souveraine.toml` — the sidebar's
`/key` and `/temp` commands now just point there. The keyring path is dead.
- Token pressure is fetched after each turn from
`GET /v1/conversations/:id/tokens`.
## Portability (KDE / non-Hyprland)
`Souveraine.qml` itself is compositor-agnostic: quickshell runs on any
wlroots-ish Wayland compositor and KWin; window sensing uses the
foreign-toplevel protocol (KWin implements it); the cursor read tries
`hyprctl`, then `kdotool`, then degrades to nothing — ambient never blocks
a send. Server autostart is desktop-neutral (systemd user unit, nohup
fallback), so opening any surface summons her.
What is NOT portable yet is the chrome: the chat UI is illogical-impulse's
sidebar. The path for "I run KDE, can I use this?" is a standalone
quickshell config (own ShellRoot + a window hosting the chat/presence
modules) that ships `Souveraine.qml` unchanged — planned once the modules
stop being ii-embedded. Same service, same mappings, different shell.
## Deploy
```bash
./deploy.sh # backs up upstream Ai.qml, symlinks ours in
./deploy.sh -u # restore upstream
```
Requires the server: `souveraine server` (default http://127.0.0.1:8484,
override with `ai.souveraineUrl` in the ii config).
## Wire contract
The server's SSE layer is a full mirror of `BackendEvent` (see
`src/api/models.rs::StreamEvent` — exhaustive `From` impls both ways, so a
new engine event is a compile error at the seam, not a silent skip). The
surface consumes the personification channel: subconscious tokens buffer and
flush as one bubble when the N+1 pass ends (`subconscious_pass`), halts land
as body signals, interstitials render by register (cenno = quiet aside,
her_voice = gutter passage), `primary_complete` releases the input while the
stream stays open for the subconscious, and `context_pressure` drives the
live token counter. `atmosphere`/`outfit`/`itinerary` are logged, awaiting
their shell-chrome layer.
Server-side, the backchannel and verbs exist for every surface:
`POST /v1/conversations/:id/cancel` (interrupt, `*[raised hand]*`
semantics), `.../interject` (mid-turn notes, queued between turns),
`GET .../messages` (transcript backfill), `POST .../fork` (`/btw`
side-quests). `SendMessageRequest.ambient` injects the sensorium note.
RemoteBackend rides all of it, so TUI remote mode gained cancel/interject/
fork/resume in the same stroke.
## Not yet wired
- Sidebar UI hooks for cancel (Esc) and interject (type-while-busy) — the
service functions exist, the ii chat input doesn't call them yet
- Conversation resume in the sidebar (server verb exists; surface always
starts fresh)
- Atmosphere/outfit/itinerary driving actual shell chrome (events arrive;
modules pending)
- File/image attachments (server has an image path; surface doesn't use it yet)
- Regenerate (Souveraine conversations are forward-only by doctrine)
- "Blank LLM mode" — a memoryless passthrough agent for throwaway questions;
needs a server-side agent flavor first
- Dedicated widgets for surfacing/subconscious bubbles instead of interface
notes

View file

@ -0,0 +1,176 @@
# Lock, idle, and wallpaper reference extraction
This is a design extraction, not a dependency list. Souveraine should retain
one session authority across desktop, laptop, and phone; form factor changes
the chosen layout and cards, never the meaning of lock, idle, or auth.
Migration rule: ii is a temporary compatibility substrate, not a source of
new architecture. New behavior belongs in `surfaces/quickshell` under a
Souveraine-owned module or service. Borrowed code is replaced deliberately,
with the replacement's contract documented before the borrowed file leaves.
## What the active shell has today
The phone lock has a credential keypad, fingerprint attempt, battery state,
and guarded power actions. It does **not** yet have lockscreen media,
notifications, timers, weather, calls, agent output policy, a staged idle
coordinator, or a real sleep-inhibitor backend. `Idle.qml` currently controls
hypridle because the tested invisible Wayland idle inhibitor is not honoured
on the Pixel 3 compositor build.
That is a sound small base. It is not yet a phone-grade lock surface.
## Ideas to extract
| Source | Keep | Do not inherit |
| --- | --- | --- |
| Sailfish/Lipstick lockscreen | A lock screen as layered/pannable content: glance surface, credential surface, and event surface. Wake/display-off are separate from lock. | Its device-specific Lipstick and DeviceLock plumbing; source provenance must be audited before copying. |
| Glacier Home | A compact MPRIS card with capability-gated previous/play/next controls; media is loaded only if a player exists. Clock, media, keypad, and notifications are separate components. | Its older Nemo/Amber APIs and visual code verbatim unless copyright notices travel with it. |
| DankMaterialShell | Independent idle monitors for dim/DPMS/lock/suspend, explicit re-arming, AC-vs-battery thresholds, and a wake monitor after screen-off. | Treating its shell state as authority or blindly importing its settings model. |
| ii/iNiR | MPRIS card mechanics, downloaded artwork, palette adaptation, and image "least busy region" placement. ii already calculates content placement against wallpaper pixels. | The current one-file wallpaper assumption and arbitrary widget dimensions. |
| Ambxst | Screen capture at lock, short-lived credential handling, failure lockout feedback, and listening to logind Lock/PrepareForSleep events. | Its AGPL code, polling `axctl` layer, and its separate lockscreen authority. Concepts only. |
DankMaterialShell and iNiR are MIT; Glacier carries MIT/BSD notices. Ambxst is
AGPL and Vast is GPL. Do not copy from the latter two into this repository
without intentionally accepting their licence obligations. Sailfish files in
the local research extraction need per-file provenance review before reuse.
## One lock surface, composable cards
The lock surface should be a host with slots rather than a new shell for each
device:
```
WlSessionLockSurface
└─ LockSurfaceHost
├─ glance slot clock · date · battery · weather
├─ continuity slot timer · ongoing call · navigation · media transport
├─ event slot notification summaries / previews
├─ credential slot PIN/password/fingerprint PAM conversation
└─ ambient-agent slot explicitly safe question/answer cards
```
The host chooses a layout profile:
| Profile | Layout |
| --- | --- |
| phone portrait | One vertical glance surface; a swipe/press brings up credentials; continuity cards occupy the lower third. |
| laptop | Center credentials with a side or lower card rail; multi-monitor cards remain local to their screen. |
| desktop | Center credentials and a sparse, large-screen card rail; no phone gesture assumptions. |
Each card declares a tier and a lock-surface visibility policy. This prevents
an attractive card from silently becoming a data leak.
| Tier | Suitable cards | Rule |
| --- | --- | --- |
| ambient | clock, battery, weather, timer, transport buttons, an ongoing-call *indicator* | no user data beyond the explicitly permitted setting |
| personal | track title/art, notification body, calendar title, agent conversation, call identity | hidden until unlock unless the user opts that field into ambient |
| step-up | send/reply/delete/purchase/unlock a physical thing | never performed by a card tap alone; needs a recent step-up grant |
An ambient agent is not a diluted personal agent. It has a separate tool and
memory allowlist, cannot read conversation history or sensors, and its output
is labelled ambient. If the lock starts while a personal response streams, the
host immediately withdraws it.
## Idle is a transition graph, not a timer
The useful DMS extraction is the vocabulary and re-arm discipline. The actual
authority remains Wayland idle protocols plus logind:
```
active ─idle timeout→ dimmed ─timeout→ lock-requested
WlSessionLock.secure
lock-secure ─timeout→ display-off
│ │
PrepareForSleep(true) ─────────────────────────────────────┘
suspending → asleep → waking → active
```
Input can cancel dimming and display-off before lock. It cannot cancel an
already secure lock; credentials do that. Every transition has an event source
and an observable result. `screenLocked` is the request; `screenLockSecure` is
the compositor acknowledgement.
The implementation sequence is:
1. Add one `IdleCoordinator` that owns the staged state and form-factor
thresholds. It consumes native `IdleMonitor` events when verified on the
target; the Pixel can retain the hypridle adapter behind that interface.
2. Add a logind event watcher for this session's `Lock` and the manager's
`PrepareForSleep` signal.
3. Hold a delay-mode sleep inhibitor while the shell is running. Release it
only after the Wayland lock is secure; re-acquire it on wake. Measure the
actual logind delay budget on the Pixel 3.
4. Make `idle`, `sleep`, `logout`, and `user-switch` explicit inhibitor kinds.
Only expose a kind after it owns a real backend. `idle` is the sole
implemented kind today.
## Wallpaper should be an asset, not a path
The current background path gives every output the same image and asks crop to
solve incompatible aspect ratios. A downloaded landscape image consequently
loses its subject on the phone. Preserve originals, but track a portable asset
record:
```
id, originalPath, origin, licence, palette,
focalPoint(x,y), safeRegions[],
variants: { landscape, portrait, square },
previewPath, generatedAt
```
Selection order for a screen is:
1. Use an explicit per-device assignment when the user set one.
2. Select a sufficiently close aspect-ratio variant.
3. Crop a derived local variant around `focalPoint` while preserving a
`safeRegion`; never overwrite the original.
4. Fall back to a blurred/palette background with the image contained, rather
than crop the only meaningful part away.
ii's existing least-busy-region analysis is useful twice: place a clock/card
where it does not cover the subject, and propose an initial safe region.
However, it must run against the **actual output crop** and card dimensions,
not a fixed 300×300 desktop widget. A manual focal-point editor is the escape
hatch; an analyser may propose, never silently decide.
For the lock surface, prefer a frozen screencopy or an already-derived local
variant over downloading or decoding a remote wallpaper at lock time. Locking
must remain instant and work offline.
## Proposed Souveraine map
```
modules/common/functions/Session.qml lifecycle verbs + IPC projection
services/SessionEvents.qml logind Lock / PrepareForSleep ingress
services/IdleCoordinator.qml transition graph + target adapters
services/LockContentPolicy.qml card tier / field visibility decisions
modules/ii/lock/LockSurfaceHost.qml shared card host
modules/ii/lock/cards/ Clock · Media · Notifications · Continuity · AmbientAgent
services/StepUpAuth.qml independent PamContext + expiring grants
services/WallpaperAssets.qml asset records, variants, safe regions
```
None of these are separate session managers. They are consumers of the single
`session` lifecycle and its state projection.
## Build order
1. **Truth before visuals:** logind event ingress, delay inhibitor, and
`IdleCoordinator` with a visible transition log. [done — SessionEvents.qml, delay inhibitor in SessionEvents, IdleCoordinator extended with sleep states]
2. **First lock cards:** clock/date, battery, transport-only MPRIS. Add the
metadata privacy switch before title/artist/artwork.
3. **Continuity:** timers, ongoing call, navigation, and notification summary
cards with per-field redaction.
4. **Trust:** ambient-agent allowlist, lock-time response redaction, then
`StepUpAuth` and action-family grants. [partially done — StepUpAuth.qml built, minTier wiring and ambient-agent allowlist remain]
5. **Wallpaper assets:** per-device assignments, focal-point editor, derived
portrait/landscape variants, and crop-aware card placement.
The first useful vertical slice is therefore not a huge lockscreen redesign:
one shared `LockSurfaceHost`, a transport-only media card, a real staged idle
state projection, and a wallpaper asset descriptor. It proves the architecture
on both shapes without committing personal data to the lock surface.

View file

@ -0,0 +1,307 @@
# 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
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:
- `locked` comes from `LockedHint` / `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-v1` so 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."
1. Take a standing delay-mode sleep inhibitor at shell startup.
2. Watch `PrepareForSleep` via a persistent `gdbus monitor --system` process,
parsed line-by-line — **not** polling. Polling latency eats the delay budget
directly.
3. On `PrepareForSleep(true)`: drive the lock, wait for `WlSessionLock.secure`,
release the delay inhibitor only then.
4. 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-manager` fragment watching `PrepareForSleep` or holding its own
inhibitor produces double-locks and races that look like our bug but are two
authorities colliding. Audit `systemctl --user list-units` and the session bus
on both machines before trusting the suspend race.
- **Scope portals deliberately.** `xdg-desktop-portal-wlr` already 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
`IdleMonitor` and `IdleInhibitor` report 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.

View file

@ -0,0 +1,104 @@
# Session and interaction trust model
This is the contract for the Souveraine shell on laptop and phone. It records
the boundary between orchestration we own and the Linux/Wayland authorities we
must consume rather than replace.
## Authorities and projections
| Question | Authority | Souveraine's role |
| --- | --- | --- |
| Can this machine suspend, hibernate, power off, or reboot? | logind `Can*` methods | Query and expose the answer; execute the matching logind/systemd verb. |
| Has the compositor secured the display? | `WlSessionLock.secure` | Mirror it as `screenLockSecure`; never infer it from a button press. |
| Does the shell want its lock surface shown? | `GlobalStates.screenLocked` | Maintain this request state and persist it across a shell crash. |
| Has the user authenticated? | PAM and the lock's `LockContext` | Start a PAM conversation; only it may release `WlSessionLock`. |
| Why is automatic idle sleep suppressed? | The active idle mechanism | Keep a reason/cookie registry, but report only mechanisms that are actually applied. |
`screenLocked` and `screenLockSecure` must stay distinct. The first closes
ordinary shell surfaces immediately. The second is the only proof suitable for
personal-data disclosure. A failed or delayed Wayland lock is therefore
visible as `lockRequested: true, locked: false`, not mistaken for success.
## Session IPC
All structured IPC results are JSON strings. Quickshell IPC only marshals
primitive QML types, so `: var` silently becomes `void` and loses its payload.
`session.lock()` means “the Wayland lock was requested”; it does not claim
the compositor is secure yet. `session.state()` exposes the subsequent
`locked` acknowledgement and the most recent asynchronous power-action result.
Power verbs return `status: "started"` when their command has started; a later
exit is available in `lastAction`. Refusals always log and return a reason.
The current public inhibitor surface implements `idle` and `sleep`. It is
intentionally wrong to accept `shutdown`, `logout`, or `user-switch` until each
owns a real mechanism. The eventual contract is:
| Kind | Backend | Status |
| --- | --- | --- |
| `idle` | Native Wayland idle inhibitor once verified on both targets; until then the tested hypridle control path. | Implemented. |
| `sleep` | A live logind/systemd inhibitor FD or `systemd-inhibit` process, held for the cookie lifetime. | Implemented via SessionEvents delay-mode inhibitor. |
| `logout`, `user-switch` | Shell policy checks at those operations, not fake logind inhibitors. | Not yet implemented. |
Every inhibitor must have a non-empty human reason, a cookie, and a visible
holder in `state()`. Releasing an unknown cookie is a refusal.
## Lock and suspend sequencing
The next lifecycle change is not another cached state machine. It is a logind
event ingress, implemented in `services/SessionEvents.qml`:
1. [done] Subscribe to the current logind session's `Lock` signal and request the
Wayland lock on receipt. This makes external `loginctl lock-session` calls
meaningful without assuming logind implements a screen locker.
2. [done] Hold one delay-mode sleep inhibitor from shell startup.
3. [done] On logind `PrepareForSleep(true)`, request the Wayland lock and wait for
`WlSessionLock.secure`; release the delay inhibitor only then.
4. [done] Reacquire it after `PrepareForSleep(false)`.
The delay budget is logind's `InhibitDelayMaxUSec` (commonly five seconds), so
this must be timed on the Pixel 3 before it becomes a security claim. The
failure policy is fail closed: if the lock is not secure before the deadline,
leave a diagnostic event and do not pretend the session was locked.
## Interaction capability tiers
The lock screen, media controls, and agent are one policy system, not three.
Each operation and each response is labelled with a minimum tier:
| Tier | Requirement | Examples |
| --- | --- | --- |
| `ambient` | None | clock, weather, timers, transport controls, non-personal answers |
| `personal` | No lock is requested and the compositor is not secure (`!screenLocked && !screenLockSecure`) | messages, calendar, memories, fleet state, conversation history |
| `stepUp` | Recent successful reauthentication | sending, pushes, deletion, payments, physical access |
Media metadata is `ambient` by default but must be configurable to `personal`.
When the lock occurs, in-flight personal or step-up agent output is withheld or
redacted; only ambient output remains visible.
Step-up authentication is a separate `PamContext`, using a dedicated PAM
service such as `souveraine-stepup`. It never unlocks the session and it never
accepts a boolean from an agent as proof. A successful result mints a
short-lived, in-memory grant bound to the local action family. The grant must
be cleared on lock, session end, PAM failure, and expiry. The exact freshness
window is a deliberate policy setting, not an implementation accident.
Implemented in `services/StepUpAuth.qml`. The PAM service file
(`/etc/pam.d/souveraine-stepup`) is NOT shipped by the shell — it is
root-owned system configuration and must be installed/audited separately.
Before enabling it, verify that one polkit agent owns the session and that
no legacy desktop power manager is competing for idle or sleep policy.
The trust boundary matrix is documented in `TRUST-BOUNDARY-MATRIX.md`.
## Required verification
- `session.capabilities()` matches logind `Can*` on laptop and phone.
- `session.lock()` reaches `locked: true` only after `WlSessionLock.secure`.
- `loginctl lock-session` and suspend both take the same Wayland lock path.
- Unsupported inhibitors refuse; each supported cookie visibly changes its
mechanism and is released exactly once.
- A lock during personal agent output hides it; a lock during an ambient timer
leaves only the timer visible.
- Step-up grants cannot survive lock, timeout, shell restart, or action-family
changes. [done — StepUpAuth revokes on lock, expiry timer runs every 30s]

180
docs/SETTINGS-APP-PLAN.md Normal file
View file

@ -0,0 +1,180 @@
# Souveraine shell — plan (2026-07-13)
## What this is now
`qs -c souveraine` — our own quickshell config, composed by `deploy.sh`:
our files (shell.qml, SouveraineFamily, services/, modules/settings/) are
symlinked from this repo; untouched ii directories are borrowed as whole-dir
symlinks; directories where we override any file are composed file-by-file.
ii's tree stays pristine — no overlay symlinks, no `.upstream` backups. We
step on end-4's toes nowhere; we borrow deliberately and vendor per-directory
only when a reason forces it.
`SouveraineFamily.qml` is the convergence scoreboard: one family, both modes
(desktop + phone). Panels that differ by form factor get an
`extraCondition: Config.options.souveraine.phone` gate; a panel is
"homogenized" when its gate is deleted. No `panelFamily` switching — the
config loads SouveraineFamily, period.
Live on laptop + phone as of 2026-07-13. Hyprland flipped: laptop via the
`qsConfig` var, phone via sed across its 4 hardcoded `qs -c ii` sites
(backup: `hyprland.lua.bak-souveraine-config`).
### The Settings app
`souveraine/settings-phone.qml` — purpose-built mobile shell (list → push →
back, 68px finger rows, fullscreen, no titlebar), launched by
`settings-launch.sh` (imports compositor env from the user manager) via
`souveraine-settings.desktop` in the phone's app grid. Page registry shape
`[{name, icon, component}]` is identical to desktop settings.qml, so pages
are interchangeable. Pages edit `Config.options` (JsonAdapter → config.json,
hot-apply + persist).
Current pages: Device (souveraine.phone flag + future per-device overrides),
Lock screen, Dock, Pill, Keyboard, About (ii's stock page verbatim).
## Doctrine — "tie back down"
Two tiers, nothing app-private:
1. **Shell settings**`Config.options.*` → config.json. Idempotent,
survives redeploy, diffable. Zero new persistence.
2. **System settings** → the owning daemon, live: NetworkManager, ModemManager
(mmcli -J), bluez, UPower, brightnessctl. The app is a *view* over system
state — kill it and the system is still the truth. No shadow copies, no
sync jobs. Privileged writes via polkit (`pkexec` — setuid fixed
2026-07-13, Pixel3Arch gap #7), zero sudo.
## Upstream drift — the decision (2026-07-13)
Our overrides were forked against the **phone's vintage** of ii. The laptop
runs a **newer** ii. Drift is large and one-directional. The question is not
"rebase everything" — it's "which upstream additions do we actually want,
and which are desktop features we're deliberately not running on a
phone-first shell."
**Laptop-upstream additions we do NOT have:**
- `background.widgets.{visualizer,stats,systemResources}` — desktop widgets
(the pctrade-style widget system landed upstream). Phone doesn't use these.
BUT: borrowed `Background.qml` reads `Config.options.background.widgets.*`
unconditionally → TypeError spam on laptop. **Fix: backport the keys into
our Config.qml (small surgical add), don't pull the widget machinery.**
- `claudeUsage` — Claude Pro/Max subscription gauge in the bar. Want later.
- `fileSearch` — indexed file search in the launcher. Want later.
- `sidebar.width`/`widthExtended`, AiChat `fontSize` live-binding. Want.
- `dpmsTimeout`, `unlockHook`, `autoIdleInhibit`, `termBgTone` — small knobs.
- GlobalStates super-press timing (`superPressTime`,
`shouldSuppressSuperReleaseSearch`) — press-and-hold-super suppresses
search. Conflicts with our `dockRevealPulse` patch (both touch the
super-release block). **Rebase needed eventually; not breaking anything
today.**
- Ai.qml: OpenAI Responses API strategy, `thoughtSignature`, FileView chat
save. Our Ai.qml is heavily customized (no hardcoded models, Bifrost
server, agent/resume) — **rebase here is the dangerous one; do carefully
or not at all.**
**What we have that upstream DROPPED:**
- `Config.options.dock.stacks` (fan-out stacks, "stackId|appId,appId") —
upstream removed it. Our `DockStack.qml` arc feature is ours alone now,
not just forked. Keep.
### Drift action items (priority order)
1. **Backport `background.widgets.*` keys into our Config.qml** — kills the
laptop TypeError spam. Small, surgical, no widget machinery pulled in.
2. GlobalStates.qml — rebase `dockRevealPulse` onto upstream's super-press
rewrite. Both touch the same block.
3. Ai.qml — decide: carry our customizations forward on the old base (safe,
current path) or rebase onto upstream's Responses-API Ai.qml (risky, gets
us thoughtSignature + FileView). Defer until we want one of those.
4. SidebarLeft/Right/Overview — dedupe the OSK flee-fix (15 lines triplicated)
into one place after any rebase.
5. Don't rebase dock/osk/TaskbarApps unless a specific breakage forces it.
## Pages still to build (deferred, "in time")
- **Display** — brightness (Brightness service), screen toggle
(`blueline-screen-toggle`), idle/suspend timeouts. hypridle.conf is
user-owned: regenerate whole from template, don't sed.
- **Network** — WiFi via existing Network service; **Cellular** needs a new
`services/Cellular.qml` wrapping `mmcli -J` (data toggle, APN, signal,
operator, SIM PIN). The real new work.
- **Bluetooth** — BluetoothStatus exists, but org.bluez activation fails on
the phone (journal, every boot). Fix the service before building UI on it.
- **App health check** — surfaced as a wanted page; not built.
- **Dock pins/stacks editor** — list editor with rename; consumes the staged
`renameStack`/`stackName` plumbing in TaskbarApps/DockStack.
## Other open items
- Phone cold-boot verification (autostart line flipped, unproven until next boot).
- `souveraine.phone` defaults false; Device page exposes the toggle but
deploy.sh --phone doesn't flip it. Tying it down on phone deploy would
satisfy idempotency doctrine.
- Services rename to be more "souveraine-esq" — deferred.
- Licensing sweep — see ~/Projects/MAKE_PROPER_LICENSES_FOR_ALL_PROJECTS.md.
## Ecosystem — the framework basics (2026-07-13)
Reference studied: `PostMarketOS-Blueline/references/phosh` (full source
checkout). Phosh's shell is built on three primitives we can learn from
without copying its C: `PhoshLayerSurface` (every visible thing subclasses
it), the zwlr_layer_shell_v1 layer it sits on (BACKGROUND/TOP/OVERLAY), and
`zphoc_stacked_layer_surface_v1` — phoc's extension for ordering
layer-surfaces above/below each other *within* a layer (bare wlr can't).
Plus a `PhoshState` bitmask (NONE / MODAL_SYSTEM_PROMPT / BLANKED / LOCKED /
SETTINGS / OVERVIEW) that the shell reads to gate layer visibility —
`use_top_layer = !phosh_shell_get_locked()` is the canonical line. State
drives layer visibility, not the reverse.
Note: `PhoshDockedManager` is NOT "the dock" in our sense — it's *hardware*
docking (phone → external display: disables OSK, stops auto-maximize, flips
`is-phone`). It's the system-level analog of our `souveraine.phone` flag:
the phone-becomes-desktop transition. They rhyme; they aren't the same
feature.
### What we're actually building (three pieces, compose incrementally)
1. **Layer registry + ShellState** — declare each surface's layer and
stack-order explicitly, driven by a state bitmask. GlobalStates is
*almost* this already (it has the state bits) but doesn't own the
layer/stack declarations. Quickshell gives us layer-shell via
`PanelWindow` anchors + exclusive zones — same protocol as Phosh, QML
binding not C. Steal the model, not the code.
2. **Live manifest per surface** — the dock (then each major surface)
projects its current state as a queryable structure: pinned apps,
stacks, positions, visibility, mode. This is a *service*, not UI —
`dock.manifest``{pinned, stacks, hidden, mode}`. The dock already
holds this state internally; the manifest is the projection for
external consumers.
3. **Method abstraction for the agent** — the agent does NOT get a new
toolcall integration. Souveraine is already a fully-fleshed harness
with its own integration state; we use THAT. What lives on the dock
side is a small method surface (`dock.pin(appId)`, `dock.restack(...)`,
`dock.reveal()`) with **state checks baked in** — validate inputs,
refuse mutation when the dock is in a state that forbids it (locked,
mid-drag), return real results. The agent calls a guarded method, not
a footgun; it genuinely can't mess up the dock through the abstraction.
### Where the lessons live — Souveraine School
The *teaching* of these methods — what they are, when to use them, the
lessons — is NOT inline in the dock or the agent. It lives in the
Souveraine School, taught to agent and human in proper time. The dock's
job is to expose the abstraction; the School's job is to teach it. Two
separate concerns; do not conflate.
### First artifact
The **dock manifest** is the smallest concrete first step: the dock is
built, working, and already has internal state (pinned apps, stacks,
reveal/pulse). Projecting it into a queryable manifest + guarded method
surface proves the pattern. The layer registry generalizes it after.
### Reference material on disk
- Phosh source: `~/Projects/PostMarketOS-Blueline/references/phosh` (full
C/meson checkout, read-only reference — do not edit).
- end4-pC fork (pctrade): `~/Projects/end4-pC` — studied for the
panelFamilies + background-widget patterns; not copied.
- Pure Maps (Kirigami/QML nav) — referenced in
`Pixel3Arch/docs/car-and-dock-references.md`; not yet pulled as source.

View file

@ -0,0 +1,64 @@
# Trust boundary matrix
Every session operation classified by caller type, required auth gate, and
current enforcement status. This is the reference for what is gated and what
is not — if an operation is not in this matrix, it is not gated.
## Legend
| Column | Meaning |
| --- | --- |
| Operation | The IPC verb or internal action |
| Caller | Who can invoke it |
| Min tier | Minimum capability tier required |
| Gate | What enforces the tier |
| Status | `enforced`, `partial`, `ungated` |
## Operations
| Operation | Caller | Min tier | Gate | Status |
| --- | --- | --- | --- | --- |
| `session.lock()` | IPC / UI / logind signal | ambient | None (always allowed) | enforced |
| `session.unlock()` | Credential gate only | stepUp | LockContext PIN/PAM | enforced — IPC refuses |
| `session.suspend()` | IPC / UI | ambient | capability probe | enforced |
| `session.hibernate()` | IPC / UI | ambient | capability probe | enforced |
| `session.poweroff()` | IPC / UI | ambient | capability probe + polkit | enforced |
| `session.reboot()` | IPC / UI | ambient | capability probe + polkit | enforced |
| `session.logout()` | IPC / UI | ambient | None | enforced |
| `session.inhibit("idle")` | IPC / internal | ambient | reason required | enforced |
| `session.inhibit("sleep")` | IPC / internal | ambient | reason required | enforced |
| `session.state()` | IPC / agent | ambient | None (read-only projection) | enforced |
| `session.caps()` | IPC / agent | ambient | None (read-only projection) | enforced |
| `StepUpAuth.requestAuth()` | UI / agent | ambient | PAM conversation | enforced |
| `StepUpAuth.isGranted()` | UI / agent | ambient | None (read-only check) | enforced |
| `StepUpAuth.revokeGrant()` | UI / internal | ambient | None | enforced |
| Agent conversation | Souveraine IPC | ambient | Server auth token | enforced |
| Agent send with personal context | Souveraine IPC | personal | !screenLocked && !screenLockSecure | enforced via LockContentPolicy |
| Agent output on lock surface | Lock surface | ambient | LockContentPolicy.allowsOnLock() | enforced |
| In-flight agent response on lock | Lock surface | personal | Ai.qml redacts on screenLocked | enforced |
| Media metadata on lock surface | Lock surface | ambient (if opted) | LockContentPolicy.mediaMetadataAmbient | enforced |
| Media transport controls | Lock surface | ambient | LockContentPolicy.mediaControlsVisible | enforced |
| Lock screen power actions | Lock surface | ambient | Config.lock.security.requirePasswordToPower | enforced |
| Idle state transition | IdleCoordinator | ambient | nativeEnabled config | enforced |
| Sleep/suspend transition | SessionEvents | ambient | delay inhibitor + WlSessionLock.secure | enforced |
| Session audit trail | SessionAudit | ambient | append-only JSONL with hash chain | enforced |
## Not yet gated (gaps)
| Operation | Caller | Required tier | Gap |
| --- | --- | --- | --- |
| Agent delete/push operations | Souveraine IPC | stepUp | StepUpAuth built but minTier metadata not wired |
| Agent physical access | Souveraine IPC | stepUp | StepUpAuth built but minTier metadata not wired |
| Break-glass override | Emergency | scoped grant | Done (StepUpAuth.breakGlass) |
| Boot-time IPC audit | Shell startup | ambient | Done (log in Session.qml) |
## Notes
- The matrix is local-only: all callers are on the same machine, reachable
only over quickshell's IPC socket by the user who owns the session.
- If a network-reachable caller is ever added, every row needs re-evaluation.
- The `personal` tier gate is `!screenLocked && !screenLockSecure` — the
session must not be locked and the compositor must not have secured the
lock surface. This is a live check, not a cached bool.
- The `stepUp` tier requires a recent (TTL-window) StepUpAuth grant for the
relevant action family.

View file

@ -0,0 +1,170 @@
# qtpim contacts — state as of 2026-07-15
Casey (he/him). Pixel 3, mainline Arch. Ani + Aster = Annie.
Souveraine = an OS, an agent substrate, and an agent.
## Constraints (non-negotiable, learned the hard way this session)
- **No GNOME or KDE library dependencies.** This rules out EDS/libebook
(Evolution stack) and Akonadi/KPeople. EDS was investigated at length and
**rejected**. Do not resurrect it.
- The only thing EDS ever offered was "Chatty already reads it." Chatty is
Casey's fork — teach Chatty to read the new store instead. The EDS book on
the phone is **empty (0 contacts)**. There is no sunk value there.
- Cite `file:line` or say "not in the code / undecided." Never state a guess
as established fact.
## What is actually built and installed on the phone
**qtpim dev — DONE, working.**
- Source: `~/Projects/qtpim`, branch `dev` (the only branch), HEAD `d03b5f6`.
Actively maintained upstream by Axel Spoerl (Qt Company), commits through
July 2026.
- Built native aarch64, 268/268 targets clean.
- Installed to `/usr` (NOT `/usr/local` — Qt6's prefix is `/usr/lib`; a first
attempt landed in `/usr/local` and was removed; only one copy exists now).
- Artifacts: `libQt6Contacts.so.6.12.0`, `libQt6Versit.so.6.12.0` (vCard),
`libQt6VersitOrganizer`, QML modules `QtContacts` + `QtOrganizer`,
`Qt6Contacts.pc` (6.12.0).
- QML plugin links clean: `libdeclarative_contactsplugin.so` resolves
`libQt6Contacts.so.6` with no missing symbols.
Reconfigure line (idempotent):
```sh
cd ~/Projects/qtpim/_build
/usr/lib/qt6/bin/qt-cmake .. -GNinja -DCMAKE_BUILD_TYPE=Release \
-DQT_NO_PACKAGE_VERSION_CHECK=TRUE \
-DQT_NO_PACKAGE_VERSION_INCOMPATIBLE_WARNING=TRUE \
-DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_STAGING_PREFIX=/usr
ninja && sudo ninja install && sudo ldconfig
```
### Caveats on the qtpim install
- qtpim dev's `CMakeLists.txt:14` does `find_package(Qt6 6.12.0)`. The phone
has Qt **6.11.1**. `QT_NO_PACKAGE_VERSION_CHECK=TRUE` forces past that gate.
It compiles and links clean, so the gate is bureaucratic, not a real API
break — but libs are versioned `6.12.0` against a 6.11.1 Qt. Works; not
blessed. Clean alternative: `qt6-base-git` (AUR) for a real 6.12 dev Qt,
then no override needed. Big native build on a Pixel 3.
- `/usr/lib/qt6/bin/qt-configure-module` is **broken on Arch** — it looks for
`qt-cmake-private` at `/usr/lib/qt6/lib/qt6/`, actual location is
`/usr/lib/qt6/`. Use `qt-cmake` directly (above).
- This is a manual `ninja install`, not a package. A Qt update can stomp it.
No PKGBUILD tracks it. (Casey's standing rule: untracked hand-installed
binaries are a known gap — worth a PKGBUILD eventually.)
## The actual blocker: there is no persistent backend
Only backend installed is `libqtcontacts_memory.so` — **RAM only, gone on
restart**. qtpim upstream ships only `memory` and `serviceactionmanager`.
So Chatty cannot swap to qtpim yet. There is nothing persistent to swap *to*.
The store must exist first.
## qtcontacts-sqlite attempt — BLOCKED, decision needed
`~/Projects/qtcontacts-sqlite`, cloned from `sailfishos/qtcontacts-sqlite`,
HEAD `2939cf2`. Active upstream (May 2026). qmake `.pro`, Qt5-era, RPM
packaging.
**Patched so far** (all reversible; `.orig` files sit next to each):
- `config.pri``config.pri.orig`: `PKGCONFIG += Qt5Contacts``Qt6Contacts`,
`c++11``c++17`, db path `qtcontacts-sqlite-qt5/``-qt6/`.
- `src/engine/contactstransientstore.cpp``.orig`: `QMutex::Recursive`
`QRecursiveMutex` (Qt6 split the class), added `#include <QRecursiveMutex>`,
`QMutexLocker``QMutexLocker<QRecursiveMutex>` at 3 sites.
- `src/engine/contactsengine.h` + `.cpp``.orig`: dropped `const` from
`collection()` / `collections()` — Qt6's `QContactManagerEngine`
(`/usr/include/qt6/QtContacts/qcontactmanagerengine.h:77-78`) declares them
non-const.
Those fixes **worked** — those errors are gone. `qmake6` configures, exit 0.
**Remaining errors, two kinds:**
Mechanical Qt5→Qt6 drift (known fixes, tedious):
- `contactsengine.cpp:948,950,951,953,958``QMutexLocker` needs template args
(`JobThread::MutexUnlocker` / `m_locker`)
- `contactsengine.cpp:1244,1273` — deleted `QMutexLocker` copy ctor
- `contactsengine.cpp:1297``qRegisterMetaTypeStreamOperators` removed in Qt6
**The real problem — `QContact::IgnoreAccessConstraints`:**
Verified by grep:
- NOT in `/usr/include/qt6/QtContacts/` (upstream Qt6 headers)
- NOT in `~/Projects/qtpim/src/contacts/*.h` (upstream dev source)
- **IS** in `sailfishos/qtpim` fork at `src/contacts/qcontact.h:59`
So qtcontacts-sqlite is **not** a Qt5 engine needing a port. It is written
against *Sailfish's forked qtpim*, which carries API upstream never had.
Call sites: `contactreader.cpp:764,1997`, `contactwriter.cpp:3473,3512,3558,
3954,3967,3976,3984`, `contactsengine.cpp` — 8+ places.
Stubbing it out means silently changing access-constraint semantics in a
contacts store. Not a safe sed.
## Three paths — Casey's call, not to be picked unilaterally
1. **Finish the mechanical fixes, then invent semantics for
`IgnoreAccessConstraints` against upstream.** Cheap up front, quiet
wrongness later. Not recommended.
2. **Build Sailfish's qtpim fork instead of upstream dev.** qtcontacts-sqlite
then compiles as designed. But Casey asked for *dev* specifically, and the
Sailfish fork was last touched 2021. Trades dev for fork debt.
3. **Write a sqlite engine against upstream dev.** Template is the in-tree
`memory` backend: `~/Projects/qtpim/src/plugins/contacts/memory/`,
**1,070 lines**, complete working reference. Only **2 pure virtuals** are
mandatory (`managerName()`, `managerVersion()`); the other 33 virtuals have
working defaults — implement save/remove/fetch, inherit the rest. More work
up front, no fork debt, it's Casey's.
## Why qtpim over EDS (settled, don't relitigate)
| | qtpim | EDS |
|---|---|---|
| Shape | in-process library + plugin engine | D-Bus daemon, mandatory |
| D-Bus in source | **zero** (verified by grep) | the entire architecture |
| Backend | yours, swappable | Evolution's model |
| QML | native module in-tree | none |
| vCard | in-tree (`src/versit`) | yes |
| Must implement | 2 methods | n/a — you adapt to it |
Precedent: UBports ships this exact stack today —
`gitlab.com/ubports/development/core/lomiri-addressbook-app` (active
2026-06-23) builds against `qtpim5-dev` + `qml-module-qtcontacts`. They pin a
git snapshot (`qtpim5-dev (>= 5.0~git20171109~0bd985b)`) — that's what
vendoring an unreleased module looks like. They also have a separate
`lomiri-address-book-service` because *multiple* apps share one store. Chatty
is a second reader, so that concern is real — but solve it when there are two
readers; "both open the same sqlite store" may be enough. Don't pre-build it.
Note UBports/Sailfish use `qtcontacts-sqlite` (their own DB), **not** EDS.
Nobody in that lineage uses EDS.
## Chatty state (unchanged this session — no code touched)
`~/Projects/Chatty`, design doc `PAF-chatty-identity-layer.md`.
- Steps 13 **built and working**: `persons` / `person_addresses` /
`person_link_exceptions` tables at `HISTORY_VERSION 6`; `ChattyPerson` +
`chatty_identity_*` API; `chatty_chat_get_priority_section()` re-keyed onto
Person. Matrix rooms no longer auto-bucket as Known — the original bug.
- `chatty-identity.c` currently forwards to Chatty's **private SQL tables**.
That's the seam that would repoint at a qtpim store.
- Binding seam to be aware of: Chatty is C/GObject, qtpim is C++/Qt. Not a
blocker, not free.
- The design doc still contains an **EDS pivot section — that is dead**.
Superseded by this file. Steps 13 in it are still valid.
- Still missing: any favoriting UI at all. That gap is what started this whole
investigation ("what is the methodology to favorite someone?").
## Open question that was never answered
What are contacts *for* in Souveraine? An address book, or people Annie knows
(stable referent across transports + git-backed memory)? That answer probably
determines whether the store is a contacts DB at all. This was asked and never
resolved — resolve it before writing an engine.

575
docs/session-trust-map.html Normal file
View file

@ -0,0 +1,575 @@
<title>Session Trust Architecture — Souveraine / Pixel 3</title>
<style>
:root {
--bg: #f7f5f0;
--bg-raised: #ffffff;
--ink: #1c1f24;
--ink-dim: #52585f;
--ink-faint: #8a8f96;
--line: #ddd8cd;
--line-strong: #b8b2a4;
--accent: #a8722f;
--enforced: #3a7a8c;
--gap: #b3453f;
--partial: #b8862f;
--done: #4b7c52;
--code-bg: #eeeae0;
--shadow: 0 1px 2px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.03);
}
:root[data-theme="dark"] {
--bg: #14171b;
--bg-raised: #1b1f24;
--ink: #e8e6e1;
--ink-dim: #a3a9ae;
--ink-faint: #6b7178;
--line: #2b2f35;
--line-strong: #3a3f46;
--accent: #d99a4f;
--enforced: #6fb0c4;
--gap: #d97a74;
--partial: #d9a94f;
--done: #7fb586;
--code-bg: #20242a;
--shadow: 0 1px 2px rgba(0,0,0,0.2), 0 4px 16px rgba(0,0,0,0.25);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14171b;
--bg-raised: #1b1f24;
--ink: #e8e6e1;
--ink-dim: #a3a9ae;
--ink-faint: #6b7178;
--line: #2b2f35;
--line-strong: #3a3f46;
--accent: #d99a4f;
--enforced: #6fb0c4;
--gap: #d97a74;
--partial: #d9a94f;
--done: #7fb586;
--code-bg: #20242a;
--shadow: 0 1px 2px rgba(0,0,0,0.2), 0 4px 16px rgba(0,0,0,0.25);
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: -apple-system, "Segoe UI", Inter, system-ui, sans-serif;
font-size: 15px;
line-height: 1.55;
}
.wrap {
max-width: 1180px;
margin: 0 auto;
padding: 2.5rem 1.75rem 5rem;
}
code, .mono, .path {
font-family: "SF Mono", "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace;
font-size: 0.88em;
}
.path {
background: var(--code-bg);
border-radius: 4px;
padding: 0.05em 0.4em;
color: var(--ink-dim);
}
header.top {
border-bottom: 1px solid var(--line);
padding-bottom: 1.5rem;
margin-bottom: 2rem;
}
.eyebrow {
font-size: 0.72rem;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--accent);
font-weight: 600;
margin-bottom: 0.6rem;
}
h1 {
font-size: 1.9rem;
font-weight: 700;
margin: 0 0 0.5rem;
letter-spacing: -0.01em;
text-wrap: balance;
}
.dek {
color: var(--ink-dim);
font-size: 1rem;
max-width: 62ch;
margin: 0;
}
.meta-row {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
margin-top: 1rem;
font-size: 0.82rem;
color: var(--ink-faint);
}
.meta-row b { color: var(--ink-dim); font-weight: 600; }
section { margin-bottom: 3rem; }
h2 {
font-size: 1.15rem;
font-weight: 700;
margin: 0 0 1rem;
display: flex;
align-items: baseline;
gap: 0.6rem;
}
h2 .num {
color: var(--ink-faint);
font-weight: 400;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
.callout {
background: var(--bg-raised);
border: 1px solid var(--line);
border-left: 3px solid var(--accent);
border-radius: 6px;
padding: 1rem 1.25rem;
box-shadow: var(--shadow);
}
.callout p:first-child { margin-top: 0; }
.callout p:last-child { margin-bottom: 0; }
/* summary strip */
.strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1px;
background: var(--line);
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
margin-bottom: 2.5rem;
}
.strip > div {
background: var(--bg-raised);
padding: 1.1rem 1.3rem;
}
.strip .label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--ink-faint);
margin-bottom: 0.35rem;
}
.strip .value {
font-size: 0.95rem;
font-weight: 600;
}
.strip .value.enforced { color: var(--enforced); }
.strip .value.gap { color: var(--gap); }
.strip .value.partial { color: var(--partial); }
/* diagram container */
.diagram-frame {
background: var(--bg-raised);
border: 1px solid var(--line);
border-radius: 10px;
padding: 1.5rem;
box-shadow: var(--shadow);
overflow-x: auto;
}
.diagram-frame .mermaid { min-width: 900px; }
.legend {
display: flex;
gap: 1.4rem;
flex-wrap: wrap;
margin-top: 1rem;
font-size: 0.8rem;
color: var(--ink-dim);
}
.legend span.dot {
display: inline-block;
width: 9px; height: 9px;
border-radius: 2px;
margin-right: 0.4em;
}
/* status table */
table {
width: 100%;
border-collapse: collapse;
background: var(--bg-raised);
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
font-size: 0.86rem;
}
.table-scroll { overflow-x: auto; border-radius: 8px; box-shadow: var(--shadow); }
th, td {
text-align: left;
padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
th {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ink-faint);
font-weight: 600;
background: var(--code-bg);
}
tr:last-child td { border-bottom: none; }
tbody tr:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }
.status-pill {
display: inline-block;
font-size: 0.72rem;
font-weight: 600;
padding: 0.1rem 0.55rem;
border-radius: 999px;
white-space: nowrap;
}
.status-pill.enforced { background: color-mix(in srgb, var(--enforced) 18%, transparent); color: var(--enforced); }
.status-pill.gap { background: color-mix(in srgb, var(--gap) 18%, transparent); color: var(--gap); }
.status-pill.partial { background: color-mix(in srgb, var(--partial) 20%, transparent); color: var(--partial); }
.status-pill.done { background: color-mix(in srgb, var(--done) 18%, transparent); color: var(--done); }
/* gaps grid */
.gaps-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1rem;
}
.gap-card {
background: var(--bg-raised);
border: 1px solid var(--line);
border-radius: 8px;
padding: 1.1rem 1.25rem;
box-shadow: var(--shadow);
}
.gap-card h3 {
font-size: 0.95rem;
margin: 0 0 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.gap-card p {
margin: 0.4rem 0;
color: var(--ink-dim);
font-size: 0.88rem;
}
.gap-card .sev {
width: 8px; height: 8px; border-radius: 50%;
display: inline-block;
background: var(--gap);
flex-shrink: 0;
}
.decision-block {
background: var(--bg-raised);
border: 1px solid var(--line);
border-radius: 8px;
padding: 1.25rem 1.4rem;
box-shadow: var(--shadow);
margin-bottom: 1rem;
}
.decision-block h3 { margin: 0 0 0.5rem; font-size: 1rem; }
.decision-block .q { color: var(--ink-dim); font-size: 0.88rem; margin-bottom: 0.7rem; }
.decision-block .options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 0.8rem;
margin-top: 0.8rem;
}
.opt {
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.7rem 0.85rem;
font-size: 0.83rem;
}
.opt .opt-title { font-weight: 600; margin-bottom: 0.3rem; }
.opt .opt-title.rec::after {
content: "recommended";
font-weight: 500;
font-size: 0.68rem;
color: var(--done);
margin-left: 0.5em;
text-transform: uppercase;
letter-spacing: 0.03em;
}
footer {
margin-top: 3rem;
padding-top: 1.5rem;
border-top: 1px solid var(--line);
font-size: 0.78rem;
color: var(--ink-faint);
}
a { color: var(--enforced); }
@media (max-width: 600px) {
h1 { font-size: 1.5rem; }
.wrap { padding: 1.5rem 1rem 3rem; }
}
</style>
<div class="wrap">
<header class="top">
<div class="eyebrow">Architecture map · verified against source, 2026-07-15</div>
<h1>Session Trust: Lockscreen, Idle, and the Session Manager</h1>
<p class="dek">How SouveraineOS's QuickShell layer consumes logind, PAM, and Wayland session-lock across the laptop and the Pixel 3 — what's built, what's designed but unverified, and where the real gaps sit.</p>
<div class="meta-row">
<span><b>Primary source</b> <span class="path">souveraine/surfaces/quickshell/</span></span>
<span><b>Branch</b> <span class="path">experimental/session-trust-phase1</span> @ <span class="path">6532572</span></span>
<span><b>Deploys to</b> laptop + Pixel 3 blueline, identical config</span>
</div>
</header>
<div class="strip">
<div><div class="label">Core invariant</div><div class="value">screenLocked ≠ screenLockSecure</div></div>
<div><div class="label">Rows enforced</div><div class="value enforced">19 of 23</div></div>
<div><div class="label">Rows gapped</div><div class="value gap">2 wiring gaps</div></div>
<div><div class="label">Unverified claims</div><div class="value partial">3 (see §4)</div></div>
<div><div class="label">Phone-only landmine</div><div class="value gap">logind.conf power-key hack</div></div>
</div>
<section>
<h2><span class="num">01</span>Why this exists</h2>
<div class="callout">
<p>SouveraineOS is not a new lockscreen — it's an orchestration layer that <em>consumes</em> logind, PAM, and Wayland's session-lock protocol rather than replacing them. The design rule that keeps this safe: <b>always route state through the protocol, never invent a shadow copy.</b> Two form factors (x86 laptop, ARM Pixel 3) run the exact same QuickShell config via <span class="path">deploy.sh --phone</span>.</p>
<p>The reason a map is worth building now: this is genuinely two different codebases doing overlapping-sounding things. <span class="path">souveraine/src/core/session/</span> (Rust) is Ani's <em>conversation</em> session — turns, SSE streams, subagents. <span class="path">souveraine/surfaces/quickshell/</span> (QML) is the Linux <em>login session</em> — lock, suspend, idle. They share a name and nothing else. All of what follows is the QML side.</p>
</div>
</section>
<section>
<h2><span class="num">02</span>The architecture</h2>
<div class="diagram-frame">
<pre class="mermaid">
flowchart TB
subgraph AUTH["Linux/Wayland authorities — consumed, never replaced"]
direction LR
logind["logind\nCan*, PrepareForSleep,\nSession.Lock signal"]
wlock["WlSessionLock\n.secure (compositor ack)"]
pam["PAM\nlogin + step-up conversation"]
polkit["polkit\none agent per session"]
end
subgraph INGRESS["services/SessionEvents.qml — logind ears"]
direction TB
resolver["session path resolver\nloginctl show-session"]
sleepmon["gdbus monitor: PrepareForSleep"]
lockmon["gdbus monitor: Session.Lock\n(echo-guarded vs our own lock)"]
delayinh["delay-mode sleep inhibitor\nsystemd-inhibit --mode=delay\nheld from shell startup"]
end
subgraph CORE["Core state — GlobalStates.qml"]
direction LR
reqstate["screenLocked\n(shell's REQUEST)"]
secstate["screenLockSecure\n(compositor's ACK)"]
end
subgraph ARBITER["modules/common/functions/Session.qml — the arbiter"]
direction TB
caps["capability probe\nbusctl Can* via awk\n(not sed — broke on phone)"]
verbs["verbs: lock/suspend/hibernate/\npoweroff/reboot/logout\nrun via Process, exit code logged"]
inhibitreg["inhibit(what, reason) → cookie\nstate() lists every holder"]
end
subgraph IDLE["services/Idle.qml + IdleCoordinator.qml"]
direction TB
idleinh["IdleInhibitor\n(idle-inhibit-unstable-v1)"]
idlestage["stage machine:\nDim → Lock → Suspending → Asleep → Waking"]
end
subgraph TIERS["Capability tiers — one policy for lock, media, agent"]
direction TB
ambient["ambient — no auth\nclock, weather, MPRIS transport"]
personal["personal — !screenLocked && !screenLockSecure\nmemory, calendar, fleet state, history"]
stepup["stepUp — fresh PAM re-auth\nsend, delete, push, payment, physical"]
end
subgraph STEPUP["services/StepUpAuth.qml"]
direction TB
grant["grant = {family, expires}\n5min TTL, 30s coarse expiry timer"]
breakglass["breakGlass()\n60s TTL, one-time, logged"]
end
subgraph AUDIT["services/SessionAudit.qml"]
audlog["append-only JSONL\nQt.md5(prev) chain\n~/.local/share/souveraine/session-audit.jsonl\n(advisory, NOT crypto)"]
end
subgraph AGENT["Agent surface — Ai.qml / Souveraine.qml"]
direction TB
redact["in-flight response redaction\non screenLocked mid-stream"]
sendgate["Souveraine.send() returns 'step-up'\nwhen family lacks a grant"]
end
logind -- "Session.Lock signal" --> lockmon
logind -- "PrepareForSleep(true/false)" --> sleepmon
lockmon -- "not an echo of our own lock" --> reqstate
sleepmon --> delayinh
delayinh -- "release only after" --> secstate
wlock -- "WlSessionLock.secure fires" --> secstate
reqstate -.->|"never conflated with"| secstate
caps -->|"probed once at startup"| verbs
verbs --> logind
verbs --> polkit
reqstate --> ARBITER
ARBITER --> CORE
idlestage --> reqstate
idleinh --> inhibitreg
secstate ==> personal
reqstate ==> personal
pam --> stepup
stepup --> grant
grant --> stepup
personal --> sendgate
stepup --> sendgate
reqstate --> redact
grant -. "revoked on lock" .-> reqstate
CORE --> AUDIT
STEPUP --> AUDIT
INGRESS --> AUDIT
classDef authority fill:#5b8fb0,stroke:#3a6b82,color:#fff
classDef core fill:#d99a4f,stroke:#a8722f,color:#1c1f24
classDef gapNode fill:#c15c5c,stroke:#8f3b36,color:#fff
classDef done fill:#7fb586,stroke:#4b7c52,color:#1c1f24
class logind,wlock,pam,polkit authority
class reqstate,secstate core
class sendgate,grant done
</pre>
</div>
<div class="legend">
<span><span class="dot" style="background:#5b8fb0"></span>External Linux authority (query/consume only)</span>
<span><span class="dot" style="background:#d99a4f"></span>Core dual-state invariant</span>
<span><span class="dot" style="background:#7fb586"></span>Enforced &amp; verified</span>
<span><span class="dot" style="background:#c15c5c"></span>Gap or unverified</span>
</div>
</section>
<section>
<h2><span class="num">03</span>The one invariant that governs everything</h2>
<div class="callout">
<p><code>GlobalStates.screenLocked</code> is the shell <b>asking</b> for the lock surface. <code>GlobalStates.screenLockSecure</code> mirrors <code>WlSessionLock.secure</code> — the compositor's <b>proof</b> that it actually happened. A failed or delayed Wayland lock is visible as <code>lockRequested: true, locked: false</code>. Every personal-data gate in the system reads <code>secure</code>, never <code>locked</code>. This is stated three separate times across the source docs (<span class="path">SESSION-TRUST-ARCHITECTURE.md</span>, <span class="path">TRUST-BOUNDARY-MATRIX.md</span>, the <span class="path">2026-07-14 DUMP</span>) — it's the load-bearing decision the whole model hangs from.</p>
</div>
</section>
<section>
<h2><span class="num">04</span>Status matrix</h2>
<p style="color:var(--ink-dim); font-size:0.85rem; margin-top:-0.5rem;">Sourced directly from <span class="path">TRUST-BOUNDARY-MATRIX.md</span> and the phase-1 dump's own verification checklist — not re-derived.</p>
<div class="table-scroll">
<table>
<thead>
<tr><th>Operation</th><th>Min tier</th><th>Gate</th><th>Status</th></tr>
</thead>
<tbody>
<tr><td>session.lock()</td><td>ambient</td><td>always allowed</td><td><span class="status-pill enforced">enforced</span></td></tr>
<tr><td>session.unlock()</td><td>stepUp</td><td>LockContext PIN/PAM only — IPC refuses by design</td><td><span class="status-pill enforced">enforced</span></td></tr>
<tr><td>session.suspend/hibernate/poweroff/reboot()</td><td>ambient</td><td>capability probe (+ polkit for power/reboot)</td><td><span class="status-pill enforced">enforced</span></td></tr>
<tr><td>session.inhibit("idle" / "sleep")</td><td>ambient</td><td>reason required, cookie tracked</td><td><span class="status-pill enforced">enforced</span></td></tr>
<tr><td>Agent send w/ personal context</td><td>personal</td><td>!screenLocked && !screenLockSecure (live check)</td><td><span class="status-pill enforced">enforced</span></td></tr>
<tr><td>In-flight agent response on lock</td><td>personal</td><td>Ai.qml redacts on screenLocked</td><td><span class="status-pill partial">re-verify</span> — redaction path only stopped crashing at the last commit of the session</td></tr>
<tr><td>Sleep/suspend transition</td><td>ambient</td><td>delay inhibitor + WlSessionLock.secure</td><td><span class="status-pill partial">unverified</span> — delay budget (~5s) not yet timed on the Pixel 3</td></tr>
<tr><td>lock() reaches locked:true only after secure</td><td></td><td></td><td><span class="status-pill partial">unverified</span> — needs timing pass</td></tr>
<tr><td>Session audit trail</td><td>ambient</td><td>append-only JSONL, md5 hash chain</td><td><span class="status-pill enforced">enforced</span> <i>(advisory tamper-evidence only, not crypto)</i></td></tr>
<tr><td>Agent delete / push operations</td><td>stepUp</td><td>StepUpAuth exists but per-op minTier metadata isn't wired</td><td><span class="status-pill gap">gap</span></td></tr>
<tr><td>Agent physical-access operations</td><td>stepUp</td><td>same as above</td><td><span class="status-pill gap">gap</span></td></tr>
<tr><td>Break-glass override</td><td>scoped</td><td>StepUpAuth.breakGlass — mandatory reason, 60s TTL, one-time</td><td><span class="status-pill done">done</span></td></tr>
<tr><td>Native Wayland idle-notify on Pixel compositor</td><td></td><td></td><td><span class="status-pill partial">unverified</span> — hypridle is the stopgap until confirmed on both targets</td></tr>
</tbody>
</table>
</div>
</section>
<section>
<h2><span class="num">05</span>Fundamental gaps — the actual decision points</h2>
<div class="gaps-grid">
<div class="gap-card">
<h3><span class="sev"></span>Phone power-button hack predates and bypasses the new ingress</h3>
<p>The Pixel 3's power button was fixed by setting <span class="path">HandlePowerKey=ignore</span> in <span class="path">/etc/systemd/logind.conf</span> (2026-07-06) plus a Hyprland no-op bind — because logind's default poweroff was killing modem state. That fix <b>lives only on the phone</b>, is not in any repo, and predates <span class="path">SessionEvents.qml</span>. Once the hold-rails kernel patch lands and DPMS/suspend is safe, the button needs rebinding to screen-off/suspend — <b>through the new logind ingress</b>, not another local hack layered on top.</p>
</div>
<div class="gap-card">
<h3><span class="sev"></span>Two things are named "session" and share nothing</h3>
<p><span class="path">souveraine/src/core/session/</span> (Rust, Ani's conversation/turn lifecycle) and <span class="path">souveraine/surfaces/quickshell/modules/common/functions/Session.qml</span> (Linux login session — lock/suspend/idle) are unrelated. This cost real time in this scan alone; worth a naming pass if confusion recurs (e.g. rename the QML one to <span class="path">LinuxSession</span> or similar internally — it already deploys as IPC target <span class="path">sessionctl</span> to avoid colliding with ii's own <span class="path">session</span>).</p>
</div>
<div class="gap-card">
<h3><span class="sev"></span>stepUp tier is built but not load-bearing yet</h3>
<p><span class="path">StepUpAuth.qml</span> is fully implemented — grants, TTL, break-glass, revocation on lock — but the per-operation <span class="path">minTier</span> metadata that would actually route agent delete/push/physical-access calls through it isn't wired. Right now those operations are ungated in practice even though the machinery to gate them exists.</p>
</div>
<div class="gap-card">
<h3><span class="sev"></span>PAM step-up backend is polkit today, not PAM</h3>
<p><span class="path">/etc/pam.d/souveraine-stepup</span> is intentionally not shipped (root-owned system config). Until it's installed and audited, <span class="path">StepUpAuth</span> falls through to <span class="path">pkcheck</span>/polkit at runtime. That's a materially different trust boundary than a dedicated PAM service — worth deciding explicitly whether polkit-as-stepup is acceptable long-term or just a bootstrap.</p>
</div>
<div class="gap-card">
<h3><span class="sev"></span>Audit chain is advisory, not tamper-proof</h3>
<p><span class="path">SessionAudit.qml</span> uses <span class="path">Qt.md5()</span> because QML has no SHA-256 primitive. The docs are explicit that this is "advisory tamper-evidence, not crypto" — fine for debugging "why did it lock," not sufficient if this audit trail is ever meant to survive an adversarial actor with local access.</p>
</div>
<div class="gap-card">
<h3><span class="sev"></span>Trust matrix is scoped local-only, by design and by necessity</h3>
<p>Every gated operation assumes a same-machine caller over the QuickShell IPC socket, owned by the single user. This is explicitly flagged in the source: "if a network-reachable caller is ever added, every row needs re-evaluation." Any federation/multi-machine work (per <span class="path">federation-fork-vision</span>) that lets a remote agent reach into session state would invalidate this entire matrix and needs its own pass, not an incremental patch.</p>
</div>
</div>
</section>
<section>
<h2><span class="num">06</span>What's actually deployed vs. designed</h2>
<div class="callout">
<p>The live config at <span class="path">~/.config/quickshell/ii</span> (deployed, currently running) has a <b>simpler</b> GlobalStates/Idle than what's described above — no <code>screenLockSecure</code>, no session IPC surface, no SessionEvents.qml. The richer model lives in <span class="path">souveraine/surfaces/quickshell/</span> and is composed into a separate <span class="path">~/.config/quickshell/souveraine</span> config by <span class="path">deploy.sh</span>, which borrows untouched ii directories as whole-dir symlinks and overlays only the files Souveraine changes — ii's own tree stays pristine. Session-trust phase 1 landed same-day (2026-07-14) on <span class="path">experimental/session-trust-phase1</span>; verification is partial (see §4). Before treating any "enforced" row above as a hard security guarantee, confirm which config is actually running with <code>qs -c souveraine</code> vs <code>qs -c ii</code>.</p>
</div>
</section>
<section>
<h2><span class="num">07</span>File index</h2>
<div class="table-scroll">
<table>
<thead><tr><th>Concern</th><th>File</th></tr></thead>
<tbody>
<tr><td>Design contract</td><td><span class="path">souveraine/surfaces/quickshell/SESSION-TRUST-ARCHITECTURE.md</span></td></tr>
<tr><td>Per-operation gating matrix</td><td><span class="path">souveraine/surfaces/quickshell/TRUST-BOUNDARY-MATRIX.md</span></td></tr>
<tr><td>Same-day work log + verification checklist</td><td><span class="path">souveraine/surfaces/quickshell/DUMP-session-trust-2026-07-14.md</span></td></tr>
<tr><td>logind PrepareForSleep + Session.Lock ingress</td><td><span class="path">services/SessionEvents.qml</span></td></tr>
<tr><td>Step-up PAM/polkit grants</td><td><span class="path">services/StepUpAuth.qml</span></td></tr>
<tr><td>Hash-chained audit trail</td><td><span class="path">services/SessionAudit.qml</span></td></tr>
<tr><td>Idle stage machine</td><td><span class="path">services/IdleCoordinator.qml</span>, <span class="path">services/Idle.qml</span></td></tr>
<tr><td>Capability probe + verbs (the arbiter)</td><td><span class="path">modules/common/functions/Session.qml</span></td></tr>
<tr><td>Dual lock-state singleton</td><td><span class="path">GlobalStates.qml</span> (screenLocked / screenLockSecure)</td></tr>
<tr><td>Settings UI for idle/lock/dim timers</td><td><span class="path">modules/settings/LockConfig.qml</span>, <span class="path">IdleConfig.qml</span></td></tr>
<tr><td>Deploy/compose script (ii borrow + overlay)</td><td><span class="path">surfaces/quickshell/deploy.sh</span></td></tr>
<tr><td>Phone-only power-button fix (not in any repo)</td><td><span class="path">/etc/systemd/logind.conf</span> on-device, see <span class="path">Pixel3Arch/docs/phone-shell-ux.md</span> §"Power button fix"</td></tr>
<tr><td>Unrelated: Ani's conversation/turn session (Rust)</td><td><span class="path">souveraine/src/core/session/mod.rs</span> — not part of this map</td></tr>
</tbody>
</table>
</div>
</section>
<footer>
Built from direct source reads on 2026-07-15: SESSION-TRUST-ARCHITECTURE.md, TRUST-BOUNDARY-MATRIX.md, DUMP-session-trust-2026-07-14.md, SessionEvents.qml, StepUpAuth.qml, Session.qml (both the QML arbiter and confirmation it differs from the Rust core/session), GlobalStates.qml, Idle.qml, and Pixel3Arch/docs/phone-shell-ux.md. No claim above goes beyond what those files state.
</footer>
</div>