Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/docs/tasks/16-culver-messenger-contacts.md
Fimeg 608f652537 tasks: fold 01 into 16.F and 22 into 19; 24 open tasks -> 22
Both were sub-items, not work units. 01's every step read "after 16.B/C";
22 sourced its own candidates from 19's still-required list and its status
line was "enumerate with Casey at session start".
2026-07-26 08:47:55 -04:00

11 KiB
Raw Permalink Blame History

TASK 16 — culver: usable messenger + contacts as the Personal-class store

Status: in progress. A, B and D landed 2026-07-21; C is implemented in the local Culver worktree and passes the focused archdev build, with phone acceptance/deploy still pending. Unblocks lane F (was TASK-01). Size: not single-session — four distinct work units (AD), each session-sized. Do them in order; A is the smallest and most load-bearing. Repo: ~/Projects/culver (Gitea Fimeg/culver, CI on primary).

Where culver is (verified 2026-07-21)

  • Matrix sync is real and further along than any reference bridge: SyncService sliding sync, per-room Timeline, E2EE crypto store, reactions/threads/edits/redaction. crates/matrix (1510 LOC) is not the weak link.
  • Runtime architecture is clean: QML → invokable → AppCommand (mpsc) → runtime.rsProtocol/StoreCoreEventModelUpdate → GUI thread. crates/app/src/runtime.rs.
  • crates/contacts (1374 LOC) is real and is the qtpim replacement (qtpim retired — archive/06): owned SQLite store, vCard 4.0 import/ export, number/MXID/email → identity resolver. The runtime already uses it for name resolution via the resolve_display hook.

The four work units

A. Tap a room → no messages render — landed 669f13a

The QML and the ChatModel bridge were written to two different contracts and never reconciled. Same failure class as the 2026-07-20 "matrix login never fires" bug (#[auto_cxx_name]), one level up: here the invokable names themselves don't exist, so QML silently no-ops instead of erroring.

QML calls / binds Real bridge (crates/app/src/bridge/chat.rs) Effect
chatModel.open(cid) (Main.qml:58) load_conversationloadConversation LoadTimeline never dispatched → timeline stays empty
chat.loadOlder() (ChatView.qml:112) paginate_backpaginateBack back-pagination dead
delegate roles messageId, bodyText, bodyKind, senderDisplay, threadReplyCount, inReplyToId actual roles id, body, html, sender, replyCount, inReplyTo every row binding undefined → blank rows even once data arrives
chat.avatar / protocol / subtitle / typing not qproperties header half-empty
chat.threadModel (ThreadView.qml) doesn't exist — thread arrives via thread_ready signal + thread_rows thread pane can't bind

Two layers, both broken: fix openloadConversation and loadOlderpaginateBack, and align the ~14 role names, and add the missing subtitle/avatar/protocol qproperties (or drop the QML that references them). Decide the ThreadView contract (signal-driven vs. a real threadModel) and make one side match.

LoadTimeline in runtime.rs reads only from the store (store.messages) — correct, because the matrix backend streams MessageReceived into the store on sync. So once the names line up, tapping a room shows content with no backend change.

Acceptance: tap any synced room → its messages render; scroll up paginates history; header shows title + protocol glyph.

B. Contacts as the Personal-class store — landed 57740b1

The store works but is plaintext todayConnection::open(path), PRAGMA journal_mode = WAL, names/numbers/MXIDs/emails/inline photo BLOBs all in the clear in the shared DB file (crates/contacts/src/ store.rs). And export_vcard / import_vcards are unguarded free functions — a plaintext off-system egress path. So "won't open without the system key" and "cannot be shared off system" are the design intent, not the current behaviour.

Bring it to the bar, per docs/STORAGE-ENCRYPTION.md:

  • Contacts = Personal class. Key evicted on lock; rooted in the machine seed via the Argon2id-wrapped Personal class key. A locked/ stolen device yields ciphertext. Use the primitive TASK-11 already shipped: souveraine-secrets seals AES-256-GCM under a machined- signature-wrapped store key (source=Machined, reboot-verified). The contact DB key comes from that rail, not a file on disk. Store won't open when the rail is unavailable — that is the point.
  • "Share a contact" is a Personal→egress operation, not a function call. export_vcard stops being freely callable and becomes an authorized op gated by stepUp + agent interaction — the org.souveraine.Secrets.Manage stepUp dialog (TASK-11 "ingress pending") is the seam. Import (ingress) stays ungated; egress is the boundary.

Acceptance: contact DB is ciphertext at rest and does not open without the machine identity (verify: move the file to a bare box → no plaintext). A vCard export attempt raises stepUp and does nothing without it. Round-trip still green for a legitimately-authorized share.

C. Contact card UI — implemented locally; device acceptance pending

The cards at the top of the inbox can't open because there is no contact-card bridge or viewcrates/contacts has no UI surface (only the resolve_display name hook is wired). Add a ContactModel bridge + card view. Build it against the gated store from B, so opening a card is a keyed read and "share" from the card triggers the stepUp. Building the UI first would bake the plaintext/free-share model we are moving away from.

Acceptance: tapping a contact chip/avatar opens a card (name, numbers, MXIDs, photo); "share" on the card triggers stepUp.

Implementation note (2026-07-21): ContactModel now projects the keyed store, the chat-header avatar resolves SMS/Matrix participants into ContactCard, and share verifies through org.souveraine.Secrets.Manage.VerifyPassphrase before the runtime can mint EgressAuth and return a vCard. cargo check -p culver passed on archdev and the touched QML passes qmllint; phone interaction is not yet claimed.

Late 2026-07-21 additions (same worktree, single deploy with E): qml/Contacts.qml — a contacts list page reached from a 👤 button in the inbox title bar (narrow: StackView push; wide: modal popup like Accounts); rows come from the existing ContactModel, tap → selectContact → the shared ContactCard. Registered in build.rs views.

E. History persistence: boot amnesia — implemented 2026-07-21

Casey's field report: every boot Culver "drops its message history and has to regain it." Probe of the phone's keyed store (busctl plain-session key fetch + culver-core example on archdev) showed the store persists fine — 75 conversations — but most rooms had 0 messages: LoadTimeline only ever read the local store and nothing ever asked a backend to fill gaps, so what looked like "regaining" was sliding-sync re-delivery on every boot.

Fix: CoreEvent::HistoryFetched { conversation, messages, before, limit } (core) + spawn_history_fill (runtime). A store page shorter than the request spawns an async backend messages() fetch off the event loop; the fetched page is persisted (idempotent upserts) and the open view re-rendered from the store. Short pagination pages are WITHHELD until the fill answers, because ChatPrepend has no dedup — the fill's event is the single prepend for that request (falls back to exactly the withheld page on backend failure). Telephony's messages() reads the same store, so the fill is a harmless no-op there. Once fetched, history is on disk (SQLCipher) and later boots serve it without the network.

Known-latent, NOT touched: ChatView's ListView is BottomToTop while ChatModel rows are chronological — verify message order visually on device before changing either side.

D. Notifications: culver → NotifyEvents — landed d33d837

Notifications on the phone already work — the shell owns the surface (org.freedesktop.Notifications is quickshell's; TASK archive/04 built the NotifyEvents fan-out seam + lock card). culver just isn't feeding it: nothing in the tree emits. Chatty's im.received uses the notify-send/libnotify pipe that lands in banner + lock card.

Emit on inbound MessageReceived for conversations that are not the open one and not muted (mute state already exists — Conversation:: muted, Matrix RoomNotificationMode::Mute). One emit point in runtime.rs's event fold.

Acceptance: an inbound message to a non-open, non-muted conversation raises a banner (and the lock card while locked) via the same pipe Chatty uses.

F. Favorites: the wife's name on her number — folded in from TASK-01

The original motivator for the whole contacts/Person layer, and small enough that it was never a task so much as B and C's acceptance test. Kept here rather than as its own file: it is entirely gated on B (store keyed to the machine identity) and C (contact card / ContactModel bridge), and a file whose every step reads "after 16" is a pointer, not a work unit.

  1. Confirm the store answers a query — proves it is live before writing.
  2. Insert name + number through the gated store. One idempotent script: software must work fresh AND as an upgrade, so no one-off sqlite3 patches as the fix.
  3. Open the inbox; the number renders as the name.

Acceptance: the number shows as her name in the conversation list, and the insert is reproducible from a script. qtpim is not the path (archive/06).

Also queued (not blocking AD)

Inbox favorites redesign (Casey confirmed 2026-07-21): favorites as a horizontal scrollable circles rail along the top; below it the inbox lists conversations most-recent-first by default, with a filter/toggle later. Rail ordered by last-contacted, later honouring a pinned state. New component, not a fix — do it after A lands so there is content to open.

Full-app completion after C: wire the currently guarded/inert inbox mute and delete actions, contact create/edit/import, attachment send progress, search, delivery/read state, and account error/recovery surfaces. Treat each as a separate acceptance-backed unit; “the backend has a method” is not a shipped user feature until the QML bridge and phone path exercise it.

Build / deploy

Per tree conventions: build on archdev, never the laptop. culver builds in archdev ~/culver; CI runs on push to primary. cxx-qt 0.9 keeps snake_case Qt-facing names — every extern "RustQt" block needs #[auto_cxx_name] (root cause of the class of bug in A). Deploy to the Pixel 3 from the laptop over USB.

Connects to

  • Lane F (favorites — wife's name on her number): folded in here 2026-07-26. culver-contacts is the "small owned store the Person layer reads directly" it had been blocked on since qtpim retired (archive/06).
  • TASK-11 (souveraine-secrets): supplies the at-rest key + stepUp for B.
  • archive/04 (notification server): supplies the NotifyEvents seam for D.
  • docs/STORAGE-ENCRYPTION.md: Personal-class placement for B.