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".
11 KiB
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 (A–D), 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:
SyncServicesliding sync, per-roomTimeline, 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.rs→Protocol/Store→CoreEvent→ModelUpdate→ 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 theresolve_displayhook.
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_conversation → loadConversation |
LoadTimeline never dispatched → timeline stays empty |
chat.loadOlder() (ChatView.qml:112) |
paginate_back → paginateBack |
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 open→loadConversation and
loadOlder→paginateBack, 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 today — Connection::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_vcardstops being freely callable and becomes an authorized op gated by stepUp + agent interaction — theorg.souveraine.Secrets.ManagestepUp 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 view — crates/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.
- Confirm the store answers a query — proves it is live before writing.
- Insert name + number through the gated store. One idempotent script:
software must work fresh AND as an upgrade, so no one-off
sqlite3patches as the fix. - 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 A–D)
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
NotifyEventsseam for D. docs/STORAGE-ENCRYPTION.md: Personal-class placement for B.