Replace auto-increment ids with jj-style ones
Ids are 64 random bits in reverse hex — nibble 0 renders as z, jj's
change-id alphabet. The migration generates them in SQL, hex(randomblob)
with each digit substituted, since SQLite has no translate().

Assisted-by: GLM-5.3 via pi
change unvukqnuvvqoxyllrvxkzzryvzzmrsqx
commit da6ecaad1b7c35b3f538434a49003693527504bf
author Alpha Chen <alpha@kejadlen.dev>
date
parent wxuwwzuv
diff --git a/migrations/0006_jj_style_ids.sql b/migrations/0006_jj_style_ids.sql
new file mode 100644
index 0000000..cb5181e
--- /dev/null
+++ b/migrations/0006_jj_style_ids.sql
@@ -0,0 +1,185 @@
+-- jj-style ids replace the INTEGER PRIMARY KEYs (task uu): random
+-- 16-character reverse-hex strings — jj's change-id alphabet, where
+-- nibble 0 renders as z. SQLite can't change a primary key in place,
+-- so each table is rebuilt, as 0002 did for messages; an `old` column
+-- carries the numeric id across the rebuilds and comes off at the end.
+--
+-- The id expression is hex(randomblob(8)) with every digit substituted:
+-- 0123456789abcdef -> zyxwvutsrqponmlk. Nested replace() calls are safe
+-- here because no substituted character is ever substituted again.
+
+CREATE TABLE threads_new (
+    id TEXT PRIMARY KEY,
+    account TEXT NOT NULL REFERENCES accounts (slug),
+    subject TEXT NOT NULL,
+    jmap_thread_id TEXT,
+    old INTEGER NOT NULL
+) STRICT;
+
+INSERT INTO threads_new (id, account, subject, jmap_thread_id, old)
+SELECT replace(replace(replace(replace(replace(replace(replace(replace(
+       replace(replace(replace(replace(replace(replace(replace(replace(
+       lower(hex(randomblob(8))),
+       '0','z'),'1','y'),'2','x'),'3','w'),'4','v'),'5','u'),'6','t'),'7','s'),
+       '8','r'),'9','q'),'a','p'),'b','o'),'c','n'),'d','m'),'e','l'),'f','k'),
+       account, subject, jmap_thread_id, id
+FROM threads;
+
+DROP TABLE threads;
+ALTER TABLE threads_new RENAME TO threads;
+
+CREATE TABLE messages_new (
+    id TEXT PRIMARY KEY,
+    account TEXT NOT NULL REFERENCES accounts (slug),
+    message_id TEXT NOT NULL,
+    jmap_id TEXT,
+    thread TEXT NOT NULL REFERENCES threads (id),
+    at TEXT NOT NULL,
+    cc TEXT NOT NULL DEFAULT '[]',
+    bcc TEXT NOT NULL DEFAULT '[]',
+    body TEXT NOT NULL,
+    kind TEXT NOT NULL CHECK (kind IN ('received', 'sent')),
+    from_name TEXT,
+    from_addr TEXT,
+    state TEXT CHECK (state IN ('inbox', 'do', 'wait', 'watch', 'done')),
+    folder TEXT REFERENCES folders (name),
+    sent_by TEXT REFERENCES users (login),
+    sent_to TEXT,
+    old INTEGER NOT NULL,
+    UNIQUE (account, message_id),
+    CHECK (
+        (kind = 'received'
+            AND from_name IS NOT NULL AND from_addr IS NOT NULL AND state IS NOT NULL
+            AND sent_by IS NULL AND sent_to IS NULL)
+        OR (kind = 'sent'
+            AND sent_to IS NOT NULL
+            AND from_name IS NULL AND from_addr IS NULL AND state IS NULL AND folder IS NULL)
+    )
+) STRICT;
+
+INSERT INTO messages_new (id, account, message_id, jmap_id, thread, at, cc, bcc, body,
+    kind, from_name, from_addr, state, folder, sent_by, sent_to, old)
+SELECT replace(replace(replace(replace(replace(replace(replace(replace(
+       replace(replace(replace(replace(replace(replace(replace(replace(
+       lower(hex(randomblob(8))),
+       '0','z'),'1','y'),'2','x'),'3','w'),'4','v'),'5','u'),'6','t'),'7','s'),
+       '8','r'),'9','q'),'a','p'),'b','o'),'c','n'),'d','m'),'e','l'),'f','k'),
+       m.account, m.message_id, m.jmap_id, t.id, m.at, m.cc, m.bcc, m.body,
+       m.kind, m.from_name, m.from_addr, m.state, m.folder, m.sent_by, m.sent_to, m.id
+FROM messages m JOIN threads t ON t.old = m.thread;
+
+DROP TABLE messages;
+ALTER TABLE messages_new RENAME TO messages;
+
+CREATE TABLE assignees_new (
+    message TEXT NOT NULL REFERENCES messages (id),
+    user TEXT NOT NULL REFERENCES users (login),
+    PRIMARY KEY (message, user)
+) STRICT;
+
+INSERT INTO assignees_new (message, user)
+SELECT m.id, a.user FROM assignees a JOIN messages m ON m.old = a.message;
+
+DROP TABLE assignees;
+ALTER TABLE assignees_new RENAME TO assignees;
+
+CREATE TABLE reads_new (
+    user TEXT NOT NULL REFERENCES users (login),
+    message TEXT NOT NULL REFERENCES messages (id),
+    PRIMARY KEY (user, message)
+) STRICT;
+
+INSERT INTO reads_new (user, message)
+SELECT r.user, m.id FROM reads r JOIN messages m ON m.old = r.message;
+
+DROP TABLE reads;
+ALTER TABLE reads_new RENAME TO reads;
+
+CREATE TABLE comments_new (
+    id TEXT PRIMARY KEY,
+    thread TEXT NOT NULL REFERENCES threads (id),
+    author TEXT NOT NULL REFERENCES users (login),
+    at TEXT NOT NULL,
+    text TEXT NOT NULL
+) STRICT;
+
+INSERT INTO comments_new (id, thread, author, at, text)
+SELECT replace(replace(replace(replace(replace(replace(replace(replace(
+       replace(replace(replace(replace(replace(replace(replace(replace(
+       lower(hex(randomblob(8))),
+       '0','z'),'1','y'),'2','x'),'3','w'),'4','v'),'5','u'),'6','t'),'7','s'),
+       '8','r'),'9','q'),'a','p'),'b','o'),'c','n'),'d','m'),'e','l'),'f','k'),
+       t.id, c.author, c.at, c.text
+FROM comments c JOIN threads t ON t.old = c.thread;
+
+DROP TABLE comments;
+ALTER TABLE comments_new RENAME TO comments;
+
+CREATE TABLE history_new (
+    id TEXT PRIMARY KEY,
+    message TEXT NOT NULL REFERENCES messages (id),
+    user TEXT NOT NULL REFERENCES users (login),
+    at TEXT NOT NULL,
+    event TEXT NOT NULL
+) STRICT;
+
+-- Random ids carry no order, so the insertion order history needs lives
+-- in the rowid the new table already has.
+INSERT INTO history_new (rowid, id, message, user, at, event)
+SELECT h.rowid,
+       replace(replace(replace(replace(replace(replace(replace(replace(
+       replace(replace(replace(replace(replace(replace(replace(replace(
+       lower(hex(randomblob(8))),
+       '0','z'),'1','y'),'2','x'),'3','w'),'4','v'),'5','u'),'6','t'),'7','s'),
+       '8','r'),'9','q'),'a','p'),'b','o'),'c','n'),'d','m'),'e','l'),'f','k'),
+       m.id, h.user, h.at, h.event
+FROM history h JOIN messages m ON m.old = h.message
+ORDER BY h.rowid;
+
+DROP TABLE history;
+ALTER TABLE history_new RENAME TO history;
+
+-- The queued-intent tables point at messages too, so they take the
+-- same rebuild.
+CREATE TABLE pending_files_new (
+    message TEXT NOT NULL REFERENCES messages (id) ON DELETE CASCADE,
+    folder TEXT REFERENCES folders (name),
+    PRIMARY KEY (message)
+) STRICT;
+
+INSERT INTO pending_files_new (message, folder)
+SELECT m.id, p.folder FROM pending_files p JOIN messages m ON m.old = p.message;
+
+DROP TABLE pending_files;
+ALTER TABLE pending_files_new RENAME TO pending_files;
+
+CREATE TABLE pending_deletes_new (
+    message TEXT NOT NULL REFERENCES messages (id) ON DELETE CASCADE,
+    PRIMARY KEY (message)
+) STRICT;
+
+INSERT INTO pending_deletes_new (message)
+SELECT m.id FROM pending_deletes p JOIN messages m ON m.old = p.message;
+
+DROP TABLE pending_deletes;
+ALTER TABLE pending_deletes_new RENAME TO pending_deletes;
+
+CREATE TABLE pending_archives_new (
+    message TEXT NOT NULL REFERENCES messages (id) ON DELETE CASCADE,
+    PRIMARY KEY (message)
+) STRICT;
+
+INSERT INTO pending_archives_new (message)
+SELECT m.id FROM pending_archives p JOIN messages m ON m.old = p.message;
+
+DROP TABLE pending_archives;
+ALTER TABLE pending_archives_new RENAME TO pending_archives;
+
+CREATE UNIQUE INDEX threads_account_jmap_thread ON threads (account, jmap_thread_id);
+CREATE INDEX messages_thread ON messages (thread);
+CREATE INDEX messages_state ON messages (state);
+CREATE INDEX comments_thread ON comments (thread);
+CREATE INDEX history_message ON history (message);
+
+ALTER TABLE threads DROP COLUMN old;
+ALTER TABLE messages DROP COLUMN old;
diff --git a/src/fixtures.rs b/src/fixtures.rs
index 4aec016..ea63ef3 100644
--- a/src/fixtures.rs
+++ b/src/fixtures.rs
@@ -7,15 +7,36 @@ use std::collections::BTreeSet;
 use jiff::civil::{DateTime, date};
 
 use crate::Error;
-use crate::model::{Account, Comment, Kind, Message, MessageId, State, Thread, User, Values};
+use crate::model::{Account, Comment, Kind, Message, State, Thread, User, Values, reverse_hex};
 use crate::store::{Clock, Store};
 
-pub const WATER: MessageId = 7;
-pub const ELI_PRACTICE: MessageId = 20;
+/// Seeds for the rows tests name by number.
+pub const WATER: u32 = 7;
+pub const ELI_PRACTICE: u32 = 20;
 
 pub const ALEX: &str = "alex@example.com";
 pub const SAM: &str = "sam@example.com";
 
+/// The fixture row id for a seed number: the seed hashed (splitmix64)
+/// and rendered as jj-style reverse hex, so rows keep stable jj-looking
+/// ids while the fixtures stay written in numbers.
+pub fn id(seed: u32) -> String {
+    reverse_hex(splitmix64(u64::from(seed)))
+}
+
+/// The fixture Message-ID for a seed number.
+pub fn message_id(seed: u32) -> String {
+    format!("{}@fixtures.docket.invalid", id(seed))
+}
+
+fn splitmix64(mut x: u64) -> u64 {
+    x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
+    let mut z = x;
+    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
+    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
+    z ^ (z >> 31)
+}
+
 /// Thursday, 1 October 2026, at noon.
 pub fn now() -> DateTime {
     at(10, 1, 12, 0)
@@ -25,10 +46,6 @@ fn at(month: i8, day: i8, hour: i8, minute: i8) -> DateTime {
     date(2026, month, day).at(hour, minute, 0, 0)
 }
 
-fn message_id(id: MessageId) -> String {
-    format!("{id}@fixtures.docket.invalid")
-}
-
 fn strings(xs: &[&str]) -> Vec<String> {
     xs.iter().map(|&x| x.to_owned()).collect()
 }
@@ -41,9 +58,9 @@ struct Builder {
 }
 
 impl Builder {
-    fn thread(&mut self, id: u32, account: &str, subject: &str) {
+    fn thread(&mut self, seed: u32, account: &str, subject: &str) {
         self.threads.push(Thread {
-            id,
+            id: id(seed),
             account: account.to_owned(),
             subject: subject.to_owned(),
         });
@@ -52,7 +69,7 @@ impl Builder {
     #[allow(clippy::too_many_arguments)]
     fn recv(
         &mut self,
-        id: MessageId,
+        seed: u32,
         thread: u32,
         at: DateTime,
         (from, addr): (&str, &str),
@@ -61,9 +78,9 @@ impl Builder {
         cc: &[&str],
     ) {
         self.messages.push(Message {
-            id,
-            message_id: message_id(id),
-            thread,
+            id: id(seed),
+            message_id: message_id(seed),
+            thread: id(thread),
             at,
             cc: strings(cc),
             bcc: Vec::new(),
@@ -85,7 +102,7 @@ impl Builder {
 
     fn sent(
         &mut self,
-        id: MessageId,
+        seed: u32,
         thread: u32,
         at: DateTime,
         (by, to): (&str, &[&str]),
@@ -93,9 +110,9 @@ impl Builder {
         (cc, bcc): (&[&str], &[&str]),
     ) {
         self.messages.push(Message {
-            id,
-            message_id: message_id(id),
-            thread,
+            id: id(seed),
+            message_id: message_id(seed),
+            thread: id(thread),
             at,
             cc: strings(cc),
             bcc: strings(bcc),
@@ -108,12 +125,12 @@ impl Builder {
     }
 
     fn comment(&mut self, thread: u32, author: &str, at: DateTime, text: &str) {
-        let id = u32::try_from(self.comments.len())
+        let n = u32::try_from(self.comments.len())
             .unwrap_or(u32::MAX)
             .saturating_add(1);
         self.comments.push(Comment {
-            id,
-            thread,
+            id: id(n),
+            thread: id(thread),
             author: author.to_owned(),
             at,
             text: text.to_owned(),
@@ -330,9 +347,9 @@ pub fn seed(store: &Store) -> Result<(), Error> {
         .messages
         .iter()
         .filter(|m| m.at < cutoff)
-        .flat_map(|m| [(ALEX, m.id), (SAM, m.id)])
+        .flat_map(|m| [(ALEX, m.id.clone()), (SAM, m.id.clone())])
         .collect();
-    reads.push((SAM, 5));
+    reads.push((SAM, id(5)));
 
     store.import(|tx| {
         for user in &users {
@@ -354,7 +371,7 @@ pub fn seed(store: &Store) -> Result<(), Error> {
             tx.comment(comment)?;
         }
         for (user, id) in reads {
-            tx.read(user, id)?;
+            tx.read(user, &id)?;
         }
         Ok(())
     })
diff --git a/src/jmap.rs b/src/jmap.rs
index a7b18b8..5e92729 100644
--- a/src/jmap.rs
+++ b/src/jmap.rs
@@ -1446,7 +1446,7 @@ impl Client {
         for p in &pending {
             let Some(memberships) = current.get(&p.jmap_id) else {
                 tracing::warn!(jmap_id = %p.jmap_id, "filing dropped: the mail is gone server-side");
-                clear.push(p.message);
+                clear.push(p.message.clone());
                 continue;
             };
             if p.folder
@@ -1454,7 +1454,7 @@ impl Client {
                 .is_some_and(|f| layout.folder_id(f).is_none())
             {
                 tracing::warn!(?p.folder, "filing dropped: the folder vanished server-side");
-                clear.push(p.message);
+                clear.push(p.message.clone());
                 continue;
             }
             if !filing_allowed(memberships, layout, p.folder.as_deref()) {
@@ -1482,7 +1482,10 @@ impl Client {
             // Refused ids clear too: the server's verdict stands, and
             // retrying would only requeue the refusal.
             clear.extend(chunk.iter().filter_map(|(id, _)| {
-                pending.iter().find(|p| &p.jmap_id == id).map(|p| p.message)
+                pending
+                    .iter()
+                    .find(|p| &p.jmap_id == id)
+                    .map(|p| p.message.clone())
             }));
         }
         store.clear_pending_files(&clear)?;
@@ -1528,7 +1531,7 @@ impl Client {
         for p in &pending {
             let Some(memberships) = current.get(&p.jmap_id) else {
                 tracing::warn!(jmap_id = %p.jmap_id, "archive dropped: the mail is gone server-side");
-                clear.push(p.message);
+                clear.push(p.message.clone());
                 continue;
             };
             let Some(next) = archived_mailboxes(memberships, layout) else {
@@ -1538,7 +1541,7 @@ impl Client {
                 if layout.archive.is_none() {
                     tracing::warn!(jmap_id = %p.jmap_id, "archive dropped: the server has no Archive mailbox");
                 }
-                clear.push(p.message);
+                clear.push(p.message.clone());
                 continue;
             };
             if !archive_allowed(layout) {
@@ -1561,7 +1564,10 @@ impl Client {
             // Refused ids clear too: the server's verdict stands, and
             // retrying would only requeue the refusal.
             clear.extend(chunk.iter().filter_map(|(id, _)| {
-                pending.iter().find(|p| &p.jmap_id == id).map(|p| p.message)
+                pending
+                    .iter()
+                    .find(|p| &p.jmap_id == id)
+                    .map(|p| p.message.clone())
             }));
         }
         store.clear_pending_archives(&clear)?;
@@ -1598,11 +1604,12 @@ impl Client {
             deleted = deleted.saturating_add(reply.destroyed.len());
             // Refused ids clear too: the server's verdict stands, and
             // retrying would only requeue the refusal.
-            clear.extend(
-                chunk
+            clear.extend(chunk.iter().filter_map(|id| {
+                pending
                     .iter()
-                    .filter_map(|id| pending.iter().find(|p| &p.jmap_id == id).map(|p| p.message)),
-            );
+                    .find(|p| &p.jmap_id == id)
+                    .map(|p| p.message.clone())
+            }));
         }
         store.clear_pending_deletes(&clear)?;
         Ok(deleted)
diff --git a/src/lists.rs b/src/lists.rs
index 791080c..33bc7c8 100644
--- a/src/lists.rs
+++ b/src/lists.rs
@@ -101,7 +101,7 @@ fn section(
     for m in msgs {
         if let Some(g) = groups.iter_mut().find(|g| g.thread.id == m.thread) {
             g.rows.push(m);
-        } else if let Some(thread) = store.thread(m.thread)? {
+        } else if let Some(thread) = store.thread(&m.thread)? {
             groups.push(Group {
                 thread,
                 rows: vec![m],
@@ -125,8 +125,9 @@ fn section(
 mod tests {
     use super::*;
     use crate::fixtures::{self, ALEX, SAM};
+    use crate::model::MessageId;
 
-    fn summary(store: &Store, user: &str, view: &View) -> Vec<(String, Vec<Vec<u32>>)> {
+    fn summary(store: &Store, user: &str, view: &View) -> Vec<(String, Vec<Vec<MessageId>>)> {
         let me = store.user(user).unwrap().unwrap();
         sections(store, &me, view)
             .unwrap()
@@ -136,7 +137,7 @@ mod tests {
                     s.head,
                     s.groups
                         .iter()
-                        .map(|g| g.rows.iter().map(|m| m.id).collect())
+                        .map(|g| g.rows.iter().map(|m| m.id.clone()).collect())
                         .collect(),
                 )
             })
@@ -157,15 +158,22 @@ mod tests {
         assert_eq!(heads(&store, SAM, &View::ForMe), ["INBOX", "DO", "WATCH"]);
         let (_, groups) = &sam[0];
         // Newest thread first; the roofing thread shows only its two Inbox emails.
-        assert_eq!(groups[0], vec![fixtures::WATER]);
-        assert!(groups.contains(&vec![3, 4]));
+        assert_eq!(groups[0], vec![fixtures::id(fixtures::WATER)]);
+        assert!(groups.contains(&vec![fixtures::id(3), fixtures::id(4)]));
 
         // Alex's own Inbox messages share the section with unassigned ones.
         let alex = summary(&store, ALEX, &View::ForMe);
         assert_eq!(heads(&store, ALEX, &View::ForMe), ["INBOX", "WAIT"]);
         assert_eq!(
             alex[0].1,
-            [vec![7], vec![6], vec![10], vec![8], vec![9], vec![3, 4]]
+            [
+                vec![fixtures::id(7)],
+                vec![fixtures::id(6)],
+                vec![fixtures::id(10)],
+                vec![fixtures::id(8)],
+                vec![fixtures::id(9)],
+                vec![fixtures::id(3), fixtures::id(4)]
+            ]
         );
     }
 
@@ -183,17 +191,17 @@ mod tests {
         // oldest first.
         let inbox = summary(&store, SAM, &View::Lane(State::Inbox));
         assert_eq!(heads(&store, SAM, &View::Lane(State::Inbox)), [""]);
-        assert_eq!(inbox[0].1[0], vec![fixtures::WATER]);
+        assert_eq!(inbox[0].1[0], vec![fixtures::id(fixtures::WATER)]);
         let todo = summary(&store, ALEX, &View::Lane(State::Do));
         assert_eq!(heads(&store, ALEX, &View::Lane(State::Do)), [""]);
-        assert_eq!(todo[0].1, [vec![13], vec![5]]);
+        assert_eq!(todo[0].1, [vec![fixtures::id(13)], vec![fixtures::id(5)]]);
         let wait = summary(&store, SAM, &View::Lane(State::Wait));
-        assert_eq!(wait[0].1, vec![vec![11]]);
+        assert_eq!(wait[0].1, vec![vec![fixtures::id(11)]]);
 
         let me = store.user(SAM).unwrap().unwrap();
         let watch = sections(&store, &me, &View::Lane(State::Watch)).unwrap();
         assert!(watch[0].compact);
-        assert_eq!(watch[0].groups[0].rows[0].id, 14);
+        assert_eq!(watch[0].groups[0].rows[0].id, fixtures::id(14));
         assert!(
             sections(&store, &me, &View::Lane(State::Done))
                 .unwrap()
@@ -206,11 +214,11 @@ mod tests {
     fn search_covers_every_message() {
         let store = fixtures::store().unwrap();
         let hits = summary(&store, SAM, &View::Search("downspout".into()));
-        assert_eq!(hits[0].1, vec![vec![2, 4]]);
+        assert_eq!(hits[0].1, vec![vec![fixtures::id(2), fixtures::id(4)]]);
         let by_sender = summary(&store, SAM, &View::Search("ALEX".into()));
-        assert_eq!(by_sender[0].1, vec![vec![12]]);
+        assert_eq!(by_sender[0].1, vec![vec![fixtures::id(12)]]);
         let by_subject = summary(&store, SAM, &View::Search("checkup".into()));
-        assert_eq!(by_subject[0].1, vec![vec![15]]);
+        assert_eq!(by_subject[0].1, vec![vec![fixtures::id(15)]]);
         assert!(summary(&store, SAM, &View::Search("  ".into())).is_empty());
         assert!(summary(&store, SAM, &View::Search("zzz".into())).is_empty());
     }
diff --git a/src/model.rs b/src/model.rs
index 389d9e9..817340b 100644
--- a/src/model.rs
+++ b/src/model.rs
@@ -7,9 +7,27 @@ use std::fmt;
 
 use jiff::civil::DateTime;
 
-pub type MessageId = u32;
-pub type ThreadId = u32;
-pub type CommentId = u32;
+pub type MessageId = String;
+pub type ThreadId = String;
+pub type CommentId = String;
+
+/// The reverse-hex alphabet jj's change ids use: nibble `0` renders as
+/// `z`, so the ids carry no order and no meaning.
+pub(crate) const REVERSE_HEX: [char; 16] = [
+    'z', 'y', 'x', 'w', 'v', 'u', 't', 's', 'r', 'q', 'p', 'o', 'n', 'm', 'l', 'k',
+];
+
+pub(crate) fn reverse_hex(bits: u64) -> String {
+    reverse_hex_bytes(&bits.to_be_bytes())
+}
+
+pub(crate) fn reverse_hex_bytes(bytes: &[u8]) -> String {
+    bytes
+        .iter()
+        .flat_map(|b| [b >> 4, b & 0xf])
+        .filter_map(|nibble| REVERSE_HEX.get(usize::from(nibble)).copied())
+        .collect()
+}
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
 pub enum State {
diff --git a/src/routes.rs b/src/routes.rs
index b63fa09..ca087d6 100644
--- a/src/routes.rs
+++ b/src/routes.rs
@@ -138,7 +138,10 @@ fn render(
     here: &str,
 ) -> Result<Markup, Error> {
     let store = &state.store;
-    let thread = selected.map(|id| open_thread(store, me, id)).transpose()?;
+    let thread = selected
+        .clone()
+        .map(|id| open_thread(store, me, &id))
+        .transpose()?;
     let nav = std::iter::once(View::ForMe)
         .chain(State::LANES.map(View::Lane))
         .map(|v| lists::count(store, me, &v).map(|n| (v, n)))
@@ -161,20 +164,22 @@ fn render(
 
 /// Opening a thread reads the selected message and the latest one, the two
 /// that open expanded.
-fn open_thread(store: &Store, me: &User, id: MessageId) -> Result<ThreadView, Error> {
+fn open_thread(store: &Store, me: &User, id: &str) -> Result<ThreadView, Error> {
     let msg = store.message(id)?.ok_or(Error::NotFound("message"))?;
     let latest = store
-        .thread_messages(msg.thread)?
+        .thread_messages(&msg.thread)?
         .last()
-        .map_or(id, |m| m.id);
+        .map_or_else(|| id.to_owned(), |m| m.id.clone());
     store.mark_read(&me.login, id)?;
-    store.mark_read(&me.login, latest)?;
+    store.mark_read(&me.login, &latest)?;
     Ok(ThreadView {
-        thread: store.thread(msg.thread)?.ok_or(Error::NotFound("thread"))?,
+        thread: store
+            .thread(&msg.thread)?
+            .ok_or(Error::NotFound("thread"))?,
         account: store
-            .thread_account(msg.thread)?
+            .thread_account(&msg.thread)?
             .ok_or(Error::NotFound("account"))?,
-        timeline: store.timeline(msg.thread)?,
+        timeline: store.timeline(&msg.thread)?,
         latest,
     })
 }
@@ -249,7 +254,7 @@ async fn edit(
     change: Change,
     back: &str,
 ) -> Result<Redirect, Error> {
-    state.store.edit(&me.login, id, change)?;
+    state.store.edit(&me.login, &id, change)?;
     Ok(Redirect::to(safe_back(back)))
 }
 
@@ -259,7 +264,7 @@ async fn delete_message(
     Path(id): Path<MessageId>,
     Form(form): Form<BackForm>,
 ) -> Result<Redirect, Error> {
-    state.store.delete(&me.login, id)?;
+    state.store.delete(&me.login, &id)?;
     Ok(Redirect::to(safe_back(&form.back)))
 }
 
@@ -275,7 +280,7 @@ async fn add_comment(
     Path(thread): Path<ThreadId>,
     Form(form): Form<CommentForm>,
 ) -> Result<Redirect, Error> {
-    state.store.add_comment(&me.login, thread, &form.text)?;
+    state.store.add_comment(&me.login, &thread, &form.text)?;
     Ok(Redirect::to(safe_back(&form.back)))
 }
 
diff --git a/src/store.rs b/src/store.rs
index 5e1433f..a306f06 100644
--- a/src/store.rs
+++ b/src/store.rs
@@ -14,8 +14,7 @@ use rusqlite::{Connection, OptionalExtension as _, Params, Row, ToSql, Transacti
 
 use crate::Error;
 use crate::model::{
-    Account, Comment, CommentId, Event, Kind, Message, MessageId, State, Thread, ThreadId, User,
-    Values,
+    Account, Comment, Event, Kind, Message, MessageId, State, Thread, User, Values,
 };
 
 /// Applied in order; `PRAGMA user_version` counts how many have run.
@@ -25,6 +24,7 @@ const MIGRATIONS: &[&str] = &[
     include_str!("../migrations/0003_pending_files.sql"),
     include_str!("../migrations/0004_pending_deletes.sql"),
     include_str!("../migrations/0005_pending_archives.sql"),
+    include_str!("../migrations/0006_jj_style_ids.sql"),
 ];
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -208,7 +208,7 @@ impl Store {
         Ok(found)
     }
 
-    pub fn thread(&self, id: ThreadId) -> Result<Option<Thread>, Error> {
+    pub fn thread(&self, id: &str) -> Result<Option<Thread>, Error> {
         let thread = self
             .lock()
             .conn
@@ -227,16 +227,16 @@ impl Store {
         Ok(thread)
     }
 
-    pub fn thread_account(&self, thread: ThreadId) -> Result<Option<Account>, Error> {
+    pub fn thread_account(&self, thread: &str) -> Result<Option<Account>, Error> {
         load_thread_account(&self.lock().conn, thread)
     }
 
-    pub fn message(&self, id: MessageId) -> Result<Option<Message>, Error> {
+    pub fn message(&self, id: &str) -> Result<Option<Message>, Error> {
         load_message(&self.lock().conn, id)
     }
 
     /// The thread's messages, oldest first.
-    pub fn thread_messages(&self, thread: ThreadId) -> Result<Vec<Message>, Error> {
+    pub fn thread_messages(&self, thread: &str) -> Result<Vec<Message>, Error> {
         load_messages(&self.lock().conn, "m.thread = ?1", [thread])
     }
 
@@ -270,11 +270,11 @@ impl Store {
 
     /// The thread's messages and comments in time order. A message sorts
     /// before a comment made at the same moment.
-    pub fn timeline(&self, thread: ThreadId) -> Result<Vec<Item>, Error> {
+    pub fn timeline(&self, thread: &str) -> Result<Vec<Item>, Error> {
         let inner = self.lock();
         let messages = load_messages(&inner.conn, "m.thread = ?1", [thread])?;
         let mut stmt = inner.conn.prepare_cached(
-            "SELECT id, thread, author, at, text FROM comments WHERE thread = ?1 ORDER BY at, id",
+            "SELECT id, thread, author, at, text FROM comments WHERE thread = ?1 ORDER BY at, rowid",
         )?;
         let comments = stmt
             .query_map([thread], |r| {
@@ -312,15 +312,15 @@ impl Store {
         Ok(ids)
     }
 
-    pub fn mark_read(&self, user: &str, msg: MessageId) -> Result<(), Error> {
+    pub fn mark_read(&self, user: &str, msg: &str) -> Result<(), Error> {
         insert_read(&self.lock().conn, user, msg)
     }
 
     /// Every change made to the message's values, oldest first.
-    pub fn history(&self, message: MessageId) -> Result<Vec<Event>, Error> {
+    pub fn history(&self, message: &str) -> Result<Vec<Event>, Error> {
         let inner = self.lock();
         let mut stmt = inner.conn.prepare_cached(
-            "SELECT message, user, at, event FROM history WHERE message = ?1 ORDER BY id",
+            "SELECT message, user, at, event FROM history WHERE message = ?1 ORDER BY rowid",
         )?;
         let events = stmt
             .query_map([message], |r| {
@@ -335,11 +335,11 @@ impl Store {
         Ok(events)
     }
 
-    pub fn edit(&self, user: &str, id: MessageId, change: Change) -> Result<(), Error> {
+    pub fn edit(&self, user: &str, id: &str, change: Change) -> Result<(), Error> {
         let mut inner = self.lock();
         let tx = inner.conn.transaction()?;
         let msg = load_message(&tx, id)?.ok_or(Error::NotFound("message"))?;
-        let read_only = load_thread_account(&tx, msg.thread)?.is_some_and(|a| a.read_only);
+        let read_only = load_thread_account(&tx, &msg.thread)?.is_some_and(|a| a.read_only);
         let Kind::Received { values, .. } = msg.kind else {
             return Err(Error::BadRequest("sent messages have no values"));
         };
@@ -398,7 +398,7 @@ impl Store {
         tx.commit()?;
         inner
             .undo
-            .insert(user.to_owned(), (id, prev, queued_archive));
+            .insert(user.to_owned(), (id.to_owned(), prev, queued_archive));
         let flash = if queued_archive {
             // The verb is Done; the archive is what it does.
             "Done — leaves the shared Inbox".to_owned()
@@ -427,18 +427,18 @@ impl Store {
         // poll already pushed it, the mail sits in Archive and the
         // state simply reverts.
         if queued_archive {
-            tx.execute("DELETE FROM pending_archives WHERE message = ?1", [id])?;
+            tx.execute("DELETE FROM pending_archives WHERE message = ?1", [&id])?;
         }
         // An undone filing reverts server-side too: the queued intent
         // points back at the folder the undo restores.
-        let msg = load_message(&tx, id)?.ok_or(Error::NotFound("message"))?;
+        let msg = load_message(&tx, &id)?.ok_or(Error::NotFound("message"))?;
         if let Kind::Received { values, .. } = &msg.kind
             && values.folder != prev.folder
         {
-            queue_file(&tx, id, prev.folder.as_deref())?;
+            queue_file(&tx, &id, prev.folder.as_deref())?;
         }
-        write_values(&tx, id, &prev)?;
-        insert_event(&tx, id, user, self.now(), "Undone")?;
+        write_values(&tx, &id, &prev)?;
+        insert_event(&tx, &id, user, self.now(), "Undone")?;
         tx.commit()?;
         inner.undo.remove(user);
         inner.flash.insert(
@@ -491,11 +491,11 @@ impl Store {
     /// destroy server-side — which lands it in Trash — while the
     /// thread goes Done here with a history event. Any queued filing
     /// or archive is superseded.
-    pub fn delete(&self, user: &str, id: MessageId) -> Result<(), Error> {
+    pub fn delete(&self, user: &str, id: &str) -> Result<(), Error> {
         let mut inner = self.lock();
         let tx = inner.conn.transaction()?;
         let msg = load_message(&tx, id)?.ok_or(Error::NotFound("message"))?;
-        let read_only = load_thread_account(&tx, msg.thread)?.is_some_and(|a| a.read_only);
+        let read_only = load_thread_account(&tx, &msg.thread)?.is_some_and(|a| a.read_only);
         if read_only {
             return Err(Error::Forbidden("this account is read-only"));
         }
@@ -601,7 +601,7 @@ impl Store {
         self.lock().flash.remove(user)
     }
 
-    pub fn add_comment(&self, user: &str, thread: ThreadId, text: &str) -> Result<(), Error> {
+    pub fn add_comment(&self, user: &str, thread: &str, text: &str) -> Result<(), Error> {
         let text = text.trim();
         if text.is_empty() {
             return Err(Error::BadRequest("comment is empty"));
@@ -788,16 +788,16 @@ impl Import<'_> {
             return Err(Error::NotFound("thread"));
         }
         if let Some(values) = m.values() {
-            write_values(&self.tx, m.id, values)?;
+            write_values(&self.tx, &m.id, values)?;
         }
         Ok(())
     }
 
     pub fn comment(&self, c: &Comment) -> Result<(), Error> {
-        insert_comment(&self.tx, Some(c.id), c.thread, &c.author, c.at, &c.text)
+        insert_comment(&self.tx, Some(&c.id), &c.thread, &c.author, c.at, &c.text)
     }
 
-    pub fn read(&self, user: &str, msg: MessageId) -> Result<(), Error> {
+    pub fn read(&self, user: &str, msg: &str) -> Result<(), Error> {
         insert_read(&self.tx, user, msg)
     }
 
@@ -809,9 +809,15 @@ impl Import<'_> {
     /// refreshes.
     pub fn incoming(&self, account: &str, mail: &Incoming) -> Result<(), Error> {
         self.tx.execute(
-            "INSERT INTO threads (account, subject, jmap_thread_id) VALUES (?1, ?2, ?3)
+            "INSERT INTO threads (id, account, subject, jmap_thread_id)
+             VALUES (?1, ?2, ?3, ?4)
              ON CONFLICT (account, jmap_thread_id) DO NOTHING",
-            params![account, mail.subject, mail.jmap_thread_id],
+            params![
+                fresh_id(&self.tx)?,
+                account,
+                mail.subject,
+                mail.jmap_thread_id
+            ],
         )?;
         let (kind, from_name, from_addr, state, folder, sent_by, sent_to) = match &mail.kind {
             IncomingKind::Received {
@@ -841,10 +847,10 @@ impl Import<'_> {
         let cc = json(&mail.cc)?;
         let bcc = json(&mail.bcc)?;
         let _upserted = self.tx.execute(
-            "INSERT INTO messages (account, message_id, jmap_id, thread, at, cc, bcc, body,
+            "INSERT INTO messages (id, account, message_id, jmap_id, thread, at, cc, bcc, body,
                 kind, from_name, from_addr, state, folder, sent_by, sent_to)
-             SELECT ?1, ?2, ?3, t.id, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15
-             FROM threads t WHERE t.account = ?1 AND t.jmap_thread_id = ?4
+             SELECT ?1, ?2, ?3, ?4, t.id, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16
+             FROM threads t WHERE t.account = ?2 AND t.jmap_thread_id = ?5
              ON CONFLICT (account, message_id) DO UPDATE SET
                  jmap_id = excluded.jmap_id,
                  thread = excluded.thread,
@@ -858,6 +864,7 @@ impl Import<'_> {
                  sent_by = excluded.sent_by,
                  sent_to = excluded.sent_to",
             params![
+                fresh_id(&self.tx)?,
                 account,
                 mail.message_id,
                 mail.jmap_id,
@@ -929,7 +936,7 @@ fn load_user(conn: &Connection, login: &str) -> Result<Option<User>, Error> {
     Ok(user)
 }
 
-fn load_thread_account(conn: &Connection, thread: ThreadId) -> Result<Option<Account>, Error> {
+fn load_thread_account(conn: &Connection, thread: &str) -> Result<Option<Account>, Error> {
     let account = conn
         .query_row(
             "SELECT a.slug, a.name, a.address, a.read_only
@@ -959,7 +966,7 @@ fn folder_exists(conn: &Connection, name: &str) -> Result<bool, Error> {
     Ok(exists)
 }
 
-fn load_message(conn: &Connection, id: MessageId) -> Result<Option<Message>, Error> {
+fn load_message(conn: &Connection, id: &str) -> Result<Option<Message>, Error> {
     Ok(load_messages(conn, "m.id = ?1", [id])?.into_iter().next())
 }
 
@@ -1017,7 +1024,7 @@ fn message_from_row(r: &Row<'_>) -> rusqlite::Result<Message> {
     })
 }
 
-fn write_values(conn: &Connection, id: MessageId, values: &Values) -> Result<(), Error> {
+fn write_values(conn: &Connection, id: &str, values: &Values) -> Result<(), Error> {
     conn.execute(
         "UPDATE messages SET state = ?2, folder = ?3 WHERE id = ?1",
         params![id, values.state, values.folder],
@@ -1035,7 +1042,7 @@ fn write_values(conn: &Connection, id: MessageId, values: &Values) -> Result<(),
 /// Records a filing for the poll loop to push (task rn). Fixture mail
 /// has no server side to move, so rows without a cached JMAP id stay
 /// out.
-fn queue_file(conn: &Connection, id: MessageId, folder: Option<&str>) -> Result<(), Error> {
+fn queue_file(conn: &Connection, id: &str, folder: Option<&str>) -> Result<(), Error> {
     conn.execute(
         "INSERT INTO pending_files (message, folder)
          SELECT ?1, ?2 FROM messages WHERE id = ?1 AND jmap_id IS NOT NULL
@@ -1048,7 +1055,7 @@ fn queue_file(conn: &Connection, id: MessageId, folder: Option<&str>) -> Result<
 /// Queues the message's exit from the shared Inbox (Done's meaning on
 /// a writable account). Fixture mail has no server side to leave, so
 /// rows without a cached JMAP id stay out.
-fn queue_archive(conn: &Connection, id: MessageId) -> Result<(), Error> {
+fn queue_archive(conn: &Connection, id: &str) -> Result<(), Error> {
     conn.execute(
         "INSERT INTO pending_archives (message)
          SELECT ?1 FROM messages WHERE id = ?1 AND jmap_id IS NOT NULL
@@ -1060,34 +1067,34 @@ fn queue_archive(conn: &Connection, id: MessageId) -> Result<(), Error> {
 
 fn insert_event(
     conn: &Connection,
-    message: MessageId,
+    message: &str,
     user: &str,
     at: DateTime,
     text: &str,
 ) -> Result<(), Error> {
     conn.execute(
-        "INSERT INTO history (message, user, at, event) VALUES (?1, ?2, ?3, ?4)",
-        params![message, user, at, text],
+        "INSERT INTO history (id, message, user, at, event) VALUES (?1, ?2, ?3, ?4, ?5)",
+        params![fresh_id(conn)?, message, user, at, text],
     )?;
     Ok(())
 }
 
 fn insert_comment(
     conn: &Connection,
-    id: Option<CommentId>,
-    thread: ThreadId,
+    id: Option<&str>,
+    thread: &str,
     author: &str,
     at: DateTime,
     text: &str,
 ) -> Result<(), Error> {
     conn.execute(
         "INSERT INTO comments (id, thread, author, at, text) VALUES (?1, ?2, ?3, ?4, ?5)",
-        params![id, thread, author, at, text],
+        params![id.unwrap_or(&fresh_id(conn)?), thread, author, at, text],
     )?;
     Ok(())
 }
 
-fn insert_read(conn: &Connection, user: &str, msg: MessageId) -> Result<(), Error> {
+fn insert_read(conn: &Connection, user: &str, msg: &str) -> Result<(), Error> {
     conn.execute(
         "INSERT OR IGNORE INTO reads (user, message) VALUES (?1, ?2)",
         params![user, msg],
@@ -1095,6 +1102,14 @@ fn insert_read(conn: &Connection, user: &str, msg: MessageId) -> Result<(), Erro
     Ok(())
 }
 
+/// A fresh row id: 64 random bits as jj-style reverse hex — jj's
+/// change-id alphabet, where nibble `0` renders as `z` — giving 16
+/// characters that carry no order and no meaning.
+fn fresh_id(conn: &Connection) -> Result<String, Error> {
+    let bytes: Vec<u8> = conn.query_row("SELECT randomblob(8)", [], |r| r.get(0))?;
+    Ok(crate::model::reverse_hex_bytes(&bytes))
+}
+
 /// Lists of names and addresses are stored as JSON arrays.
 fn json<T: serde::Serialize>(value: &T) -> rusqlite::Result<String> {
     serde_json::to_string(value).map_err(|e| rusqlite::Error::ToSqlConversionFailure(e.into()))
@@ -1124,7 +1139,7 @@ mod tests {
     use super::*;
     use crate::fixtures::{self, ALEX, SAM};
 
-    fn values(store: &Store, id: MessageId) -> Values {
+    fn values(store: &Store, id: &str) -> Values {
         store
             .message(id)
             .unwrap()
@@ -1137,8 +1152,10 @@ mod tests {
     #[test]
     fn edits_record_undo_flash_and_history() {
         let store = fixtures::store().unwrap();
-        store.edit(SAM, 4, Change::State(State::Do)).unwrap();
-        assert_eq!(values(&store, 4).state, State::Do);
+        store
+            .edit(SAM, &fixtures::id(4), Change::State(State::Do))
+            .unwrap();
+        assert_eq!(values(&store, &fixtures::id(4)).state, State::Do);
         assert_eq!(
             store.take_flash(SAM),
             Some(Flash {
@@ -1149,12 +1166,12 @@ mod tests {
         assert_eq!(store.take_flash(SAM), None);
 
         store.undo(SAM).unwrap();
-        assert_eq!(values(&store, 4).state, State::Inbox);
+        assert_eq!(values(&store, &fixtures::id(4)).state, State::Inbox);
         assert_eq!(store.take_flash(SAM).unwrap().text, "Undone");
         assert!(matches!(store.undo(SAM), Err(Error::BadRequest(_))));
 
         let history: Vec<_> = store
-            .history(4)
+            .history(&fixtures::id(4))
             .unwrap()
             .into_iter()
             .map(|e| (e.user, e.at, e.text))
@@ -1188,18 +1205,19 @@ mod tests {
             .unwrap()
             .first()
             .unwrap()
-            .id;
+            .id
+            .clone();
         store.sign_in(SAM, "Sam").unwrap();
 
         // Filing queues the intent, and the latest one wins.
         store
-            .edit(SAM, id, Change::Folder(Some("House".into())))
+            .edit(SAM, &id, Change::Folder(Some("House".into())))
             .unwrap();
-        store.edit(SAM, id, Change::Folder(None)).unwrap();
+        store.edit(SAM, &id, Change::Folder(None)).unwrap();
         assert_eq!(
             store.pending_files("household").unwrap(),
             [PendingFile {
-                message: id,
+                message: id.clone(),
                 jmap_id: "E9".into(),
                 folder: None,
             }]
@@ -1209,13 +1227,13 @@ mod tests {
         assert!(store.pending_files("eli").unwrap().is_empty());
 
         // What the poll loop pushed, it clears.
-        store.clear_pending_files(&[id]).unwrap();
+        store.clear_pending_files(&[id.clone()]).unwrap();
         assert!(store.pending_files("household").unwrap().is_empty());
         store.clear_pending_files(&[]).unwrap();
 
         // Undoing a filing re-points the queue at the restored folder.
         store
-            .edit(SAM, id, Change::Folder(Some("House".into())))
+            .edit(SAM, &id, Change::Folder(Some("House".into())))
             .unwrap();
         assert_eq!(
             store.pending_files("household").unwrap()[0].folder,
@@ -1231,11 +1249,11 @@ mod tests {
             "CREATE TRIGGER no_pending BEFORE INSERT ON pending_files BEGIN SELECT RAISE(ABORT, 'no'); END;",
         );
         assert!(matches!(
-            store.edit(SAM, id, Change::Folder(Some("House".into()))),
+            store.edit(SAM, &id, Change::Folder(Some("House".into()))),
             Err(Error::Db(_))
         ));
         exec(&store, "DROP TRIGGER no_pending;");
-        assert_eq!(values(&store, id).folder, None);
+        assert_eq!(values(&store, &id).folder, None);
     }
 
     #[test]
@@ -1251,7 +1269,11 @@ mod tests {
     fn fixture_mail_never_queues_a_filing() {
         let store = fixtures::store().unwrap();
         store
-            .edit(SAM, 4, Change::Folder(Some("Finance".into())))
+            .edit(
+                SAM,
+                &fixtures::id(4),
+                Change::Folder(Some("Finance".into())),
+            )
             .unwrap();
         // No server side to move, so no row: fixture mail has no cached
         // JMAP id.
@@ -1262,14 +1284,23 @@ mod tests {
     fn folders() {
         let store = fixtures::store().unwrap();
         store
-            .edit(SAM, 4, Change::Folder(Some("Finance".into())))
+            .edit(
+                SAM,
+                &fixtures::id(4),
+                Change::Folder(Some("Finance".into())),
+            )
+            .unwrap();
+        assert_eq!(
+            values(&store, &fixtures::id(4)).folder.as_deref(),
+            Some("Finance")
+        );
+        store
+            .edit(SAM, &fixtures::id(4), Change::Folder(None))
             .unwrap();
-        assert_eq!(values(&store, 4).folder.as_deref(), Some("Finance"));
-        store.edit(SAM, 4, Change::Folder(None)).unwrap();
-        assert_eq!(values(&store, 4).folder, None);
+        assert_eq!(values(&store, &fixtures::id(4)).folder, None);
         assert_eq!(store.take_flash(SAM).unwrap().text, "Removed from folder");
         assert!(matches!(
-            store.edit(SAM, 4, Change::Folder(Some("Nope".into()))),
+            store.edit(SAM, &fixtures::id(4), Change::Folder(Some("Nope".into()))),
             Err(Error::NotFound("folder"))
         ));
         assert_eq!(
@@ -1297,17 +1328,18 @@ mod tests {
             .unwrap()
             .first()
             .unwrap()
-            .id;
+            .id
+            .clone();
         store.sign_in(SAM, "Sam").unwrap();
 
         // Done on a writable account queues the archive; the history
         // names the verb, the flash teaches what it does.
-        store.edit(SAM, id, Change::State(State::Done)).unwrap();
-        assert_eq!(values(&store, id).state, State::Done);
+        store.edit(SAM, &id, Change::State(State::Done)).unwrap();
+        assert_eq!(values(&store, &id).state, State::Done);
         assert_eq!(
             store.pending_archives("household").unwrap(),
             [PendingArchive {
-                message: id,
+                message: id.clone(),
                 jmap_id: "E9".into(),
             }]
         );
@@ -1319,7 +1351,7 @@ mod tests {
             }
         );
         let history: Vec<_> = store
-            .history(id)
+            .history(&id)
             .unwrap()
             .into_iter()
             .map(|e| (e.user, e.text))
@@ -1328,20 +1360,20 @@ mod tests {
 
         // Undo reverts the state and cancels the queued exit.
         store.undo(SAM).unwrap();
-        assert_eq!(values(&store, id).state, State::Inbox);
+        assert_eq!(values(&store, &id).state, State::Inbox);
         assert!(store.pending_archives("household").unwrap().is_empty());
 
         // Another account's drain never sees them; what the poll
         // pushed, it clears.
-        store.edit(SAM, id, Change::State(State::Done)).unwrap();
+        store.edit(SAM, &id, Change::State(State::Done)).unwrap();
         assert!(store.pending_archives("eli").unwrap().is_empty());
-        store.clear_pending_archives(&[id]).unwrap();
+        store.clear_pending_archives(&[id.clone()]).unwrap();
         assert!(store.pending_archives("household").unwrap().is_empty());
         store.clear_pending_archives(&[]).unwrap();
 
         // Deleting a Done message supersedes its queued archive.
-        store.edit(SAM, id, Change::State(State::Done)).unwrap();
-        store.delete(SAM, id).unwrap();
+        store.edit(SAM, &id, Change::State(State::Done)).unwrap();
+        store.delete(SAM, &id).unwrap();
         assert!(store.pending_archives("household").unwrap().is_empty());
         assert_eq!(store.pending_deletes("household").unwrap().len(), 1);
 
@@ -1351,13 +1383,13 @@ mod tests {
             &store,
             "CREATE TRIGGER no_pending_archive BEFORE INSERT ON pending_archives BEGIN SELECT RAISE(ABORT, 'no'); END;",
         );
-        store.edit(SAM, id, Change::State(State::Inbox)).unwrap();
+        store.edit(SAM, &id, Change::State(State::Inbox)).unwrap();
         assert!(matches!(
-            store.edit(SAM, id, Change::State(State::Done)),
+            store.edit(SAM, &id, Change::State(State::Done)),
             Err(Error::Db(_))
         ));
         exec(&store, "DROP TRIGGER no_pending_archive;");
-        assert_eq!(values(&store, id).state, State::Inbox);
+        assert_eq!(values(&store, &id).state, State::Inbox);
     }
 
     #[test]
@@ -1365,11 +1397,13 @@ mod tests {
         // Read-only accounts and fixture mail mark Done without queueing
         // anything.
         let store = fixtures::store().unwrap();
-        let eli = fixtures::ELI_PRACTICE;
-        store.edit(SAM, eli, Change::State(State::Done)).unwrap();
+        let eli = fixtures::id(fixtures::ELI_PRACTICE);
+        store.edit(SAM, &eli, Change::State(State::Done)).unwrap();
         assert!(store.pending_archives("eli").unwrap().is_empty());
         assert_eq!(store.take_flash(SAM).unwrap().text, "Moved to Done");
-        store.edit(SAM, 4, Change::State(State::Done)).unwrap();
+        store
+            .edit(SAM, &fixtures::id(4), Change::State(State::Done))
+            .unwrap();
         assert!(store.pending_archives("household").unwrap().is_empty());
     }
 
@@ -1402,19 +1436,20 @@ mod tests {
             .unwrap()
             .first()
             .unwrap()
-            .id;
+            .id
+            .clone();
         store.sign_in(SAM, "Sam").unwrap();
 
         // A queued filing is superseded: the destroy replaces it.
         store
-            .edit(SAM, id, Change::Folder(Some("House".into())))
+            .edit(SAM, &id, Change::Folder(Some("House".into())))
             .unwrap();
-        store.delete(SAM, id).unwrap();
-        assert_eq!(values(&store, id).state, State::Done);
+        store.delete(SAM, &id).unwrap();
+        assert_eq!(values(&store, &id).state, State::Done);
         assert_eq!(
             store.pending_deletes("household").unwrap(),
             [PendingDelete {
-                message: id,
+                message: id.clone(),
                 jmap_id: "E9".into(),
             }]
         );
@@ -1427,7 +1462,7 @@ mod tests {
             })
         );
         let history: Vec<_> = store
-            .history(id)
+            .history(&id)
             .unwrap()
             .into_iter()
             .map(|e| (e.user, e.text))
@@ -1443,7 +1478,7 @@ mod tests {
         // Another account's drain never sees them; what the poll
         // pushed, it clears.
         assert!(store.pending_deletes("eli").unwrap().is_empty());
-        store.clear_pending_deletes(&[id]).unwrap();
+        store.clear_pending_deletes(&[id.clone()]).unwrap();
         assert!(store.pending_deletes("household").unwrap().is_empty());
         store.clear_pending_deletes(&[]).unwrap();
 
@@ -1452,10 +1487,10 @@ mod tests {
             &store,
             "CREATE TRIGGER no_pending_delete BEFORE INSERT ON pending_deletes BEGIN SELECT RAISE(ABORT, 'no'); END;",
         );
-        store.edit(SAM, id, Change::State(State::Inbox)).unwrap();
-        assert!(matches!(store.delete(SAM, id), Err(Error::Db(_))));
+        store.edit(SAM, &id, Change::State(State::Inbox)).unwrap();
+        assert!(matches!(store.delete(SAM, &id), Err(Error::Db(_))));
         exec(&store, "DROP TRIGGER no_pending_delete;");
-        assert_eq!(values(&store, id).state, State::Inbox);
+        assert_eq!(values(&store, &id).state, State::Inbox);
     }
 
     #[test]
@@ -1470,13 +1505,13 @@ mod tests {
     #[test]
     fn read_only_accounts_cannot_file() {
         let store = fixtures::store().unwrap();
-        let eli = fixtures::ELI_PRACTICE;
+        let eli = fixtures::id(fixtures::ELI_PRACTICE);
         assert!(matches!(
-            store.edit(SAM, eli, Change::Folder(None)),
+            store.edit(SAM, &eli, Change::Folder(None)),
             Err(Error::Forbidden(_))
         ));
-        assert!(matches!(store.delete(SAM, eli), Err(Error::Forbidden(_))));
-        store.edit(SAM, eli, Change::State(State::Done)).unwrap();
+        assert!(matches!(store.delete(SAM, &eli), Err(Error::Forbidden(_))));
+        store.edit(SAM, &eli, Change::State(State::Done)).unwrap();
     }
 
     #[test]
@@ -1484,28 +1519,31 @@ mod tests {
         let store = fixtures::store().unwrap();
         // Fixture mail has no server side to trash, so no row queues —
         // but the thread still goes Done.
-        store.delete(SAM, 4).unwrap();
-        assert_eq!(values(&store, 4).state, State::Done);
+        store.delete(SAM, &fixtures::id(4)).unwrap();
+        assert_eq!(values(&store, &fixtures::id(4)).state, State::Done);
         assert!(store.pending_deletes("household").unwrap().is_empty());
         // Sent replies carry no values to transition.
-        assert!(matches!(store.delete(SAM, 2), Err(Error::BadRequest(_))));
+        assert!(matches!(
+            store.delete(SAM, &fixtures::id(2)),
+            Err(Error::BadRequest(_))
+        ));
     }
 
     #[test]
     fn assignees_toggle() {
         let store = fixtures::store().unwrap();
         store
-            .edit(ALEX, 4, Change::ToggleAssignee(ALEX.into()))
+            .edit(ALEX, &fixtures::id(4), Change::ToggleAssignee(ALEX.into()))
             .unwrap();
-        assert!(values(&store, 4).assignees.contains(ALEX));
+        assert!(values(&store, &fixtures::id(4)).assignees.contains(ALEX));
         assert_eq!(store.take_flash(ALEX).unwrap().text, "Assigned Alex");
         store
-            .edit(ALEX, 4, Change::ToggleAssignee(ALEX.into()))
+            .edit(ALEX, &fixtures::id(4), Change::ToggleAssignee(ALEX.into()))
             .unwrap();
-        assert!(values(&store, 4).assignees.is_empty());
+        assert!(values(&store, &fixtures::id(4)).assignees.is_empty());
         assert_eq!(store.take_flash(ALEX).unwrap().text, "Unassigned Alex");
         assert!(matches!(
-            store.edit(ALEX, 4, Change::ToggleAssignee("eli".into())),
+            store.edit(ALEX, &fixtures::id(4), Change::ToggleAssignee("eli".into())),
             Err(Error::NotFound("user"))
         ));
     }
@@ -1514,11 +1552,11 @@ mod tests {
     fn bad_edits() {
         let store = fixtures::store().unwrap();
         assert!(matches!(
-            store.edit(SAM, 999, Change::State(State::Do)),
+            store.edit(SAM, &fixtures::id(999), Change::State(State::Do)),
             Err(Error::NotFound("message"))
         ));
         assert!(matches!(
-            store.edit(SAM, 2, Change::State(State::Do)),
+            store.edit(SAM, &fixtures::id(2), Change::State(State::Do)),
             Err(Error::BadRequest(_))
         ));
     }
@@ -1526,17 +1564,19 @@ mod tests {
     #[test]
     fn comments() {
         let store = fixtures::store().unwrap();
-        let before = store.timeline(1).unwrap().len();
-        store.add_comment(SAM, 1, "  Called them.  ").unwrap();
-        let timeline = store.timeline(1).unwrap();
+        let before = store.timeline(&fixtures::id(1)).unwrap().len();
+        store
+            .add_comment(SAM, &fixtures::id(1), "  Called them.  ")
+            .unwrap();
+        let timeline = store.timeline(&fixtures::id(1)).unwrap();
         assert_eq!(timeline.len(), before + 1);
         assert!(matches!(timeline.last(), Some(Item::Comment(c)) if c.text == "Called them."));
         assert!(matches!(
-            store.add_comment(SAM, 1, "   "),
+            store.add_comment(SAM, &fixtures::id(1), "   "),
             Err(Error::BadRequest(_))
         ));
         assert!(matches!(
-            store.add_comment(SAM, 999, "hi"),
+            store.add_comment(SAM, &fixtures::id(999), "hi"),
             Err(Error::NotFound("thread"))
         ));
     }
@@ -1545,7 +1585,7 @@ mod tests {
     fn timeline_interleaves_comments_by_time() {
         let store = fixtures::store().unwrap();
         let order: Vec<_> = store
-            .timeline(1)
+            .timeline(&fixtures::id(1))
             .unwrap()
             .into_iter()
             .map(|item| match item {
@@ -1553,33 +1593,61 @@ mod tests {
                 Item::Comment(c) => format!("c{}", c.id),
             })
             .collect();
-        assert_eq!(order, ["m1", "c1", "m2", "m3", "m4"]);
+        assert_eq!(
+            order,
+            [
+                format!("m{}", fixtures::id(1)),
+                format!("c{}", fixtures::id(1)),
+                format!("m{}", fixtures::id(2)),
+                format!("m{}", fixtures::id(3)),
+                format!("m{}", fixtures::id(4)),
+            ]
+        );
     }
 
     #[test]
     fn read_tracking() {
         let store = fixtures::store().unwrap();
-        assert!(store.unread(ALEX).unwrap().contains(&fixtures::WATER));
-        store.mark_read(ALEX, fixtures::WATER).unwrap();
-        store.mark_read(ALEX, fixtures::WATER).unwrap();
-        assert!(!store.unread(ALEX).unwrap().contains(&fixtures::WATER));
-        assert!(store.unread(SAM).unwrap().contains(&fixtures::WATER));
+        assert!(
+            store
+                .unread(ALEX)
+                .unwrap()
+                .contains(&fixtures::id(fixtures::WATER))
+        );
+        store
+            .mark_read(ALEX, &fixtures::id(fixtures::WATER))
+            .unwrap();
+        store
+            .mark_read(ALEX, &fixtures::id(fixtures::WATER))
+            .unwrap();
+        assert!(
+            !store
+                .unread(ALEX)
+                .unwrap()
+                .contains(&fixtures::id(fixtures::WATER))
+        );
+        assert!(
+            store
+                .unread(SAM)
+                .unwrap()
+                .contains(&fixtures::id(fixtures::WATER))
+        );
         // Sent messages are never unread.
-        assert!(!store.unread(SAM).unwrap().contains(&2));
+        assert!(!store.unread(SAM).unwrap().contains(&fixtures::id(2)));
     }
 
     #[test]
     fn messages_round_trip() {
         let store = fixtures::store().unwrap();
-        let sent = store.message(2).unwrap().unwrap();
-        assert_eq!(sent.message_id, "2@fixtures.docket.invalid");
+        let sent = store.message(&fixtures::id(2)).unwrap().unwrap();
+        assert_eq!(sent.message_id, fixtures::message_id(2));
         assert_eq!(sent.cc, ["Alex"]);
         assert_eq!(sent.bcc, ["Pat Lee"]);
         assert!(
             matches!(&sent.kind, Kind::Sent { by, to } if by == &Some(SAM.to_owned())
                 && to == &["Northwind Roofing".to_string()])
         );
-        let got = store.message(5).unwrap().unwrap();
+        let got = store.message(&fixtures::id(5)).unwrap().unwrap();
         assert_eq!(got.at, jiff::civil::date(2026, 9, 30).at(15, 30, 0, 0));
         assert_eq!(
             got.values(),
@@ -1589,7 +1657,7 @@ mod tests {
                 assignees: BTreeSet::from([SAM.to_owned()]),
             })
         );
-        assert!(store.message(999).unwrap().is_none());
+        assert!(store.message(&fixtures::id(999)).unwrap().is_none());
     }
 
     #[test]
@@ -1614,12 +1682,18 @@ mod tests {
         assert_eq!(store.user(SAM).unwrap().unwrap().slug, "Sam");
         assert!(store.user("pat@example.com").unwrap().is_none());
         assert_eq!(
-            store.thread(1).unwrap().unwrap().subject,
+            store.thread(&fixtures::id(1)).unwrap().unwrap().subject,
             "Gutter repair estimate"
         );
-        assert!(store.thread(999).unwrap().is_none());
-        assert!(store.thread_account(11).unwrap().unwrap().read_only);
-        assert!(store.thread_account(999).unwrap().is_none());
+        assert!(store.thread(&fixtures::id(999)).unwrap().is_none());
+        assert!(
+            store
+                .thread_account(&fixtures::id(11))
+                .unwrap()
+                .unwrap()
+                .read_only
+        );
+        assert!(store.thread_account(&fixtures::id(999)).unwrap().is_none());
     }
 
     #[test]
@@ -1642,7 +1716,7 @@ mod tests {
         store
             .import(|tx| {
                 tx.thread(&Thread {
-                    id: 1,
+                    id: fixtures::id(1),
                     account: "eli".into(),
                     subject: "Practice".into(),
                 })
@@ -1650,7 +1724,13 @@ mod tests {
             .unwrap();
 
         assert_eq!(store.accounts().unwrap(), [account]);
-        assert!(!store.thread_account(1).unwrap().unwrap().read_only);
+        assert!(
+            !store
+                .thread_account(&fixtures::id(1))
+                .unwrap()
+                .unwrap()
+                .read_only
+        );
     }
 
     /// The shape of a JMAP delivery, for the incoming() tests.
@@ -1725,10 +1805,11 @@ mod tests {
             .unwrap()
             .first()
             .unwrap()
-            .id;
+            .id
+            .clone();
         // Triaged in Docket after the import: the state moves here.
         store.sign_in(ALEX, "Alex").unwrap();
-        store.edit(ALEX, id, Change::State(State::Do)).unwrap();
+        store.edit(ALEX, &id, Change::State(State::Do)).unwrap();
 
         // The next session reports a new body and no folder; the state we
         // set survives, the server's fields refresh.
@@ -1736,12 +1817,47 @@ mod tests {
             .import(|tx| tx.incoming("household", &incoming(State::Inbox, None, "$2,120")))
             .unwrap();
         assert_eq!(store.messages(Filter::Search("2,120")).unwrap().len(), 1);
-        let after = store.message(id).unwrap().unwrap();
+        let after = store.message(&id).unwrap().unwrap();
         assert_eq!(after.body, "$2,120");
         assert_eq!(after.values().unwrap().state, State::Do);
         assert_eq!(after.values().unwrap().folder, None);
     }
 
+    #[test]
+    fn incoming_reports_a_table_that_refuses_writes() {
+        let store = Store::open_in_memory(Clock::Fixed(fixtures::now())).unwrap();
+        store
+            .import(|tx| {
+                tx.account(&Account {
+                    slug: "household".into(),
+                    name: "Household".into(),
+                    address: "household@example.com".into(),
+                    read_only: false,
+                })
+            })
+            .unwrap();
+
+        exec(
+            &store,
+            "CREATE TRIGGER no_threads BEFORE INSERT ON threads BEGIN SELECT RAISE(ABORT, 'no'); END;",
+        );
+        assert!(matches!(
+            store.import(|tx| tx.incoming("household", &incoming(State::Inbox, None, "x"))),
+            Err(Error::Db(_))
+        ));
+        exec(&store, "DROP TRIGGER no_threads;");
+
+        exec(
+            &store,
+            "CREATE TRIGGER no_messages BEFORE INSERT ON messages BEGIN SELECT RAISE(ABORT, 'no'); END;",
+        );
+        assert!(matches!(
+            store.import(|tx| tx.incoming("household", &incoming(State::Inbox, None, "x"))),
+            Err(Error::Db(_))
+        ));
+        exec(&store, "DROP TRIGGER no_messages;");
+    }
+
     #[test]
     fn thread_ids_are_scoped_to_their_account() {
         // JMAP thread ids are opaque; two accounts may share one without
@@ -1778,13 +1894,15 @@ mod tests {
             .unwrap()
             .first()
             .unwrap()
-            .thread;
+            .thread
+            .clone();
         let b = store
             .messages(Filter::Search("his"))
             .unwrap()
             .first()
             .unwrap()
-            .thread;
+            .thread
+            .clone();
         assert_ne!(a, b);
     }
 
@@ -1813,12 +1931,23 @@ mod tests {
         .unwrap();
 
         let store = Store::init(conn, Clock::Fixed(fixtures::now())).unwrap();
-        let msg = store.message(4).unwrap().unwrap();
+        // The mail survived, its ids remade: found by Message-ID now, and
+        // the new id is jj-style reverse hex — 16 characters, no digits,
+        // no order.
+        let msg = store
+            .messages(Filter::Search("Revised estimate"))
+            .unwrap()
+            .first()
+            .cloned()
+            .unwrap();
+        assert_eq!(msg.message_id, "m4@northwind.co");
+        assert_eq!(msg.id.len(), 16);
+        assert!(msg.id.chars().all(|c| ('k'..='z').contains(&c)));
         assert_eq!(
             msg.values().unwrap().assignees,
             BTreeSet::from([ALEX.to_owned()])
         );
-        assert!(!store.unread(ALEX).unwrap().contains(&4));
+        assert!(!store.unread(ALEX).unwrap().contains(&msg.id));
 
         // The relaxed schema now takes shared-identity sends.
         store
@@ -1882,13 +2011,13 @@ mod tests {
     #[test]
     fn importing_into_a_missing_thread_fails() {
         let store = fixtures::store().unwrap();
-        let mut m = store.message(4).unwrap().unwrap();
-        m.id = 100;
+        let mut m = store.message(&fixtures::id(4)).unwrap().unwrap();
+        m.id = fixtures::id(100);
         m.message_id = "new@example.com".into();
-        m.thread = 999;
+        m.thread = fixtures::id(999);
         let result = store.import(|tx| tx.message(&m));
         assert!(matches!(result, Err(Error::NotFound("thread"))));
-        assert!(store.message(100).unwrap().is_none());
+        assert!(store.message(&fixtures::id(100)).unwrap().is_none());
     }
 
     #[test]
@@ -1900,25 +2029,36 @@ mod tests {
         assert!(store.is_empty().unwrap());
         fixtures::seed(&store).unwrap();
         assert!(!store.is_empty().unwrap());
-        store.edit(SAM, 4, Change::State(State::Wait)).unwrap();
         store
-            .edit(SAM, 4, Change::ToggleAssignee(ALEX.into()))
+            .edit(SAM, &fixtures::id(4), Change::State(State::Wait))
+            .unwrap();
+        store
+            .edit(SAM, &fixtures::id(4), Change::ToggleAssignee(ALEX.into()))
+            .unwrap();
+        store
+            .add_comment(SAM, &fixtures::id(1), "Called them.")
+            .unwrap();
+        store
+            .mark_read(SAM, &fixtures::id(fixtures::WATER))
             .unwrap();
-        store.add_comment(SAM, 1, "Called them.").unwrap();
-        store.mark_read(SAM, fixtures::WATER).unwrap();
         drop(store);
 
         let store = Store::open(&path, Clock::Fixed(fixtures::now())).unwrap();
         assert!(!store.is_empty().unwrap());
-        let v = values(&store, 4);
+        let v = values(&store, &fixtures::id(4));
         assert_eq!(v.state, State::Wait);
         assert_eq!(v.assignees, BTreeSet::from([ALEX.to_owned()]));
         assert!(matches!(
-            store.timeline(1).unwrap().last(),
+            store.timeline(&fixtures::id(1)).unwrap().last(),
             Some(Item::Comment(c)) if c.text == "Called them."
         ));
-        assert!(!store.unread(SAM).unwrap().contains(&fixtures::WATER));
-        assert_eq!(store.history(4).unwrap().len(), 2);
+        assert!(
+            !store
+                .unread(SAM)
+                .unwrap()
+                .contains(&fixtures::id(fixtures::WATER))
+        );
+        assert_eq!(store.history(&fixtures::id(4)).unwrap().len(), 2);
         // Undo and toasts belong to the session that made the edit.
         assert!(store.take_flash(SAM).is_none());
         assert!(matches!(store.undo(SAM), Err(Error::BadRequest(_))));
@@ -1929,25 +2069,35 @@ mod tests {
         let store = fixtures::store().unwrap();
         // The schema refuses a received message without a state, and a
         // folder that doesn't exist.
+        let id = fixtures::id(4);
         let inner = store.lock();
         assert!(
             inner
                 .conn
-                .execute("UPDATE messages SET state = NULL WHERE id = 4", [])
+                .execute(
+                    &format!("UPDATE messages SET state = NULL WHERE id = '{id}'"),
+                    []
+                )
                 .is_err()
         );
         assert!(
             inner
                 .conn
-                .execute("UPDATE messages SET folder = 'Nope' WHERE id = 4", [])
+                .execute(
+                    &format!("UPDATE messages SET folder = 'Nope' WHERE id = '{id}'"),
+                    []
+                )
                 .is_err()
         );
         inner
             .conn
-            .execute("UPDATE messages SET cc = 'not json' WHERE id = 4", [])
+            .execute(
+                &format!("UPDATE messages SET cc = 'not json' WHERE id = '{id}'"),
+                [],
+            )
             .unwrap();
         drop(inner);
-        assert!(matches!(store.message(4), Err(Error::Db(_))));
+        assert!(matches!(store.message(&id), Err(Error::Db(_))));
     }
 
     fn exec(store: &Store, sql: &str) {
@@ -1964,58 +2114,60 @@ mod tests {
         );
         // The state update goes in before the history row that fails.
         assert!(matches!(
-            store.edit(SAM, 4, Change::State(State::Do)),
+            store.edit(SAM, &fixtures::id(4), Change::State(State::Do)),
             Err(Error::Db(_))
         ));
         assert!(matches!(
-            store.edit(SAM, 4, Change::ToggleAssignee(ALEX.into())),
+            store.edit(SAM, &fixtures::id(4), Change::ToggleAssignee(ALEX.into())),
             Err(Error::Db(_))
         ));
-        assert_eq!(values(&store, 4).state, State::Inbox);
-        assert!(values(&store, 4).assignees.is_empty());
-        assert!(store.history(4).unwrap().is_empty());
+        assert_eq!(values(&store, &fixtures::id(4)).state, State::Inbox);
+        assert!(values(&store, &fixtures::id(4)).assignees.is_empty());
+        assert!(store.history(&fixtures::id(4)).unwrap().is_empty());
         assert!(store.take_flash(SAM).is_none());
 
         // A failed undo stays undoable.
         exec(&store, "DROP TRIGGER no_history;");
-        store.edit(SAM, 4, Change::State(State::Do)).unwrap();
+        store
+            .edit(SAM, &fixtures::id(4), Change::State(State::Do))
+            .unwrap();
         exec(
             &store,
             "CREATE TRIGGER no_history BEFORE INSERT ON history BEGIN SELECT RAISE(ABORT, 'no'); END;",
         );
         assert!(matches!(store.undo(SAM), Err(Error::Db(_))));
-        assert_eq!(values(&store, 4).state, State::Do);
+        assert_eq!(values(&store, &fixtures::id(4)).state, State::Do);
         exec(&store, "DROP TRIGGER no_history;");
         store.undo(SAM).unwrap();
-        assert_eq!(values(&store, 4).state, State::Inbox);
+        assert_eq!(values(&store, &fixtures::id(4)).state, State::Inbox);
     }
 
     #[test]
     fn database_errors_surface() {
         let store = fixtures::store().unwrap();
-        let msg = store.message(4).unwrap().unwrap();
+        let msg = store.message(&fixtures::id(4)).unwrap().unwrap();
         exec(&store, "PRAGMA query_only = ON;");
         let db_err = |r: Result<(), Error>| assert!(matches!(r, Err(Error::Db(_))), "{r:?}");
-        db_err(store.edit(SAM, 4, Change::State(State::Do)));
-        db_err(store.mark_read(SAM, 4));
+        db_err(store.edit(SAM, &fixtures::id(4), Change::State(State::Do)));
+        db_err(store.mark_read(SAM, &fixtures::id(4)));
         db_err(store.sign_in("pat@example.com", "pat").map(|_| ()));
-        db_err(store.add_comment(SAM, 1, "hi"));
+        db_err(store.add_comment(SAM, &fixtures::id(1), "hi"));
         db_err(store.import(|tx| tx.folder("Travel")));
         db_err(store.import(|tx| tx.message(&msg)));
         exec(&store, "PRAGMA query_only = OFF;");
 
         // Each table the store reads, gone in turn.
         exec(&store, "ALTER TABLE folders RENAME TO gone_folders;");
-        db_err(store.edit(SAM, 4, Change::Folder(Some("House".into()))));
+        db_err(store.edit(SAM, &fixtures::id(4), Change::Folder(Some("House".into()))));
         assert!(store.folders().is_err());
         exec(&store, "ALTER TABLE comments RENAME TO gone_comments;");
-        assert!(store.timeline(1).is_err());
+        assert!(store.timeline(&fixtures::id(1)).is_err());
         exec(&store, "ALTER TABLE reads RENAME TO gone_reads;");
         assert!(store.unread(SAM).is_err());
         exec(&store, "ALTER TABLE history RENAME TO gone_history;");
-        assert!(store.history(4).is_err());
+        assert!(store.history(&fixtures::id(4)).is_err());
         exec(&store, "ALTER TABLE threads RENAME TO gone_threads;");
-        db_err(store.add_comment(SAM, 1, "hi"));
+        db_err(store.add_comment(SAM, &fixtures::id(1), "hi"));
         exec(&store, "ALTER TABLE users RENAME TO gone_users;");
         assert!(store.users().is_err());
         assert!(store.is_empty().is_err());
diff --git a/src/views.rs b/src/views.rs
index 3c0f03d..a2dc084 100644
--- a/src/views.rs
+++ b/src/views.rs
@@ -50,7 +50,7 @@ pub fn view_path(view: &View) -> String {
     }
 }
 
-fn select_path(view: &View, id: u32) -> String {
+fn select_path(view: &View, id: &str) -> String {
     let path = view_path(view);
     let sep = if path.contains('?') { '&' } else { '?' };
     format!("{path}{sep}m={id}")
@@ -247,10 +247,10 @@ fn thread_group(p: &Page<'_>, group: &Group, rows: &[Message], compact: bool) ->
 /// A thread with one message listed collapses into a single row: subject and
 /// time, then sender and snippet.
 fn solo(p: &Page<'_>, group: &Group, m: &Message, compact: bool) -> Markup {
-    let selected = p.selected == Some(m.id);
+    let selected = p.selected.as_deref() == Some(m.id.as_str());
     let unread = p.unread.contains(&m.id);
     html! {
-        a.row.solo.sel[selected].unread[unread] href=(select_path(p.view, m.id)) {
+        a.row.solo.sel[selected].unread[unread] href=(select_path(p.view, &m.id)) {
             span.subj { (group.thread.subject) }
             span.type-figure.age { (dates::short(p.now, m.at)) }
             span.line {
@@ -276,10 +276,10 @@ fn sender(users: &[User], m: &Message) -> String {
 }
 
 fn row(p: &Page<'_>, m: &Message, compact: bool) -> Markup {
-    let selected = p.selected == Some(m.id);
+    let selected = p.selected.as_deref() == Some(m.id.as_str());
     let unread = p.unread.contains(&m.id);
     html! {
-        a.row.sel[selected].unread[unread] href=(select_path(p.view, m.id)) {
+        a.row.sel[selected].unread[unread] href=(select_path(p.view, &m.id)) {
             span.from { (sender(&p.users, m)) }
             span.type-figure.age { (dates::short(p.now, m.at)) }
             @if !compact {
@@ -292,7 +292,7 @@ fn row(p: &Page<'_>, m: &Message, compact: bool) -> Markup {
 fn reader(p: &Page<'_>) -> Markup {
     html! {
         main.reader {
-            @if let (Some(selected), Some(t)) = (p.selected, &p.thread) {
+            @if let (Some(selected), Some(t)) = (p.selected.as_deref(), &p.thread) {
                 (thread(p, t, selected))
             } @else {
                 p.empty { "No thread open." }
@@ -301,7 +301,7 @@ fn reader(p: &Page<'_>) -> Markup {
     }
 }
 
-fn thread(p: &Page<'_>, t: &ThreadView, selected: MessageId) -> Markup {
+fn thread(p: &Page<'_>, t: &ThreadView, selected: &str) -> Markup {
     html! {
         article.thread {
             header.thread-head {
@@ -384,7 +384,7 @@ fn message(p: &Page<'_>, m: &Message, open: bool, selected: bool, account: &Acco
                         title="Open in Fastmail" aria-label="Open in Fastmail" { (PreEscaped(EXTERNAL_ICON)) }
                 }
                 @match m.values() {
-                    Some(values) => (values_controls(p, m.id, values, account.read_only)),
+                    Some(values) => (values_controls(p, &m.id, values, account.read_only)),
                     None => span.type-label.sent { "Sent" },
                 }
             }
@@ -411,7 +411,7 @@ fn check(on: bool) -> Markup {
     html! { span.check { @if on { "✓" } } }
 }
 
-fn values_controls(p: &Page<'_>, id: u32, values: &Values, read_only: bool) -> Markup {
+fn values_controls(p: &Page<'_>, id: &str, values: &Values, read_only: bool) -> Markup {
     let states = html! {
         @for state in State::ALL {
             @let on = state == values.state;
@@ -491,20 +491,27 @@ mod tests {
 
     #[test]
     fn paths() {
+        let id = crate::fixtures::id(4);
         assert_eq!(view_path(&View::ForMe), "/");
         assert_eq!(view_path(&View::Lane(State::Wait)), "/wait");
         assert_eq!(view_path(&View::Search("a&b".into())), "/search?q=a%26b");
-        assert_eq!(select_path(&View::ForMe, 4), "/?m=4");
-        assert_eq!(select_path(&View::Search("x".into()), 4), "/search?q=x&m=4");
+        assert_eq!(select_path(&View::ForMe, &id), format!("/?m={id}"));
+        assert_eq!(
+            select_path(&View::Search("x".into()), &id),
+            format!("/search?q=x&m={id}")
+        );
     }
 
     #[test]
     fn fastmail_links_search_by_message_id() {
         let store = crate::fixtures::store().unwrap();
-        let m = store.message(4).unwrap().unwrap();
+        let m = store.message(&crate::fixtures::id(4)).unwrap().unwrap();
         assert_eq!(
             fastmail_url(&m),
-            "https://app.fastmail.com/mail/search:msgid%3A%3C4%40fixtures.docket.invalid%3E"
+            format!(
+                "https://app.fastmail.com/mail/search:msgid%3A%3C{}%40fixtures.docket.invalid%3E",
+                crate::fixtures::id(4)
+            )
         );
     }
 
@@ -512,9 +519,9 @@ mod tests {
     fn senders_without_a_login_show_the_recipient() {
         let users = [User::new("sam@example.com", "Sam")];
         let sent = |by: Option<&str>| Message {
-            id: 1,
+            id: crate::fixtures::id(1),
             message_id: "m@x".into(),
-            thread: 1,
+            thread: crate::fixtures::id(1),
             at: crate::fixtures::now(),
             cc: Vec::new(),
             bcc: Vec::new(),
diff --git a/tests/api.rs b/tests/api.rs
index c859751..b7f5523 100644
--- a/tests/api.rs
+++ b/tests/api.rs
@@ -1,6 +1,7 @@
 use std::net::SocketAddr;
 
 use axum::Router;
+use docket::fixtures;
 use docket::routes::{AppState, router};
 use reqwest::{Client, StatusCode, redirect};
 use tokio::net::TcpListener;
@@ -39,6 +40,10 @@ fn slug(login: &str) -> String {
         .unwrap_or_default()
 }
 
+fn mid(n: u32) -> String {
+    fixtures::id(n)
+}
+
 async fn get(addr: SocketAddr, login: &str, path: &str) -> (StatusCode, String) {
     let res = client()
         .get(format!("http://{addr}{path}"))
@@ -100,7 +105,7 @@ async fn requires_both_identity_headers() {
         assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "{login:?} {slug:?}");
     }
     // Nobody got added along the way.
-    let (_, body) = get(addr, ALEX, "/?m=4").await;
+    let (_, body) = get(addr, ALEX, &format!("/?m={}", mid(4))).await;
     assert!(!body.contains(EVE));
 }
 
@@ -113,7 +118,7 @@ async fn anyone_tailscale_lets_in_is_a_user() {
 
     // The name follows the latest X-User-Slug.
     let body = client()
-        .get(format!("http://{addr}/?m=4"))
+        .get(format!("http://{addr}/?m={}", mid(4)))
         .header("Remote-User", "eve@example.com")
         .header("X-User-Slug", "Evie")
         .send()
@@ -166,7 +171,7 @@ async fn lanes_and_search() {
     assert!(body.contains("Nothing here."));
     // A search result row links back into the search.
     let (_, body) = get(addr, SAM, "/search?q=checkup").await;
-    assert!(body.contains("/search?q=checkup&amp;m=15"));
+    assert!(body.contains(&format!("/search?q=checkup&amp;m={}", mid(15))));
 
     for path in ["/done", "/nope", "/folders/Medical", "/?m=999"] {
         let (status, _) = get(addr, SAM, path).await;
@@ -177,34 +182,35 @@ async fn lanes_and_search() {
 #[tokio::test]
 async fn thread_view_shows_the_chain_with_values_in_the_gutter() {
     let addr = spawn().await;
-    let (status, body) = get(addr, SAM, "/?m=4").await;
+    let (status, body) = get(addr, SAM, &format!("/?m={}", mid(4))).await;
     assert_eq!(status, StatusCode::OK);
     assert!(body.contains("<h2>Gutter repair estimate</h2>"));
     // Only the selected email floats.
-    assert!(body.contains(r#"class="item card sel" id="m4""#));
-    assert!(body.contains(r#"class="item card" id="m3""#));
+    assert!(body.contains(&format!(r#"class="item card sel" id="m{}""#, mid(4))));
+    assert!(body.contains(&format!(r#"class="item card" id="m{}""#, mid(3))));
     assert!(body.contains(r#"<span class="type-label sent">Sent</span>"#));
     assert!(body.contains(r#"<span class="badge accent"><span class="mark"></span>Do</span>"#));
     assert!(body.contains(r#"<span class="badge success"><span class="mark"></span>Done</span>"#));
-    assert!(body.contains(
-        "https://app.fastmail.com/mail/search:msgid%3A%3C4%40fixtures.docket.invalid%3E"
-    ));
+    assert!(body.contains(&format!(
+        "https://app.fastmail.com/mail/search:msgid%3A%3C{}%40fixtures.docket.invalid%3E",
+        mid(4)
+    )));
     assert!(body.contains(r#"<button class="btn primary" type="submit">Comment</button>"#));
     assert!(body.contains("cc Alex · bcc Pat Lee"));
     assert!(body.contains("cc Sam, Alex"));
     assert!(body.contains("→ Northwind Roofing"));
     assert!(body.contains("The Hendersons paid about $1,600"));
-    assert!(body.contains(r#"action="/messages/4/state""#));
-    assert!(body.contains(r#"action="/messages/4/folder""#));
-    assert!(body.contains(r#"action="/messages/4/assignees""#));
+    assert!(body.contains(&format!(r#"action="/messages/{}/state""#, mid(4))));
+    assert!(body.contains(&format!(r#"action="/messages/{}/folder""#, mid(4))));
+    assert!(body.contains(&format!(r#"action="/messages/{}/assignees""#, mid(4))));
     assert!(body.contains(r#"<span class="type-label value unset">Assign</span>"#));
     assert!(body.contains(r#"<span class="type-label value">House</span>"#));
-    assert!(body.contains(r#"value="/?m=4""#));
+    assert!(body.contains(&format!(r#"value="/?m={}""#, mid(4))));
 
-    let (_, body) = get(addr, ALEX, "/?m=6").await;
+    let (_, body) = get(addr, ALEX, &format!("/?m={}", mid(6))).await;
     assert!(body.contains(r#"<span class="type-label value">→ ALEX</span>"#));
     assert!(!body.contains(r#"<span class="type-label value">—</span>"#));
-    let (_, body) = get(addr, SAM, "/inbox?m=8").await;
+    let (_, body) = get(addr, SAM, &format!("/inbox?m={}", mid(8))).await;
     assert!(body.contains(r#"<span class="type-label value">—</span>"#));
     assert!(!body.contains("Read-only"));
 }
@@ -212,15 +218,15 @@ async fn thread_view_shows_the_chain_with_values_in_the_gutter() {
 #[tokio::test]
 async fn read_only_accounts_show_folder_as_plain_text() {
     let addr = spawn().await;
-    let (_, body) = get(addr, SAM, "/watch?m=20").await;
+    let (_, body) = get(addr, SAM, &format!("/watch?m={}", mid(20))).await;
     assert!(body.contains(r#"<span class="type-label">Read-only</span>"#));
     assert!(body.contains(r#"<span class="type-label value static">—</span>"#));
-    assert!(!body.contains(r#"action="/messages/20/folder""#));
-    assert!(body.contains(r#"action="/messages/20/state""#));
+    assert!(!body.contains(&format!(r#"action="/messages/{}/folder""#, mid(20))));
+    assert!(body.contains(&format!(r#"action="/messages/{}/state""#, mid(20))));
     let res = post(
         addr,
         SAM,
-        "/messages/20/folder",
+        &format!("/messages/{}/folder", mid(20)),
         &[("folder", "School"), ("back", "/")],
     )
     .await;
@@ -231,13 +237,13 @@ async fn read_only_accounts_show_folder_as_plain_text() {
 async fn opening_a_message_marks_it_read() {
     let addr = spawn().await;
     let (_, body) = get(addr, ALEX, "/").await;
-    assert!(body.contains(r#"class="row solo unread" href="/?m=7""#));
-    get(addr, ALEX, "/?m=7").await;
+    assert!(body.contains(&format!(r#"class="row solo unread" href="/?m={}""#, mid(7))));
+    get(addr, ALEX, &format!("/?m={}", mid(7))).await;
     let (_, body) = get(addr, ALEX, "/").await;
-    assert!(body.contains(r#"class="row solo" href="/?m=7""#));
+    assert!(body.contains(&format!(r#"class="row solo" href="/?m={}""#, mid(7))));
     // Read tracking is per person.
     let (_, body) = get(addr, SAM, "/").await;
-    assert!(body.contains(r#"class="row solo unread" href="/?m=7""#));
+    assert!(body.contains(&format!(r#"class="row solo unread" href="/?m={}""#, mid(7))));
 }
 
 #[tokio::test]
@@ -246,23 +252,23 @@ async fn editing_values_with_undo() {
     let res = post(
         addr,
         SAM,
-        "/messages/4/state",
-        &[("state", "do"), ("back", "/?m=4")],
+        &format!("/messages/{}/state", mid(4)),
+        &[("state", "do"), ("back", &format!("/?m={}", mid(4)))],
     )
     .await;
     assert_eq!(res.status(), StatusCode::SEE_OTHER);
-    assert_eq!(location(&res), "/?m=4");
+    assert_eq!(location(&res), &format!("/?m={}", mid(4)));
 
-    let (_, body) = get(addr, SAM, "/?m=4").await;
+    let (_, body) = get(addr, SAM, &format!("/?m={}", mid(4))).await;
     assert!(body.contains("Moved to Do"));
     assert!(body.contains(r#"action="/undo""#));
     // The toast shows once.
-    let (_, body) = get(addr, SAM, "/?m=4").await;
+    let (_, body) = get(addr, SAM, &format!("/?m={}", mid(4))).await;
     assert!(!body.contains("Moved to Do"));
 
-    let res = post(addr, SAM, "/undo", &[("back", "/?m=4")]).await;
+    let res = post(addr, SAM, "/undo", &[("back", &format!("/?m={}", mid(4)))]).await;
     assert_eq!(res.status(), StatusCode::SEE_OTHER);
-    let (_, body) = get(addr, SAM, "/?m=4").await;
+    let (_, body) = get(addr, SAM, &format!("/?m={}", mid(4))).await;
     assert!(body.contains("Undone"));
     assert!(!body.contains(r#"action="/undo""#));
     let res = post(addr, SAM, "/undo", &[("back", "/")]).await;
@@ -271,7 +277,7 @@ async fn editing_values_with_undo() {
     let res = post(
         addr,
         SAM,
-        "/messages/4/folder",
+        &format!("/messages/{}/folder", mid(4)),
         &[("folder", "Finance"), ("back", "/")],
     )
     .await;
@@ -279,32 +285,32 @@ async fn editing_values_with_undo() {
     let res = post(
         addr,
         SAM,
-        "/messages/4/folder",
+        &format!("/messages/{}/folder", mid(4)),
         &[("folder", ""), ("back", "/")],
     )
     .await;
     assert_eq!(res.status(), StatusCode::SEE_OTHER);
-    let (_, body) = get(addr, SAM, "/?m=4").await;
+    let (_, body) = get(addr, SAM, &format!("/?m={}", mid(4))).await;
     assert!(body.contains("Removed from folder"));
 
     let res = post(
         addr,
         SAM,
-        "/messages/4/assignees",
+        &format!("/messages/{}/assignees", mid(4)),
         &[("user", ALEX), ("back", "/")],
     )
     .await;
     assert_eq!(res.status(), StatusCode::SEE_OTHER);
     // Now it's Alex's; Sam's For me no longer lists it, Alex's does.
     let (_, body) = get(addr, ALEX, "/").await;
-    assert!(body.contains(r#"href="/?m=4""#));
+    assert!(body.contains(&format!(r#"href="/?m={}""#, mid(4))));
     let (_, body) = get(addr, SAM, "/").await;
-    assert!(!body.contains(r#"href="/?m=4""#));
+    assert!(!body.contains(&format!(r#"href="/?m={}""#, mid(4))));
 
     let res = post(
         addr,
         SAM,
-        "/messages/4/state",
+        &format!("/messages/{}/state", mid(4)),
         &[("state", "read"), ("back", "/")],
     )
     .await;
@@ -312,7 +318,7 @@ async fn editing_values_with_undo() {
     let res = post(
         addr,
         SAM,
-        "/messages/2/state",
+        &format!("/messages/{}/state", mid(2)),
         &[("state", "do"), ("back", "/")],
     )
     .await;
@@ -320,7 +326,7 @@ async fn editing_values_with_undo() {
     let res = post(
         addr,
         SAM,
-        "/messages/4/state",
+        &format!("/messages/{}/state", mid(4)),
         &[("state", "do"), ("back", "https://evil.example")],
     )
     .await;
@@ -333,17 +339,20 @@ async fn comments_join_the_thread() {
     let res = post(
         addr,
         ALEX,
-        "/threads/1/comments",
-        &[("text", "I'll call them."), ("back", "/?m=4")],
+        &format!("/threads/{}/comments", mid(1)),
+        &[
+            ("text", "I'll call them."),
+            ("back", &format!("/?m={}", mid(4))),
+        ],
     )
     .await;
     assert_eq!(res.status(), StatusCode::SEE_OTHER);
-    let (_, body) = get(addr, SAM, "/?m=4").await;
+    let (_, body) = get(addr, SAM, &format!("/?m={}", mid(4))).await;
     assert!(body.contains("I&#39;ll call them.") || body.contains("I'll call them."));
     let res = post(
         addr,
         ALEX,
-        "/threads/1/comments",
+        &format!("/threads/{}/comments", mid(1)),
         &[("text", " "), ("back", "/")],
     )
     .await;
@@ -355,7 +364,7 @@ async fn cross_site_posts_are_refused() {
     let addr = spawn().await;
     let send = |origin: &'static str| {
         client()
-            .post(format!("http://{addr}/messages/4/state"))
+            .post(format!("http://{addr}/messages/{}/state", mid(4)))
             .header("Remote-User", SAM)
             .header("X-User-Slug", "Sam")
             .header("Origin", origin)
@@ -485,21 +494,33 @@ async fn trashing_confirms_through_a_dialog_and_marks_done() {
     // The trash affordance is a dialog on writable accounts: open it
     // with showModal, cancel closes without posting, and the form
     // targets the delete route.
-    let (_, body) = get(addr, SAM, "/inbox?m=8").await;
-    assert!(body.contains(r#"id="trash-8""#));
+    let (_, body) = get(addr, SAM, &format!("/inbox?m={}", mid(8))).await;
+    assert!(body.contains(&format!(r#"id="trash-{}""#, mid(8))));
     assert!(body.contains("showModal"));
     assert!(body.contains("Move to Trash?"));
     assert!(body.contains(r#"formmethod="dialog""#));
-    assert!(body.contains(r#"action="/messages/8/delete""#));
+    assert!(body.contains(&format!(r#"action="/messages/{}/delete""#, mid(8))));
 
-    let res = post(addr, SAM, "/messages/8/delete", &[("back", "/inbox?m=8")]).await;
+    let res = post(
+        addr,
+        SAM,
+        &format!("/messages/{}/delete", mid(8)),
+        &[("back", &format!("/inbox?m={}", mid(8)))],
+    )
+    .await;
     assert_eq!(res.status(), StatusCode::SEE_OTHER);
-    let (_, body) = get(addr, SAM, "/search?q=142.18&m=8").await;
+    let (_, body) = get(addr, SAM, &format!("/search?q=142.18&m={}", mid(8))).await;
     assert!(body.contains(r#"<span class="badge success"><span class="mark"></span>Done</span>"#));
 
     // Read-only accounts get neither the dialog nor the route.
-    let (_, body) = get(addr, SAM, "/watch?m=20").await;
+    let (_, body) = get(addr, SAM, &format!("/watch?m={}", mid(20))).await;
     assert!(!body.contains(r#"id="trash-20""#));
-    let res = post(addr, SAM, "/messages/20/delete", &[("back", "/watch?m=20")]).await;
+    let res = post(
+        addr,
+        SAM,
+        &format!("/messages/{}/delete", mid(20)),
+        &[("back", &format!("/watch?m={}", mid(20)))],
+    )
+    .await;
     assert_eq!(res.status(), StatusCode::FORBIDDEN);
 }
diff --git a/tests/jmap.rs b/tests/jmap.rs
index 9b9994e..0452ef6 100644
--- a/tests/jmap.rs
+++ b/tests/jmap.rs
@@ -784,11 +784,11 @@ async fn each_credential_opens_a_session_and_imports_its_mail() {
 
     // Threads group their messages under one subject.
     assert_eq!(
-        store.thread(roofer1.thread).unwrap().unwrap().subject,
+        store.thread(&roofer1.thread).unwrap().unwrap().subject,
         "Gutter repair estimate"
     );
     assert_eq!(
-        store.thread(school.thread).unwrap().unwrap().subject,
+        store.thread(&school.thread).unwrap().unwrap().subject,
         "Field trip"
     );
     assert_ne!(roofer1.thread, school.thread);
@@ -1194,7 +1194,7 @@ async fn filing_pushes_through_the_poll() {
     store
         .edit(
             "sam@example.com",
-            school.id,
+            &school.id,
             Change::Folder(Some("Receipts".into())),
         )
         .unwrap();
@@ -1226,13 +1226,13 @@ async fn filing_pushes_through_the_poll() {
     // The push cleared the queue, and the same cycle's /changes sweep
     // confirmed the filing in the cached row.
     assert!(store.pending_files("household").unwrap().is_empty());
-    let after = store.message(school.id).unwrap().unwrap();
+    let after = store.message(&school.id).unwrap().unwrap();
     assert_eq!(after.values().unwrap().folder.as_deref(), Some("Receipts"));
     assert_eq!(after.values().unwrap().state, State::Watch);
 
     // Unfiling queues again and empties every folder membership.
     store
-        .edit("sam@example.com", school.id, Change::Folder(None))
+        .edit("sam@example.com", &school.id, Change::Folder(None))
         .unwrap();
     let counts = client
         .poll_once(&household, &mut sync, &store)
@@ -1266,7 +1266,7 @@ async fn acl_denied_filings_wait_for_the_rights() {
     store
         .edit(
             "sam@example.com",
-            school.id,
+            &school.id,
             Change::Folder(Some("Receipts".into())),
         )
         .unwrap();
@@ -1300,7 +1300,7 @@ async fn a_refused_filing_clears_rather_than_retries() {
     store
         .edit(
             "sam@example.com",
-            school.id,
+            &school.id,
             Change::Folder(Some("Receipts".into())),
         )
         .unwrap();
@@ -1317,7 +1317,7 @@ async fn a_refused_filing_clears_rather_than_retries() {
     assert!(store.pending_files("household").unwrap().is_empty());
     assert_eq!(
         store
-            .message(school.id)
+            .message(&school.id)
             .unwrap()
             .unwrap()
             .values()
@@ -1334,7 +1334,7 @@ async fn a_filing_for_mail_thats_gone_drops() {
     store
         .edit(
             "sam@example.com",
-            school.id,
+            &school.id,
             Change::Folder(Some("Receipts".into())),
         )
         .unwrap();
@@ -1356,7 +1356,7 @@ async fn a_filing_into_a_vanished_folder_drops() {
     store
         .edit(
             "sam@example.com",
-            school.id,
+            &school.id,
             Change::Folder(Some("Receipts".into())),
         )
         .unwrap();
@@ -1381,7 +1381,7 @@ async fn done_archives_out_of_the_shared_inbox() {
     let (world, client, household, mut sync, store, _dir) = household_ready().await;
     let school = school_message(&store);
     store
-        .edit("sam@example.com", school.id, Change::State(State::Done))
+        .edit("sam@example.com", &school.id, Change::State(State::Done))
         .unwrap();
     assert_eq!(store.pending_archives("household").unwrap().len(), 1);
 
@@ -1410,7 +1410,7 @@ async fn done_archives_out_of_the_shared_inbox() {
     assert!(store.pending_archives("household").unwrap().is_empty());
     assert_eq!(
         store
-            .message(school.id)
+            .message(&school.id)
             .unwrap()
             .unwrap()
             .values()
@@ -1433,7 +1433,7 @@ async fn mail_already_out_of_the_inbox_exits_nowhere() {
         });
     let school = school_message(&store);
     store
-        .edit("sam@example.com", school.id, Change::State(State::Done))
+        .edit("sam@example.com", &school.id, Change::State(State::Done))
         .unwrap();
 
     let counts = client
@@ -1467,7 +1467,7 @@ async fn acl_denied_archives_wait_for_the_rights() {
         });
     let school = school_message(&store);
     store
-        .edit("sam@example.com", school.id, Change::State(State::Done))
+        .edit("sam@example.com", &school.id, Change::State(State::Done))
         .unwrap();
 
     let counts = client
@@ -1497,7 +1497,7 @@ async fn a_refused_archive_clears_rather_than_retries() {
     let (world, client, household, mut sync, store, _dir) = household_ready().await;
     let school = school_message(&store);
     store
-        .edit("sam@example.com", school.id, Change::State(State::Done))
+        .edit("sam@example.com", &school.id, Change::State(State::Done))
         .unwrap();
     world.lock().unwrap().refuse.insert("E-school".into());
 
@@ -1515,7 +1515,7 @@ async fn an_archive_for_mail_thats_gone_drops() {
     let (world, client, household, mut sync, store, _dir) = household_ready().await;
     let school = school_message(&store);
     store
-        .edit("sam@example.com", school.id, Change::State(State::Done))
+        .edit("sam@example.com", &school.id, Change::State(State::Done))
         .unwrap();
     world.lock().unwrap().remove_email(HOUSEHOLD, "E-school");
 
@@ -1533,7 +1533,7 @@ async fn an_archive_with_nowhere_to_land_drops() {
     let (world, client, household, mut sync, store, _dir) = household_ready().await;
     let school = school_message(&store);
     store
-        .edit("sam@example.com", school.id, Change::State(State::Done))
+        .edit("sam@example.com", &school.id, Change::State(State::Done))
         .unwrap();
     // The server loses its Archive mailbox before the exit lands.
     world
@@ -1567,7 +1567,7 @@ async fn an_archive_with_nowhere_to_land_drops() {
 async fn trashing_destroys_through_the_poll() {
     let (world, client, household, mut sync, store, _dir) = household_ready().await;
     let school = school_message(&store);
-    store.delete("sam@example.com", school.id).unwrap();
+    store.delete("sam@example.com", &school.id).unwrap();
     assert_eq!(store.pending_deletes("household").unwrap().len(), 1);
 
     let counts = client
@@ -1588,7 +1588,7 @@ async fn trashing_destroys_through_the_poll() {
     // The cached row stays, Done.
     assert_eq!(
         store
-            .message(school.id)
+            .message(&school.id)
             .unwrap()
             .unwrap()
             .values()
@@ -1602,7 +1602,7 @@ async fn trashing_destroys_through_the_poll() {
 async fn a_refused_destruction_clears_rather_than_retries() {
     let (world, client, household, mut sync, store, _dir) = household_ready().await;
     let school = school_message(&store);
-    store.delete("sam@example.com", school.id).unwrap();
+    store.delete("sam@example.com", &school.id).unwrap();
     world.lock().unwrap().refuse.insert("E-school".into());
 
     let counts = client
@@ -1618,7 +1618,7 @@ async fn a_refused_destruction_clears_rather_than_retries() {
 async fn a_destruction_for_mail_thats_gone_clears() {
     let (world, client, household, mut sync, store, _dir) = household_ready().await;
     let school = school_message(&store);
-    store.delete("sam@example.com", school.id).unwrap();
+    store.delete("sam@example.com", &school.id).unwrap();
     world.lock().unwrap().remove_email(HOUSEHOLD, "E-school");
 
     let counts = client