git.lucas.co / cce-secrets
secrets manager
git clone https://git.lucas.co/cce-secrets.git

commit16c21dfa8823c8cb8fb5f8fac582959058c7ced7
parent2b268c7517
authorLucas Galante <[email protected]>
date2026-09-21 12:49
cce-keyring-sync: retire the kdbx backend (phase 3)

1Password has been the interchange since this morning and the daemon's
live passes are quiet, so the kdbx path goes: import, the kdbx sync,
doctor, the --kdbx flag, State.kdbx_path and EntryState.kdbx_mtime, the
keepass and rpassword dependencies, and the master-password keyring item
(removed from the live keyring by hand). main.rs is 242 lines, the
1Password path plus what it shares. cce-secrets' wording no longer
mentions KeePassXC. The Dropbox kdbx itself is untouched for the other
machines.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 KEYRING-SYNC.md                   |   39 +-
 src/bin/cce-keyring-sync/adopt.rs |   17 +-
 src/bin/cce-keyring-sync/main.rs  | 1118 +++----------------------------------
 src/bin/cce-keyring-sync/op.rs    |    7 +-
 src/bin/cce-keyring-sync/sync.rs  |    5 +-
 src/main.rs                       |   21 +-
 6 files changed, 134 insertions(+), 1073 deletions(-)

diff --git a/KEYRING-SYNC.md b/KEYRING-SYNC.md
index e049286..6a7e6ea 100644
--- a/KEYRING-SYNC.md
+++ b/KEYRING-SYNC.md
@@ -173,10 +173,12 @@ that is irrelevant, and there is no resident unlock to keep warm.
 
 # Scoping: 1Password as the interchange (option 1, 2026-09-21)
 
-Status: **phase 2 shipped (2026-09-21).** The resident daemon is the
+Status: **complete (2026-09-21).** The resident daemon is the
 `cce-keyring-sync.service` unit, ticking every five minutes against the
-Personal vault; the kdbx timer is gone. Phase 3 (retire the kdbx code) is
-what remains. Results per phase at the end of this file.
+Personal vault; the kdbx backend, `import`, `doctor`, the `keepass`
+dependency and the stored master password are gone. Everything above this
+line up to "Scoping: 1Password as the interchange" describes the retired
+kdbx design and is kept as history. Results per phase at the end.
 
 ## Goal
 
@@ -398,10 +400,13 @@ unacceptable however native the window is. Measure it before anything else
 2. ~~**`sync` on the new backend.**~~ **Done 2026-09-21**; results below.
    The table is exercised by `scripts/e2e-1password.sh` (isolated keyring,
    throwaway vault, every pass through one daemon), 19 checks.
-3. **Retire the kdbx path** after a month clean: delete the backend, the
-   `doctor` subcommand, the `keepass` dependency, and the master-password
-   keyring item. Update cce-secrets' "synced Nm ago" hint (it already reads
-   the state file; only the tooltip text names Dropbox).
+3. ~~**Retire the kdbx path** after a month clean~~ **Done 2026-09-21**,
+   the same day, on the person's call: the isolated run covered the table
+   and the daemon's live passes were quiet. Deleted the backend, `import`,
+   `doctor`, the `keepass` and `rpassword` dependencies, the `--kdbx`
+   flag, `State.kdbx_path` / `EntryState.kdbx_mtime` (unknown fields are
+   ignored on read, so the live base needed no migration), and the
+   master-password keyring item. main.rs went from 1198 lines to 242.
 
 ## Open questions
 
@@ -611,3 +616,23 @@ What it found, and what was changed for it:
 Open question 4 (a locked app) is still open; the daemon's back-off is
 what happens in the meantime. Question 5 (the "top level process" the app
 looks for) is unneeded now.
+
+## Phase 3 results (2026-09-21)
+
+`cce-keyring-sync` is now `adopt`, `sync`, `daemon`, `status` — 1,830
+lines across main.rs, op.rs, sync.rs, adopt.rs, daemon.rs, with 19 unit
+tests and `scripts/e2e-1password.sh` for the table. The kdbx file in
+Dropbox is untouched and still serves other machines and phones; nothing
+here reads or writes it any more. The `kdbx-uuid` / `kdbx-group`
+attributes remain on the 168 adopted keyring items as inert history. The
+kdbx-era base is at `state.json.kdbx-1790007276` beside the live state
+file, should anyone want the old pairing back.
+
+Rolling back to the kdbx design would mean checking out 0bac2e9^ and
+re-importing the master password; the design above documents how that
+worked. Nothing in cce-secrets or cce-browser changed shape in any phase:
+both still front gnome-keyring over the Secret Service, which was the
+point of choosing a mirror.
+
+Still open: question 4, what an `op` call sees against a locked app. The
+daemon's back-off covers the gap until it is measured.
diff --git a/src/bin/cce-keyring-sync/adopt.rs b/src/bin/cce-keyring-sync/adopt.rs
index 070a85e..173c228 100644
--- a/src/bin/cce-keyring-sync/adopt.rs
+++ b/src/bin/cce-keyring-sync/adopt.rs
@@ -1,4 +1,4 @@
-//! `adopt` — the migration step the kdbx never needed.
+//! `adopt` — pair what both sides already hold, and seed the base.
 //!
 //! After the CSV import, both the keyring and 1Password hold the same
 //! entries with no link between them, so the first run must **pair, not
@@ -9,9 +9,7 @@
 //! cross-links two accounts — the one mistake the merge cannot undo later —
 //! so any duplicate refuses the whole run until a person has sorted it.
 //!
-//! The kdbx attributes are kept, deliberately: while the kdbx backend still
-//! exists, an accidental kdbx `sync` must keep pairing by uuid rather than
-//! see 171 orphans to re-create. Phase 3 drops them with the backend.
+//! Old `kdbx-*` attributes are left on the items; nothing reads them.
 
 use std::collections::HashMap;
 
@@ -188,7 +186,7 @@ pub async fn adopt(state_path: &std::path::Path, mut state: State, vault: &str,
         std::process::exit(1);
     }
 
-    // The keyring's logins: anything cce-secrets or the kdbx import wrote.
+    // The keyring's logins: anything cce-secrets (or an earlier importer) wrote.
     let mut locals: Vec<Local<'_>> = Vec::new();
     match col.get_all_items().await {
         Ok(items) => {
@@ -376,19 +374,17 @@ pub async fn adopt(state_path: &std::path::Path, mut state: State, vault: &str,
             r.id.clone(),
             EntryState {
                 h: base.hash(&hash_key),
-                kdbx_mtime: 0,
                 keyring_modified: l.entry.modified,
                 op_updated_at: if differing.contains(&li) { String::new() } else { r.updated_raw.clone() },
             },
         );
     }
 
-    // The kdbx base is not thrown away: sync under the old backend could
-    // still be wanted if this migration is rolled back.
+    // A pre-existing base of another shape is kept aside, not overwritten.
     if state.backend != "onepassword" && state_path.exists() {
-        let backup = state_dir().join(format!("state.json.kdbx-{}", now_unix()));
+        let backup = state_dir().join(format!("state.json.old-{}", now_unix()));
         if std::fs::copy(state_path, &backup).is_ok() {
-            println!("kdbx sync state backed up to {}", backup.display());
+            println!("previous sync state backed up to {}", backup.display());
         }
     }
     state.version = 2;
@@ -399,7 +395,6 @@ pub async fn adopt(state_path: &std::path::Path, mut state: State, vault: &str,
     write_state(state_path, &state);
     crate::journal_append(&format!("{} adopt stamped {stamped} entries (1Password, vault {vault})\n", now_unix()));
     println!("\nadopted: {stamped} entries stamped; backend is now 1Password");
-    println!("note: the kdbx timer should be stopped — `sync` refuses under this backend until the daemon lands (phase 2)");
 }
 
 #[cfg(test)]
diff --git a/src/bin/cce-keyring-sync/main.rs b/src/bin/cce-keyring-sync/main.rs
index be3c5d5..fbe8e16 100644
--- a/src/bin/cce-keyring-sync/main.rs
+++ b/src/bin/cce-keyring-sync/main.rs
@@ -1,20 +1,23 @@
-//! cce-keyring-sync — keep gnome-keyring and the Dropbox kdbx in step.
+//! cce-keyring-sync — keep gnome-keyring and 1Password in step.
 //!
-//! See KEYRING-SYNC.md for the design. `import` is the one-way first run;
-//! `sync` is the bidirectional three-way merge against the state snapshot;
-//! `doctor` reconciles Dropbox conflicted copies via keepassxc-cli merge.
-//! The merge never destroys a value: conflict losers go into the kdbx
-//! entry's native History, deletions into its Recycle Bin, and modification
-//! beats deletion.
+//! See KEYRING-SYNC.md for the design and its measurements. gnome-keyring
+//! stays the live store (what cce-secrets and cce-browser front over the
+//! Secret Service); 1Password is the cross-machine interchange, reached
+//! through the `op` CLI as a child process (op.rs). `adopt` pairs what both
+//! sides already hold and seeds the base; `sync` is one three-way merge
+//! pass (sync.rs); `daemon` is the resident loop the systemd unit runs
+//! (daemon.rs) — resident because the CLI's authorization is keyed to the
+//! calling process's parent and lapses when idle.
 //!
-//! Discipline inherited from the scope:
-//! - the kdbx is read once into memory; nothing holds it open;
-//! - Dropbox "conflicted copy" siblings are detected and warned about
-//!   (import proceeds — it is read-only — but sync in phase 2 will refuse);
+//! Discipline kept from the kdbx era this replaced (2026-09-21):
 //! - the state file stores keyed hashes of fields, never values; the hash
-//!   key and the kdbx master password live as keyring items themselves;
-//! - attribute names match cce-secrets and KeePassXC's own Secret Service
-//!   bridge: label=Title, UserName, URL, Notes, plus kdbx-uuid / kdbx-group.
+//!   key is itself a keyring item;
+//! - attribute names match cce-secrets: label=Title, UserName, URL, Notes,
+//!   plus op-item / op-vault (kdbx-uuid / kdbx-group linger on old items
+//!   and are ignored);
+//! - the merge never destroys a value: 1Password keeps item history on
+//!   every edit, keyring deletions become Archive entries, and modification
+//!   beats deletion.
 
 mod adopt;
 mod daemon;
@@ -22,38 +25,20 @@ mod op;
 mod sync;
 
 use std::collections::HashMap;
-use std::io::Cursor;
 use std::path::{Path, PathBuf};
 
-use keepass::{Database, DatabaseKey};
-use secret_service::{EncryptionType, SecretService};
+use secret_service::SecretService;
 use serde::{Deserialize, Serialize};
 
-const DEFAULT_KDBX: &str = "Dropbox/Codes/Passwords.kdbx";
 pub(crate) const APP: &str = "cce-keyring-sync";
 
-/// One kdbx entry, flattened to what round-trips (KEYRING-SYNC.md: TOTP,
-/// attachments and history deliberately stay kdbx-side).
-struct KdbxEntry {
-    uuid: String,
-    title: String,
-    username: String,
-    password: String,
-    url: String,
-    notes: String,
-    group: String,
-    mtime: i64,
-}
-
 #[derive(Serialize, Deserialize, Default)]
 pub(crate) struct State {
     pub version: u32,
-    pub kdbx_path: String,
     pub last_run: i64,
-    /// Which interchange the base snapshot belongs to: "" (kdbx, the
-    /// original) or "onepassword" (set by `adopt`). The two key `entries`
-    /// differently — kdbx UUID vs 1Password item id — so a snapshot is only
-    /// ever meaningful to its own backend.
+    /// Which interchange the base snapshot belongs to: "onepassword" once
+    /// `adopt` has run. (The retired kdbx backend wrote "" and keyed
+    /// `entries` by kdbx UUID; such a file is refused, not misread.)
     #[serde(default)]
     pub backend: String,
     /// 1Password only: the vault new entries are created in.
@@ -71,7 +56,6 @@ pub(crate) struct State {
 pub(crate) struct EntryState {
     /// Keyed blake3 over the canonical field concatenation — never values.
     pub h: String,
-    pub kdbx_mtime: i64,
     pub keyring_modified: u64,
     /// 1Password's `updated_at` at the base, verbatim. Empty means unknown:
     /// the next sync fetches the entry regardless of the list timestamp.
@@ -99,98 +83,9 @@ pub(crate) fn now_unix() -> i64 {
         .unwrap_or(0)
 }
 
-/// Dropbox conflict siblings of the database. Their existence means diverged
-/// writes were already split; phase 2's `doctor` reconciles them.
-fn conflicted_copies(kdbx: &Path) -> Vec<PathBuf> {
-    let Some(dir) = kdbx.parent() else { return Vec::new() };
-    let mut out = Vec::new();
-    if let Ok(rd) = std::fs::read_dir(dir) {
-        for e in rd.flatten() {
-            let name = e.file_name().to_string_lossy().into_owned();
-            if name.to_lowercase().contains("conflicted copy") && name.ends_with(".kdbx") {
-                out.push(e.path());
-            }
-        }
-    }
-    out.sort();
-    out
-}
-
-/// The kdbx, read once (nothing holds a Dropbox-synced file open), plus the
-/// blake3 of the exact bytes read — the write path re-hashes the on-disk file
-/// before renaming over it, so a Dropbox delivery mid-merge aborts the save.
-struct Kdbx {
-    db: Database,
-    entries: Vec<KdbxEntry>,
-    ids: HashMap<String, keepass::db::EntryId>,
-    file_hash: blake3::Hash,
-}
-
-fn read_kdbx(path: &Path, password: &str) -> Result<Kdbx, String> {
-    let bytes = std::fs::read(path).map_err(|e| format!("reading {}: {e}", path.display()))?;
-    let file_hash = blake3::hash(&bytes);
-    let key = DatabaseKey::new().with_password(password);
-    let db = Database::open(&mut Cursor::new(bytes), key)
-        .map_err(|e| format!("opening database: {e}"))?;
-
-    // Entries in the recycle bin are deleted; importing them would resurrect
-    // every password ever discarded.
-    let recycle = db.recycle_bin().map(|g| g.id());
-
-    let mut out = Vec::new();
-    let mut ids = HashMap::new();
-    let mut skipped_recycled = 0usize;
-    for entry in db.iter_all_entries() {
-        if recycle.is_some() && Some(entry.parent().id()) == recycle {
-            skipped_recycled += 1;
-            continue;
-        }
-        let group = entry.parent().name.clone();
-        ids.insert(entry.id().uuid().to_string(), entry.id());
-        out.push(KdbxEntry {
-            // EntryId is the kdbx UUID (EntryId(Uuid)); stable across machines.
-            uuid: entry.id().uuid().to_string(),
-            title: entry.get_title().unwrap_or("").to_string(),
-            username: entry.get_username().unwrap_or("").to_string(),
-            password: entry.get_password().unwrap_or("").to_string(),
-            url: entry.get_url().unwrap_or("").to_string(),
-            notes: entry.get("Notes").unwrap_or("").to_string(),
-            group,
-            mtime: entry
-                .times
-                .last_modification
-                .map(|t| t.and_utc().timestamp())
-                .unwrap_or(0),
-        });
-    }
-    if skipped_recycled > 0 {
-        println!("  (skipping {skipped_recycled} recycled entries)");
-    }
-    Ok(Kdbx { db, entries: out, ids, file_hash })
-}
-
-fn canonical_hash(key: &[u8; 32], e: &KdbxEntry) -> String {
-    let mut h = blake3::Hasher::new_keyed(key);
-    for part in [&e.title, &e.username, &e.password, &e.url, &e.notes, &e.group] {
-        h.update(part.as_bytes());
-        h.update(&[0]);
-    }
-    h.finalize().to_hex().to_string()
-}
-
-fn attrs_for(e: &KdbxEntry) -> HashMap<&str, &str> {
-    let mut a = HashMap::new();
-    a.insert("kdbx-uuid", e.uuid.as_str());
-    a.insert("kdbx-group", e.group.as_str());
-    a.insert("UserName", e.username.as_str());
-    a.insert("URL", e.url.as_str());
-    a.insert("Notes", e.notes.as_str());
-    a
-}
-
 /// A secret held as a keyring item under our own application attribute:
-/// the kdbx master password and the state-file hash key both live this way,
-/// unlocked by PAM along with everything else.
+/// the state-file hash key lives this way, unlocked by PAM along with
+/// everything else.
 pub(crate) async fn keyring_get(
     ss: &SecretService<'_>,
     purpose: &str,
@@ -219,335 +114,6 @@ pub(crate) async fn keyring_put(
     Ok(())
 }
 
-enum Action {
-    Create,
-    Update,
-    Unchanged,
-}
-
-#[tokio::main(flavor = "current_thread")]
-async fn main() {
-    let args: Vec<String> = std::env::args().skip(1).collect();
-    let dry_run = args.iter().any(|a| a == "--dry-run");
-    let kdbx_flag = args
-        .iter()
-        .position(|a| a == "--kdbx")
-        .and_then(|i| args.get(i + 1))
-        .cloned();
-    let vault_flag = args
-        .iter()
-        .position(|a| a == "--vault")
-        .and_then(|i| args.get(i + 1))
-        .cloned();
-    // The subcommand: the first word that is neither a flag nor a flag's value.
-    let mut skip_next = false;
-    let mut cmd = None;
-    for a in &args {
-        if skip_next {
-            skip_next = false;
-            continue;
-        }
-        if a == "--kdbx" || a == "--vault" {
-            skip_next = true;
-            continue;
-        }
-        if !a.starts_with("--") {
-            cmd = Some(a.clone());
-            break;
-        }
-    }
-    let cmd = match cmd.as_deref() {
-        Some("import") => "import",
-        Some("sync") => "sync",
-        Some("doctor") => "doctor",
-        Some("status") => "status",
-        Some("adopt") => "adopt",
-        Some("daemon") => "daemon",
-        _ => {
-            eprintln!("usage: cce-keyring-sync sync   [--dry-run] [--kdbx <path>]");
-            eprintln!("       cce-keyring-sync daemon                              (resident; 1Password backend)");
-            eprintln!("       cce-keyring-sync import [--dry-run] [--kdbx <path>]");
-            eprintln!("       cce-keyring-sync doctor [--kdbx <path>]");
-            eprintln!("       cce-keyring-sync adopt  [--dry-run] [--vault <name>]   (pair the keyring with 1Password)");
-            eprintln!("       cce-keyring-sync status");
-            std::process::exit(2);
-        }
-    };
-
-    let state_path = state_dir().join("state.json");
-    let mut state: State = std::fs::read_to_string(&state_path)
-        .ok()
-        .and_then(|s| serde_json::from_str(&s).ok())
-        .unwrap_or_default();
-
-    let kdbx = kdbx_flag
-        .map(PathBuf::from)
-        .or_else(|| (!state.kdbx_path.is_empty()).then(|| PathBuf::from(&state.kdbx_path)))
-        .unwrap_or_else(|| home().join(DEFAULT_KDBX));
-
-    let onepassword = state.backend == "onepassword";
-    if cmd == "status" {
-        if onepassword {
-            println!("backend:   1Password (vault {})", if state.vault.is_empty() { "*" } else { &state.vault });
-        } else {
-            println!("backend:   kdbx");
-            println!("kdbx:      {}", kdbx.display());
-        }
-        println!("state:     {} entries, last run {}", state.entries.len(), state.last_run);
-        if !state.last_result.is_empty() {
-            println!("last:      {}", state.last_result);
-        }
-        if !onepassword {
-            for c in conflicted_copies(&kdbx) {
-                println!("CONFLICT:  {}", c.display());
-            }
-        }
-        return;
-    }
-    if cmd == "adopt" {
-        adopt::adopt(&state_path, state, vault_flag.as_deref().unwrap_or(""), dry_run).await;
-        return;
-    }
-    if cmd == "daemon" {
-        daemon::daemon(&state_path).await;
-        return;
-    }
-    if onepassword {
-        if cmd == "sync" {
-            // A one-shot pass (its own Authorize dialog); the daemon is the
-            // usual caller, and the flock keeps the two apart.
-            let mut remote = op::OnePassword::new(&state.vault);
-            let mut state = state;
-            if let Err(e) = sync::sync_remote(&mut remote, &state_path, &mut state, dry_run).await {
-                eprintln!("{e}");
-                std::process::exit(1);
-            }
-            return;
-        }
-        // The kdbx paths key their base by kdbx UUID; running one against a
-        // 1Password base would re-plan every entry from nothing.
-        eprintln!("the sync base belongs to the 1Password backend; `{cmd}` is kdbx-only");
-        std::process::exit(1);
-    }
-
-    if cmd == "doctor" {
-        doctor(&kdbx).await;
-        return;
-    }
-    if cmd == "sync" {
-        sync(&kdbx, &state_path, state, dry_run).await;
-        return;
-    }
-
-    // ---- import ----
-    if !kdbx.exists() {
-        eprintln!("no database at {} (pass --kdbx <path>)", kdbx.display());
-        std::process::exit(1);
-    }
-    let conflicts = conflicted_copies(&kdbx);
-    if !conflicts.is_empty() {
-        println!("WARNING: Dropbox conflicted copies exist — diverged writes were split:");
-        for c in &conflicts {
-            println!("  {}", c.display());
-        }
-        println!("Import reads only the main file and changes nothing in the kdbx;");
-        println!("phase 2's `doctor` will reconcile these. Do not delete them.\n");
-    }
-
-    // Quiescence: a file Dropbox wrote moments ago may still be mid-sync.
-    if let Ok(meta) = std::fs::metadata(&kdbx) {
-        if let Ok(age) = meta.modified().and_then(|m| m.elapsed().map_err(|e| std::io::Error::other(e))) {
-            if age.as_secs() < 5 {
-                eprintln!("{} changed {}s ago — letting Dropbox settle; retry shortly", kdbx.display(), age.as_secs());
-                std::process::exit(1);
-            }
-        }
-    }
-
-    let ss = match SecretService::connect(EncryptionType::Dh).await {
-        Ok(ss) => ss,
-        Err(e) => {
-            eprintln!("Secret Service unavailable: {e}");
-            std::process::exit(1);
-        }
-    };
-
-    // Master password: keyring first, prompt once otherwise.
-    let (password, password_was_prompted) = match keyring_get(&ss, "kdbx-password").await {
-        Ok(Some(bytes)) => (String::from_utf8_lossy(&bytes).into_owned(), false),
-        _ => {
-            let pw = rpassword::prompt_password(format!("master password for {}: ", kdbx.display()))
-                .unwrap_or_default();
-            if pw.is_empty() {
-                eprintln!("no password given");
-                std::process::exit(1);
-            }
-            (pw, true)
-        }
-    };
-
-    println!("reading {} …", kdbx.display());
-    let entries = match read_kdbx(&kdbx, &password) {
-        Ok(k) => k.entries,
-        Err(e) => {
-            eprintln!("{e}");
-            std::process::exit(1);
-        }
-    };
-    println!("  {} live entries", entries.len());
-
-    // The password proved right; persist it (real runs only) so the timer
-    // in phase 2 can run unattended.
-    if password_was_prompted && !dry_run {
-        if let Err(e) = keyring_put(&ss, "kdbx-password", "cce-keyring-sync: kdbx master password", password.as_bytes()).await {
-            eprintln!("could not store the master password in the keyring: {e}");
-        } else {
-            println!("  master password stored in the keyring (PAM-unlocked at login)");
-        }
-    }
-
-    // State hash key: minted once, kept in the keyring so the state file
-    // alone leaks nothing.
-    let hash_key: [u8; 32] = match keyring_get(&ss, "state-hash-key").await {
-        Ok(Some(b)) if b.len() == 32 => b.try_into().unwrap(),
-        _ => {
-            let mut k = [0u8; 32];
-            getrandom::getrandom(&mut k).expect("entropy");
-            if !dry_run {
-                if let Err(e) = keyring_put(&ss, "state-hash-key", "cce-keyring-sync: state hash key", &k).await {
-                    eprintln!("could not store the hash key: {e}");
-                }
-            }
-            k
-        }
-    };
-
-    let col = match ss.get_default_collection().await {
-        Ok(c) => c,
-        Err(e) => {
-            eprintln!("no default collection: {e}");
-            std::process::exit(1);
-        }
-    };
-    if col.is_locked().await.unwrap_or(false) {
-        if let Err(e) = col.unlock().await {
-            eprintln!("collection locked and unlock failed: {e}");
-            std::process::exit(1);
-        }
-    }
-
-    // Existing items by kdbx-uuid, one pass.
-    let mut existing: HashMap<String, secret_service::Item<'_>> = HashMap::new();
-    match col.get_all_items().await {
-        Ok(items) => {
-            for item in items {
-                if let Ok(attrs) = item.get_attributes().await {
-                    if let Some(uuid) = attrs.get("kdbx-uuid") {
-                        existing.insert(uuid.clone(), item);
-                    }
-                }
-            }
-        }
-        Err(e) => {
-            eprintln!("listing collection failed: {e}");
-            std::process::exit(1);
-        }
-    }
-
-    let (mut created, mut updated, mut unchanged) = (0usize, 0usize, 0usize);
-    let mut journal = String::new();
-    for e in &entries {
-        let action = match existing.get(&e.uuid) {
-            None => Action::Create,
-            Some(item) => {
-                let same_label = item.get_label().await.ok().as_deref() == Some(e.title.as_str());
-                let same_secret = item.get_secret().await.ok().as_deref() == Some(e.password.as_bytes());
-                let same_attrs = item.get_attributes().await.ok().is_some_and(|a| {
-                    a.get("UserName").map(String::as_str) == Some(e.username.as_str())
-                        && a.get("URL").map(String::as_str) == Some(e.url.as_str())
-                        && a.get("Notes").map(String::as_str) == Some(e.notes.as_str())
-                        && a.get("kdbx-group").map(String::as_str) == Some(e.group.as_str())
-                });
-                if same_label && same_secret && same_attrs {
-                    Action::Unchanged
-                } else {
-                    Action::Update
-                }
-            }
-        };
-        let verb = match action {
-            Action::Create => {
-                created += 1;
-                "create"
-            }
-            Action::Update => {
-                updated += 1;
-                "update"
-            }
-            Action::Unchanged => {
-                unchanged += 1;
-                "ok    "
-            }
-        };
-        if !matches!(action, Action::Unchanged) {
-            println!("  {verb}  {}", e.title);
-        }
-        journal.push_str(&format!("{} {verb} {}\n", now_unix(), e.title));
-
-        if dry_run {
-            continue;
-        }
-        match action {
-            Action::Create | Action::Update => {
-                // replace=true keys on the attribute set; identical kdbx-uuid
-                // makes this the create-or-overwrite we want.
-                if let Err(err) = col
-                    .create_item(&e.title, attrs_for(e), e.password.as_bytes(), true, "text/plain")
-                    .await
-                {
-                    eprintln!("  FAILED {}: {err}", e.title);
-                    continue;
-                }
-            }
-            Action::Unchanged => {}
-        }
-        let modified = match existing.get(&e.uuid) {
-            Some(item) => item.get_modified().await.unwrap_or(0),
-            None => now_unix() as u64,
-        };
-        state.entries.insert(
-            e.uuid.clone(),
-            EntryState { h: canonical_hash(&hash_key, e), kdbx_mtime: e.mtime, keyring_modified: modified, op_updated_at: String::new() },
-        );
-    }
-
-    println!(
-        "\n{}: {created} created, {updated} updated, {unchanged} unchanged (of {})",
-        if dry_run { "plan" } else { "imported" },
-        entries.len()
-    );
-
-    if dry_run {
-        return;
-    }
-    state.version = 1;
-    state.kdbx_path = kdbx.display().to_string();
-    state.last_run = now_unix();
-    let _ = std::fs::create_dir_all(state_dir());
-    // Atomic: temp + rename, same as every other write in this design.
-    let tmp = state_path.with_extension("json.tmp");
-    if std::fs::write(&tmp, serde_json::to_vec_pretty(&state).unwrap()).is_ok() {
-        let _ = std::fs::rename(&tmp, &state_path);
-    }
-    use std::io::Write;
-    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(state_dir().join("journal.log")) {
-        let _ = f.write_all(journal.as_bytes());
-    }
-    println!("state: {}", state_path.display());
-}
-
-// ===================== phase 2: bidirectional sync =====================
-
 /// A keyring item's synced fields, snapshotted once per run.
 #[derive(Clone)]
 pub(crate) struct KrEntry {
@@ -556,7 +122,7 @@ pub(crate) struct KrEntry {
     pub password: String,
     pub url: String,
     pub notes: String,
-    /// kdbx: the group name; 1Password: the vault name.
+    /// The vault name (`op-vault`).
     pub group: String,
     pub modified: u64,
 }
@@ -572,25 +138,8 @@ impl KrEntry {
     }
 }
 
-/// What one entry needs done, per KEYRING-SYNC.md's merge table.
-#[derive(Debug, Clone, Copy, PartialEq)]
-enum Plan {
-    ToKeyring,
-    ToKdbx,
-    /// Both changed: newer wins, loser into kdbx History.
-    ConflictKdbxWins,
-    ConflictKeyringWins,
-    DeleteKeyring,
-    RecycleKdbx,
-    InSync,
-}
-
-/// Allowed clock skew between machines before "newer" means anything.
-const SKEW_TOLERANCE_SECS: i64 = 3;
-
-/// A crude cross-process lock: sync and import must not interleave with a
-/// timer run. Advisory flock on a file in the state dir (local, never in
-/// Dropbox — Dropbox syncing lock files is its own disaster).
+/// A crude cross-process lock: a one-shot `sync` must not interleave with
+/// the daemon's tick. Advisory flock on a file in the state dir.
 pub(crate) fn take_lock() -> Option<std::fs::File> {
     let _ = std::fs::create_dir_all(state_dir());
     let f = std::fs::OpenOptions::new()
@@ -615,584 +164,79 @@ pub(crate) fn journal_append(lines: &str) {
     }
 }
 
-/// Push the entry's current values into its own History before overwriting —
-/// the "never destroy a value" rule. Mirrors what KeePass apps do on edit.
-fn push_history(db: &mut Database, id: keepass::db::EntryId) {
-    if let Some(mut em) = db.entry_mut(id) {
-        let snapshot = (*em).clone();
-        em.history.get_or_insert_with(Default::default).add_entry(snapshot);
-    }
-}
-
-fn set_kdbx_fields(db: &mut Database, id: keepass::db::EntryId, kr: &KrEntry) {
-    use keepass::db::Value;
-    if let Some(mut em) = db.entry_mut(id) {
-        em.set("Title", Value::Unprotected(kr.title.clone()));
-        em.set("UserName", Value::Unprotected(kr.username.clone()));
-        em.set("Password", Value::protected(kr.password.clone()));
-        em.set("URL", Value::Unprotected(kr.url.clone()));
-        em.set("Notes", Value::Unprotected(kr.notes.clone()));
-        em.times.last_modification = Some(keepass::db::Times::now());
-    }
-}
-
-/// The Recycle Bin group, created if the database has never had one.
-fn recycle_bin_id(db: &mut Database) -> keepass::db::GroupId {
-    if let Some(g) = db.recycle_bin() {
-        return g.id();
-    }
-    let mut root = db.root_mut();
-    let mut g = root.add_group();
-    g.name = "Recycle Bin".to_string();
-    let id = g.as_ref().id();
-    db.meta.recyclebin_uuid = Some(id.uuid());
-    db.meta.recyclebin_enabled = Some(true);
-    id
-}
-
-/// Atomic kdbx save: temp + fsync + rename, aborted if the on-disk file no
-/// longer matches the bytes this run read (Dropbox delivered mid-merge).
-fn save_kdbx_atomic(
-    db: &mut Database,
-    path: &Path,
-    password: &str,
-    read_hash: &blake3::Hash,
-) -> Result<(), String> {
-    // The crate only writes KDBX4. An older database (keepassxc still creates
-    // KDBX3 for AES-KDF) is upgraded in-memory to the current v4 defaults —
-    // argon2, same password — which every modern KeePass app reads. One-time,
-    // loud, and only on a run that was going to write anyway.
-    if !matches!(db.config.version, keepass::config::DatabaseVersion::KDB4(_)) {
-        println!("note: upgrading database format to KDBX4 (was pre-4; modern apps read v4)");
-        db.config = keepass::config::DatabaseConfig::default();
-    }
-    let current = std::fs::read(path).map_err(|e| format!("re-reading {}: {e}", path.display()))?;
-    if blake3::hash(&current) != *read_hash {
-        return Err("the kdbx changed on disk during the merge (Dropbox?) — aborting the save; retry".into());
-    }
-    let mut buf = Vec::new();
-    db.save(&mut buf, DatabaseKey::new().with_password(password))
-        .map_err(|e| format!("serializing kdbx: {e}"))?;
-    // Paranoia that pays for itself: the file we are about to install must
-    // itself open with the same password before it replaces the real one.
-    Database::open(&mut Cursor::new(buf.clone()), DatabaseKey::new().with_password(password))
-        .map_err(|e| format!("round-trip verification failed, NOT saving: {e}"))?;
-    let tmp = path.with_extension("kdbx.cce-tmp");
-    {
-        use std::io::Write;
-        let mut f = std::fs::File::create(&tmp).map_err(|e| format!("temp file: {e}"))?;
-        f.write_all(&buf).map_err(|e| format!("writing temp: {e}"))?;
-        f.sync_all().map_err(|e| format!("fsync: {e}"))?;
+pub(crate) fn write_state(state_path: &Path, state: &State) {
+    let _ = std::fs::create_dir_all(state_dir());
+    let tmp = state_path.with_extension("json.tmp");
+    if std::fs::write(&tmp, serde_json::to_vec_pretty(state).unwrap()).is_ok() {
+        let _ = std::fs::rename(&tmp, state_path);
     }
-    std::fs::rename(&tmp, path).map_err(|e| format!("rename into place: {e}"))
 }
 
-async fn sync(kdbx_path: &Path, state_path: &Path, mut state: State, dry_run: bool) {
-    let Some(_lock) = take_lock() else {
-        eprintln!("another cce-keyring-sync is running");
-        std::process::exit(1);
-    };
-    if state.entries.is_empty() {
-        eprintln!("no sync base — run `cce-keyring-sync import` first");
-        std::process::exit(1);
-    }
-    let conflicts = conflicted_copies(kdbx_path);
-    if !conflicts.is_empty() {
-        eprintln!("Dropbox conflicted copies exist — sync refuses to guess which side is real:");
-        for c in &conflicts {
-            eprintln!("  {}", c.display());
-        }
-        eprintln!("run `cce-keyring-sync doctor` first");
-        std::process::exit(1);
-    }
-    if let Ok(meta) = std::fs::metadata(kdbx_path) {
-        if let Ok(age) = meta.modified().and_then(|m| m.elapsed().map_err(std::io::Error::other)) {
-            if age.as_secs() < 5 {
-                eprintln!("kdbx changed {}s ago — letting Dropbox settle; retry shortly", age.as_secs());
-                std::process::exit(1);
-            }
-        }
-    }
-
-    let ss = match SecretService::connect(EncryptionType::Dh).await {
-        Ok(ss) => ss,
-        Err(e) => {
-            eprintln!("Secret Service unavailable: {e}");
-            std::process::exit(1);
-        }
-    };
-    let password = match keyring_get(&ss, "kdbx-password").await {
-        Ok(Some(b)) => String::from_utf8_lossy(&b).into_owned(),
-        _ => {
-            eprintln!("no stored master password — run `cce-keyring-sync import` once");
-            std::process::exit(1);
-        }
-    };
-    let hash_key: [u8; 32] = match keyring_get(&ss, "state-hash-key").await {
-        Ok(Some(b)) if b.len() == 32 => b.try_into().unwrap(),
-        _ => {
-            eprintln!("no state hash key — run `cce-keyring-sync import` once");
-            std::process::exit(1);
-        }
-    };
-
-    let mut kdbx = match read_kdbx(kdbx_path, &password) {
-        Ok(k) => k,
-        Err(e) => {
-            eprintln!("{e}");
-            std::process::exit(1);
-        }
-    };
-    let kdbx_by_uuid: HashMap<String, usize> = kdbx
-        .entries
+#[tokio::main(flavor = "current_thread")]
+async fn main() {
+    let args: Vec<String> = std::env::args().skip(1).collect();
+    let dry_run = args.iter().any(|a| a == "--dry-run");
+    let vault_flag = args
         .iter()
-        .enumerate()
-        .map(|(i, e)| (e.uuid.clone(), i))
-        .collect();
-
-    let col = match ss.get_default_collection().await {
-        Ok(c) => c,
-        Err(e) => {
-            eprintln!("no default collection: {e}");
-            std::process::exit(1);
-        }
-    };
-    if col.is_locked().await.unwrap_or(false) {
-        if col.unlock().await.is_err() {
-            eprintln!("collection locked");
-            std::process::exit(1);
-        }
-    }
-
-    // Keyring snapshot: synced items by uuid, plus adoption candidates —
-    // items that look like cce-secrets-born logins (a UserName attribute, no
-    // kdbx-uuid, not our own bookkeeping items).
-    let mut kr: HashMap<String, (secret_service::Item<'_>, KrEntry)> = HashMap::new();
-    let mut adopt: Vec<(secret_service::Item<'_>, KrEntry)> = Vec::new();
-    match col.get_all_items().await {
-        Ok(items) => {
-            for item in items {
-                let Ok(attrs) = item.get_attributes().await else { continue };
-                if attrs.get("application").map(String::as_str) == Some(APP) {
-                    continue; // our own password/hash-key items
-                }
-                let has_user = attrs.contains_key("UserName");
-                let uuid = attrs.get("kdbx-uuid").cloned();
-                if uuid.is_none() && !has_user {
-                    continue; // some other app's item — never ours to sync
-                }
-                let e = KrEntry {
-                    title: item.get_label().await.unwrap_or_default(),
-                    username: attrs.get("UserName").cloned().unwrap_or_default(),
-                    password: String::from_utf8_lossy(&item.get_secret().await.unwrap_or_default())
-                        .into_owned(),
-                    url: attrs.get("URL").cloned().unwrap_or_default(),
-                    notes: attrs.get("Notes").cloned().unwrap_or_default(),
-                    group: attrs.get("kdbx-group").cloned().unwrap_or_default(),
-                    modified: item.get_modified().await.unwrap_or(0),
-                };
-                match uuid {
-                    Some(u) => {
-                        kr.insert(u, (item, e));
-                    }
-                    None => adopt.push((item, e)),
-                }
-            }
-        }
-        Err(e) => {
-            eprintln!("listing collection failed: {e}");
-            std::process::exit(1);
-        }
-    }
-
-    // ---- plan ----
-    let mut uuids: Vec<String> = state
-        .entries
-        .keys()
-        .chain(kdbx_by_uuid.keys())
-        .chain(kr.keys())
-        .cloned()
-        .collect();
-    uuids.sort();
-    uuids.dedup();
-
-    let mut plans: Vec<(String, Plan)> = Vec::new();
-    for uuid in &uuids {
-        let base = state.entries.get(uuid);
-        let kx = kdbx_by_uuid.get(uuid).map(|&i| &kdbx.entries[i]);
-        let k = kr.get(uuid);
-        let kx_hash = kx.map(|e| canonical_hash(&hash_key, e));
-        let kr_hash = k.map(|(_, e)| e.hash(&hash_key));
-        let plan = match (base, kx, k) {
-            // Never seen: whichever side has it, the other gets it.
-            (None, Some(_), None) => Plan::ToKeyring,
-            (None, None, Some(_)) => Plan::ToKdbx,
-            (None, Some(_), Some(_)) => {
-                // Both born independently with the same uuid — cross-machine
-                // import. Fields equal → adopt as in sync; else newest wins.
-                if kx_hash == kr_hash {
-                    Plan::InSync
-                } else if kx.unwrap().mtime >= k.unwrap().1.modified as i64 {
-                    Plan::ConflictKdbxWins
-                } else {
-                    Plan::ConflictKeyringWins
-                }
-            }
-            (Some(b), Some(_), Some(_)) => {
-                let kx_changed = kx_hash.as_deref() != Some(b.h.as_str());
-                let kr_changed = kr_hash.as_deref() != Some(b.h.as_str());
-                match (kx_changed, kr_changed) {
-                    (false, false) => Plan::InSync,
-                    (true, false) => Plan::ToKeyring,
-                    (false, true) => Plan::ToKdbx,
-                    (true, true) => {
-                        let (km, im) = (kx.unwrap().mtime, k.unwrap().1.modified as i64);
-                        if (km - im).abs() <= SKEW_TOLERANCE_SECS || km >= im {
-                            Plan::ConflictKdbxWins // tie or newer: kdbx, documented
-                        } else {
-                            Plan::ConflictKeyringWins
-                        }
-                    }
-                }
-            }
-            // Deleted on one side; modification on the other beats deletion.
-            (Some(b), None, Some(_)) => {
-                if kr_hash.as_deref() != Some(b.h.as_str()) {
-                    Plan::ToKdbx // modified in keyring: resurrect
-                } else {
-                    Plan::DeleteKeyring
-                }
-            }
-            (Some(b), Some(_), None) => {
-                if kx_hash.as_deref() != Some(b.h.as_str()) {
-                    Plan::ToKeyring // modified in kdbx: resurrect
-                } else {
-                    Plan::RecycleKdbx
-                }
-            }
-            (Some(_), None, None) | (None, None, None) => Plan::InSync, // gone: forget
-        };
-        plans.push((uuid.clone(), plan));
-    }
-
-    // ---- report ----
-    let mut journal = String::new();
-    let mut counts: HashMap<&'static str, usize> = HashMap::new();
-    let describe = |uuid: &str, kx: Option<&KdbxEntry>, k: Option<&(secret_service::Item<'_>, KrEntry)>| {
-        kx.map(|e| e.title.clone())
-            .or_else(|| k.map(|(_, e)| e.title.clone()))
-            .unwrap_or_else(|| uuid.to_string())
-    };
-    for (uuid, plan) in &plans {
-        if *plan == Plan::InSync {
+        .position(|a| a == "--vault")
+        .and_then(|i| args.get(i + 1))
+        .cloned();
+    // The subcommand: the first word that is neither a flag nor a flag's value.
+    let mut skip_next = false;
+    let mut cmd = None;
+    for a in &args {
+        if skip_next {
+            skip_next = false;
             continue;
         }
-        let kx = kdbx_by_uuid.get(uuid).map(|&i| &kdbx.entries[i]);
-        let label = describe(uuid, kx, kr.get(uuid));
-        let verb = match plan {
-            Plan::ToKeyring => "kdbx -> keyring",
-            Plan::ToKdbx => "keyring -> kdbx",
-            Plan::ConflictKdbxWins => "CONFLICT: kdbx wins (loser -> History)",
-            Plan::ConflictKeyringWins => "CONFLICT: keyring wins (loser -> History)",
-            Plan::DeleteKeyring => "delete from keyring",
-            Plan::RecycleKdbx => "kdbx -> Recycle Bin",
-            Plan::InSync => unreachable!(),
-        };
-        *counts.entry(match plan {
-            Plan::ToKeyring | Plan::ConflictKdbxWins => "to-keyring",
-            Plan::ToKdbx | Plan::ConflictKeyringWins => "to-kdbx",
-            Plan::DeleteKeyring => "deleted",
-            Plan::RecycleKdbx => "recycled",
-            Plan::InSync => unreachable!(),
-        }).or_default() += 1;
-        println!("  {verb}: {label}");
-        journal.push_str(&format!("{} sync {verb}: {label}\n", now_unix()));
-    }
-    for (_, e) in &adopt {
-        println!("  adopt -> kdbx: {}", e.title);
-        journal.push_str(&format!("{} sync adopt: {}\n", now_unix(), e.title));
-    }
-    let quiet = plans.iter().all(|(_, p)| *p == Plan::InSync) && adopt.is_empty();
-    if quiet {
-        println!("in sync — nothing to do");
-    }
-    if dry_run || quiet {
-        if !dry_run {
-            state.last_run = now_unix();
-            write_state(state_path, &state);
-        }
-        return;
-    }
-
-    // ---- apply: kdbx first (atomic), keyring after, state last ----
-    // If the kdbx save aborts, the keyring is untouched and the next run
-    // re-plans from the same base. Keyring failures leave those entries out
-    // of the new state, so they are retried.
-    let mut kdbx_dirty = false;
-    let mut new_uuids_for_adopt: Vec<String> = Vec::new();
-
-    for (uuid, plan) in &plans {
-        match plan {
-            Plan::ToKdbx | Plan::ConflictKeyringWins => {
-                let (_, ke) = kr.get(uuid).unwrap();
-                match kdbx.ids.get(uuid) {
-                    Some(&id) => {
-                        push_history(&mut kdbx.db, id);
-                        set_kdbx_fields(&mut kdbx.db, id, ke);
-                    }
-                    None => {
-                        // Resurrect or first arrival from the keyring side.
-                        let uu = match uuid.parse() {
-                            Ok(u) => u,
-                            Err(_) => continue,
-                        };
-                        let id = keepass::db::EntryId::from_uuid(uu);
-                        let mut root = kdbx.db.root_mut();
-                        if root.add_entry_with_id(id).is_err() {
-                            continue; // id exists (recycled): leave to doctor
-                        }
-                        set_kdbx_fields(&mut kdbx.db, id, ke);
-                        kdbx.ids.insert(uuid.clone(), id);
-                    }
-                }
-                kdbx_dirty = true;
-            }
-            Plan::ConflictKdbxWins => {
-                // kdbx keeps its value; the keyring's losing value is still
-                // preserved as a History revision before the keyring is
-                // overwritten in the second pass.
-                if let (Some(&id), Some((_, ke))) = (kdbx.ids.get(uuid), kr.get(uuid)) {
-                    push_history(&mut kdbx.db, id);
-                    let winner_restore = kdbx_by_uuid.get(uuid).map(|&i| &kdbx.entries[i]);
-                    set_kdbx_fields(&mut kdbx.db, id, ke); // loser becomes current...
-                    push_history(&mut kdbx.db, id); // ...is archived...
-                    if let Some(w) = winner_restore {
-                        // ...and the winner is restored as current.
-                        let back = KrEntry {
-                            title: w.title.clone(),
-                            username: w.username.clone(),
-                            password: w.password.clone(),
-                            url: w.url.clone(),
-                            notes: w.notes.clone(),
-                            group: w.group.clone(),
-                            modified: 0,
-                        };
-                        set_kdbx_fields(&mut kdbx.db, id, &back);
-                    }
-                    kdbx_dirty = true;
-                }
-            }
-            Plan::RecycleKdbx => {
-                let rb = recycle_bin_id(&mut kdbx.db);
-                if let Some(&id) = kdbx.ids.get(uuid) {
-                    if let Some(mut em) = kdbx.db.entry_mut(id) {
-                        let _ = em.move_to(rb);
-                        kdbx_dirty = true;
-                    }
-                }
-            }
-            _ => {}
+        if a == "--vault" {
+            skip_next = true;
+            continue;
         }
-    }
-    for (_, ke) in &adopt {
-        let id = keepass::db::EntryId::new();
-        let uuid = id.uuid().to_string();
-        let mut root = kdbx.db.root_mut();
-        if root.add_entry_with_id(id).is_ok() {
-            set_kdbx_fields(&mut kdbx.db, id, ke);
-            kdbx.ids.insert(uuid.clone(), id);
-            new_uuids_for_adopt.push(uuid);
-            kdbx_dirty = true;
+        if !a.starts_with("--") {
+            cmd = Some(a.clone());
+            break;
         }
     }
-
-    if kdbx_dirty {
-        if let Err(e) = save_kdbx_atomic(&mut kdbx.db, kdbx_path, &password, &kdbx.file_hash) {
-            eprintln!("{e}");
-            std::process::exit(1);
+    let cmd = match cmd.as_deref() {
+        Some(c @ ("sync" | "status" | "adopt" | "daemon")) => c.to_string(),
+        _ => {
+            eprintln!("usage: cce-keyring-sync sync   [--dry-run]                  (one merge pass; raises its own Authorize dialog)");
+            eprintln!("       cce-keyring-sync daemon                              (resident; what cce-keyring-sync.service runs)");
+            eprintln!("       cce-keyring-sync adopt  [--dry-run] [--vault <name>]  (pair the keyring with 1Password, seed the base)");
+            eprintln!("       cce-keyring-sync status");
+            std::process::exit(2);
         }
-        println!("kdbx saved");
-    }
+    };
 
-    // Keyring side.
-    for (uuid, plan) in &plans {
-        match plan {
-            Plan::ToKeyring | Plan::ConflictKdbxWins => {
-                if let Some(&i) = kdbx_by_uuid.get(uuid) {
-                    let e = &kdbx.entries[i];
-                    if let Err(err) = col
-                        .create_item(&e.title, attrs_for(e), e.password.as_bytes(), true, "text/plain")
-                        .await
-                    {
-                        eprintln!("  keyring write failed for {}: {err}", e.title);
-                    }
-                }
-            }
-            Plan::DeleteKeyring => {
-                if let Some((item, e)) = kr.get(uuid) {
-                    if let Err(err) = item.delete().await {
-                        eprintln!("  keyring delete failed for {}: {err}", e.title);
-                    }
-                }
-            }
-            _ => {}
-        }
-    }
-    // Adopted items gain their kdbx-uuid so next run pairs them.
-    for ((item, ke), uuid) in adopt.iter().zip(&new_uuids_for_adopt) {
-        let mut attrs = HashMap::new();
-        attrs.insert("kdbx-uuid", uuid.as_str());
-        attrs.insert("kdbx-group", ke.group.as_str());
-        attrs.insert("UserName", ke.username.as_str());
-        attrs.insert("URL", ke.url.as_str());
-        attrs.insert("Notes", ke.notes.as_str());
-        if let Err(e) = item.set_attributes(attrs).await {
-            eprintln!("  could not stamp kdbx-uuid on {}: {e}", ke.title);
-        }
-    }
+    let state_path = state_dir().join("state.json");
+    let mut state: State = std::fs::read_to_string(&state_path)
+        .ok()
+        .and_then(|s| serde_json::from_str(&s).ok())
+        .unwrap_or_default();
 
-    // ---- new state: re-read both sides' final values cheaply from the plan ----
-    state.entries.clear();
-    let mut fresh_kr: HashMap<String, u64> = HashMap::new();
-    if let Ok(items) = col.get_all_items().await {
-        for item in items {
-            if let Ok(attrs) = item.get_attributes().await {
-                if let Some(u) = attrs.get("kdbx-uuid") {
-                    fresh_kr.insert(u.clone(), item.get_modified().await.unwrap_or(0));
-                }
+    match cmd.as_str() {
+        "status" => {
+            if state.backend == "onepassword" {
+                println!("backend:   1Password (vault {})", if state.vault.is_empty() { "*" } else { &state.vault });
+            } else {
+                println!("backend:   none — run `cce-keyring-sync adopt --vault <name>`");
             }
-        }
-    }
-    // Final field values per uuid: kdbx entries after merge are authoritative
-    // for hashes (both sides were written to match them).
-    let final_entries: Vec<KdbxEntry> = {
-        let mut list = Vec::new();
-        let recycle = kdbx.db.recycle_bin().map(|g| g.id());
-        for entry in kdbx.db.iter_all_entries() {
-            if recycle.is_some() && Some(entry.parent().id()) == recycle {
-                continue;
+            println!("state:     {} entries, last run {}", state.entries.len(), state.last_run);
+            if !state.last_result.is_empty() {
+                println!("last:      {}", state.last_result);
             }
-            list.push(KdbxEntry {
-                uuid: entry.id().uuid().to_string(),
-                title: entry.get_title().unwrap_or("").to_string(),
-                username: entry.get_username().unwrap_or("").to_string(),
-                password: entry.get_password().unwrap_or("").to_string(),
-                url: entry.get_url().unwrap_or("").to_string(),
-                notes: entry.get("Notes").unwrap_or("").to_string(),
-                group: entry.parent().name.clone(),
-                mtime: entry.times.last_modification.map(|t| t.and_utc().timestamp()).unwrap_or(0),
-            });
         }
-        list
-    };
-    for e in &final_entries {
-        state.entries.insert(
-            e.uuid.clone(),
-            EntryState {
-                h: canonical_hash(&hash_key, e),
-                kdbx_mtime: e.mtime,
-                keyring_modified: fresh_kr.get(&e.uuid).copied().unwrap_or(0),
-                op_updated_at: String::new(),
-            },
-        );
-    }
-    state.version = 1;
-    state.kdbx_path = kdbx_path.display().to_string();
-    state.last_run = now_unix();
-    write_state(state_path, &state);
-    journal_append(&journal);
-    let c = |k: &str| counts.get(k).copied().unwrap_or(0);
-    println!(
-        "synced: {} -> keyring, {} -> kdbx, {} adopted, {} deleted, {} recycled",
-        c("to-keyring"), c("to-kdbx"), adopt.len(), c("deleted"), c("recycled")
-    );
-}
-
-pub(crate) fn write_state(state_path: &Path, state: &State) {
-    let _ = std::fs::create_dir_all(state_dir());
-    let tmp = state_path.with_extension("json.tmp");
-    if std::fs::write(&tmp, serde_json::to_vec_pretty(state).unwrap()).is_ok() {
-        let _ = std::fs::rename(&tmp, state_path);
-    }
-}
-
-// ===================== doctor: Dropbox conflicted copies =====================
-
-/// Merge each conflicted copy into the main database with keepassxc-cli
-/// (battle-tested merge; same credentials), back everything up first, then
-/// archive the conflict files out of Dropbox.
-async fn doctor(kdbx: &Path) {
-    let conflicts = conflicted_copies(kdbx);
-    if conflicts.is_empty() {
-        println!("no conflicted copies — healthy");
-        return;
-    }
-    let ss = match SecretService::connect(EncryptionType::Dh).await {
-        Ok(ss) => ss,
-        Err(e) => {
-            eprintln!("Secret Service unavailable: {e}");
-            std::process::exit(1);
-        }
-    };
-    let password = match keyring_get(&ss, "kdbx-password").await {
-        Ok(Some(b)) => String::from_utf8_lossy(&b).into_owned(),
+        "adopt" => adopt::adopt(&state_path, state, vault_flag.as_deref().unwrap_or(""), dry_run).await,
+        "daemon" => daemon::daemon(&state_path).await,
         _ => {
-            eprintln!("no stored master password — run `cce-keyring-sync import` once");
-            std::process::exit(1);
-        }
-    };
-
-    let backups = state_dir().join("backups");
-    let _ = std::fs::create_dir_all(&backups);
-    let ts = now_unix();
-    let back = |p: &Path| backups.join(format!("{}.{ts}", p.file_name().unwrap().to_string_lossy()));
-    if std::fs::copy(kdbx, back(kdbx)).is_err() {
-        eprintln!("could not back up the main database — refusing to continue");
-        std::process::exit(1);
-    }
-    for c in &conflicts {
-        let _ = std::fs::copy(c, back(c));
-    }
-    println!("backups in {}", backups.display());
-
-    for c in &conflicts {
-        println!("merging {} …", c.display());
-        // Same credentials: conflicted copies share the master password.
-        let mut child = match std::process::Command::new("keepassxc-cli")
-            .args(["merge", "--same-credentials"])
-            .arg(kdbx)
-            .arg(c)
-            .stdin(std::process::Stdio::piped())
-            .stdout(std::process::Stdio::piped())
-            .stderr(std::process::Stdio::piped())
-            .spawn()
-        {
-            Ok(ch) => ch,
-            Err(e) => {
-                eprintln!("keepassxc-cli not runnable: {e}");
+            // A one-shot pass; the daemon is the usual caller, and the flock
+            // keeps the two apart.
+            let mut remote = op::OnePassword::new(&state.vault);
+            if let Err(e) = sync::sync_remote(&mut remote, &state_path, &mut state, dry_run).await {
+                eprintln!("{e}");
                 std::process::exit(1);
             }
-        };
-        {
-            use std::io::Write;
-            let _ = child.stdin.take().unwrap().write_all(format!("{password}\n").as_bytes());
-        }
-        match child.wait_with_output() {
-            Ok(out) if out.status.success() => {
-                // Resolved: archive the conflict file out of Dropbox (copy
-                // then remove — the backup dir is on another filesystem).
-                let dest = backups.join(c.file_name().unwrap());
-                if std::fs::copy(c, &dest).is_ok() {
-                    let _ = std::fs::remove_file(c);
-                    println!("  merged; archived to {}", dest.display());
-                }
-                journal_append(&format!("{} doctor merged {}\n", now_unix(), c.display()));
-            }
-            Ok(out) => {
-                eprintln!("  merge FAILED (left in place): {}", String::from_utf8_lossy(&out.stderr).trim());
-            }
-            Err(e) => eprintln!("  merge failed to run: {e}"),
         }
     }
-    println!("now run: cce-keyring-sync sync");
 }
diff --git a/src/bin/cce-keyring-sync/op.rs b/src/bin/cce-keyring-sync/op.rs
index 3c3ac82..e8e68c7 100644
--- a/src/bin/cce-keyring-sync/op.rs
+++ b/src/bin/cce-keyring-sync/op.rs
@@ -7,9 +7,8 @@
 //! [`OP_TIMEOUT`]: an unanswered Authorize dialog holds `op` for 60 s before
 //! it gives up, and a wedged app must not hold a tick forever.
 //!
-//! The `Interchange` trait is the shape the merge loop will call in phase 2;
-//! the kdbx backend joins it when `sync` is rewired, not before — an
-//! unexercised impl is dead code.
+//! The `Interchange` trait is what the merge loop (sync.rs) calls; 1Password
+//! is its only implementation since the kdbx backend retired.
 
 use std::time::Duration;
 
@@ -68,7 +67,7 @@ pub trait Interchange {
     /// returns the entry's new timestamp text, so the base can record it
     /// without another fetch.
     async fn update(&mut self, e: &RemoteEntry) -> Result<String, String>;
-    /// Soft-delete: 1Password's Archive, the kdbx's Recycle Bin.
+    /// Soft-delete: 1Password's Archive.
     async fn recycle(&mut self, id: &str) -> Result<(), String>;
 }
 
diff --git a/src/bin/cce-keyring-sync/sync.rs b/src/bin/cce-keyring-sync/sync.rs
index 2161720..3dc586d 100644
--- a/src/bin/cce-keyring-sync/sync.rs
+++ b/src/bin/cce-keyring-sync/sync.rs
@@ -1,8 +1,8 @@
 //! The three-way merge against an `Interchange` — the 1Password path.
 //!
 //! Same table as KEYRING-SYNC.md's, keyed by the interchange's item id
-//! (`op-item` on the keyring side). What differs from the kdbx `sync` in
-//! main.rs: there is no file, so nothing is batched — every remote write is
+//! (`op-item` on the keyring side). Unlike the kdbx merge this replaced,
+//! there is no file, so nothing is batched — every remote write is
 //! one `op` call, and the first remote failure stops the apply loop with the
 //! base snapshot kept for everything not yet applied, so the next run
 //! re-plans from the same place. Conflict losers need no History push:
@@ -313,7 +313,6 @@ pub async fn sync_remote<I: Interchange>(
 
     let snapshot = |k: &KrEntry, updated_raw: String, keyring_modified: u64| EntryState {
         h: k.hash(&hash_key),
-        kdbx_mtime: 0,
         keyring_modified,
         op_updated_at: updated_raw,
     };
diff --git a/src/main.rs b/src/main.rs
index 367978b..4118361 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -15,8 +15,8 @@ const BTN_W: f32 = 90.0;
 const BTN_H: f32 = 28.0;
 const CLIPBOARD_CLEAR_SECS: u64 = 30;
 
-/// Entry fields written back as Secret Service attributes (KeePassXC maps
-/// them onto its UserName / URL / Notes entry fields; Title is the label).
+/// Entry fields written back as Secret Service attributes (cce-keyring-sync
+/// mirrors them to 1Password's username / url / notes; Title is the label).
 const EDIT_ATTRS: [&str; 3] = ["UserName", "URL", "Notes"];
 
 /// One Secret Service item, sans secret: the secret itself is fetched on
@@ -58,9 +58,8 @@ enum Purpose {
 #[derive(Clone)]
 enum Cmd {
     Reload,
-    /// Run one `cce-keyring-sync sync` pass and reload. The sync logic stays
-    /// in the one binary the timer also runs; the UI only invokes it, so the
-    /// flock naturally serializes a button press against a timer tick.
+    /// Ask cce-keyring-sync for a pass and reload — the resident daemon
+    /// when it runs, the one-shot binary otherwise (see `run_sync`).
     Sync,
     GetSecret { path: String, purpose: Purpose },
     CreateItem { label: String, attrs: Vec<(String, String)>, secret: String },
@@ -208,7 +207,7 @@ fn spawn_worker(rx: std::sync::mpsc::Receiver<Cmd>, tx: calloop::channel::Sender
                 Ok(ss) => ss,
                 Err(e) => {
                     let _ = tx.send(AppMessage::Status(
-                        format!("Secret Service unavailable: {e} — is KeePassXC running with Secret Service integration enabled?"),
+                        format!("Secret Service unavailable: {e} — is gnome-keyring running?"),
                         true,
                     ));
                     return;
@@ -293,12 +292,12 @@ async fn load_entries(ss: &SecretService<'_>, tx: &calloop::channel::Sender<AppM
     let mut entries = Vec::new();
     for col in &collections {
         let label = col.get_label().await.unwrap_or_else(|_| "collection".to_string());
-        // Locked collection: unlocking prompts through the daemon (KeePassXC
-        // raises its own dialog and this await blocks until it's answered);
-        // a refused prompt just skips the collection.
+        // Locked collection: unlocking prompts through the Secret Service
+        // provider (gnome-keyring raises its own dialog and this await blocks
+        // until it's answered); a refused prompt just skips the collection.
         if col.is_locked().await.unwrap_or(false) {
             let _ = tx.send(AppMessage::Status(
-                format!("Unlock \"{label}\" in KeePassXC to load its entries…"),
+                format!("Unlock \"{label}\" to load its entries…"),
                 false,
             ));
             if col.unlock().await.is_err() || col.is_locked().await.unwrap_or(true) {
@@ -786,7 +785,7 @@ impl Application for SecretsApp {
                 }
                 self.scroll_y = self.scroll_y.clamp(0.0, self.max_scroll());
                 self.status_msg = if self.entries.is_empty() {
-                    "No entries — expose a KeePassXC group via Tools → Settings → Secret Service Integration".to_string()
+                    "No entries — run `cce-keyring-sync adopt --vault <name>` to seed the keyring from 1Password".to_string()
                 } else {
                     format!("{} entries", self.entries.len())
                 };