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

commit0bac2e95301be472bfd14f21823da93f8041e427
parentdfd47e0c6f
authorLucas Galante <[email protected]>
date2026-09-21 12:43
cce-keyring-sync: the resident daemon and the 1Password merge (phase 2)

sync.rs runs the three-way table against the Interchange trait, keyed by
item id: list once, fetch only entries whose updated_at moved, write the
keyring in place (label/attributes/secret) or create, update remotely via
templates, archive for keyring deletes, recreate-and-restamp when a local
edit meets a remote archive; the first remote failure stops the apply
with the base kept for everything not yet done. daemon.rs keeps one
parent alive for op (phase 0's rule): a 5-minute tick, SIGUSR1 for now,
15/30/60-minute back-off after an unanswered Authorize dialog. The unit
becomes a resident service; the timer goes. cce-secrets' Sync button and
its saves poke the daemon, falling back to the one-shot.

Two op facts the end-to-end run turned up: list prints UTC timestamps,
get/edit print local-offset ones (parsed to one canonical form now), and
the app's push after an edit is asynchronous. scripts/e2e-1password.sh
exercises the whole table in an isolated keyring against a throwaway
vault: 19 checks pass.

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

 KEYRING-SYNC.md                    |  62 ++++-
 cce-keyring-sync.service           |  25 +-
 cce-keyring-sync.timer             |  10 -
 scripts/e2e-1password.sh           | 188 +++++++++++++
 src/bin/cce-keyring-sync/adopt.rs  |  13 +-
 src/bin/cce-keyring-sync/daemon.rs |  68 +++++
 src/bin/cce-keyring-sync/main.rs   |  31 ++-
 src/bin/cce-keyring-sync/op.rs     | 119 +++++---
 src/bin/cce-keyring-sync/sync.rs   | 552 +++++++++++++++++++++++++++++++++++++
 src/main.rs                        |  90 ++++--
 10 files changed, 1075 insertions(+), 83 deletions(-)

diff --git a/KEYRING-SYNC.md b/KEYRING-SYNC.md
index eaefaba..e049286 100644
--- a/KEYRING-SYNC.md
+++ b/KEYRING-SYNC.md
@@ -173,10 +173,10 @@ that is irrelevant, and there is no resident unlock to keep warm.
 
 # Scoping: 1Password as the interchange (option 1, 2026-09-21)
 
-Status: **phase 1 shipped (2026-09-21).** `adopt` paired 168 of 171 keyring
-logins with 1Password and the base is keyed by item id; the kdbx timer is
-stopped and `sync` refuses until phase 2's daemon. See "Phase 0 results"
-and "Phase 1 results" at the end.
+Status: **phase 2 shipped (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.
 
 ## Goal
 
@@ -395,11 +395,9 @@ unacceptable however native the window is. Measure it before anything else
    **Done 2026-09-21** (fab49c8, 28c7389); results below. The kdbx backend
    did *not* move behind the trait yet — an unexercised impl is dead code;
    it joins when phase 2 rewires `sync`.
-2. **`sync` on the new backend.** Exercise the full table in an isolated
-   `dbus-run-session` keyring against a throwaway vault, as phase 2 above
-   did against a fixture kdbx: edit both ways, delete both ways, conflict,
-   new-entry adoption both ways, mid-apply `op` failure. Then flip the timer
-   (cadence per phase 0) and stop the kdbx timer.
+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
@@ -567,3 +565,49 @@ Things learned that phase 2 has to carry:
   re-enable the timer; the stamps are harmless to the kdbx path).
 - The cce-secrets Sync button now runs a `sync` that refuses; its status
   line shows the refusal text until the daemon lands.
+
+## Phase 2 results (2026-09-21)
+
+Shipped: `sync.rs` (the merge against the `Interchange` trait, keyed by
+item id), `daemon.rs` (the resident loop), the `cce-keyring-sync.service`
+unit replacing the timer, and cce-secrets' Sync button and saves poking
+the daemon with `SIGUSR1` (falling back to the one-shot when no daemon
+runs). The kdbx `sync` in main.rs is untouched and unreachable under the
+1Password backend; it and the `keepass` dependency go in phase 3.
+
+The first live pass mirrored the four 1Password-only entries into the
+keyring (the account item excluded), created the three keyring-only ones
+in 1Password (the cce-mail credentials, kept by decision), and took
+1Password's notes on the one drifted pair. 174 entries in the base.
+
+`scripts/e2e-1password.sh` runs the table end to end: mirror, idempotent
+quiet pass (one `op item list`, zero fetches), edit each way, keyring-born
+create with stamp, remote-born mirror, keyring delete → Archive, Archive →
+keyring delete, both conflict directions, modification-beats-deletion
+resurrecting as a new item with a restamp, a simulated mid-apply `op`
+failure that leaves the rest for the next run, and a value-free state
+file. 19 of 19.
+
+What it found, and what was changed for it:
+
+- **`op` prints two timestamp forms.** `item list` gives UTC to the
+  second (`…T16:27:42Z`); `item get` and `item edit` give local time with
+  an offset and nanoseconds (`…T12:27:42.39627885-04:00`). The parser
+  takes both and the base stores the canonical UTC form, or a written
+  entry would never match the list again and be fetched every tick. The
+  base timestamp is also refreshed whenever an in-sync entry's list value
+  differs — the server may stamp a write a second after the reply.
+- **A remote edit needs a moment.** Listing right after `op item edit`
+  can still show the previous `updated_at` (the app pushes
+  asynchronously); in a conflict that flips the winner. Real edits arrive
+  from other devices minutes old; the test waits two seconds.
+- **Two keyring items with one stamp** (a tool that re-creates instead of
+  editing — `secret-tool store` does exactly that, adding its own
+  `xdg:schema` attribute so it never replaces) are resolved to the most
+  recently modified one.
+- **`op item get` resolves archived ids**, so "still live" is a question
+  for the list, not `get`.
+
+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.
diff --git a/cce-keyring-sync.service b/cce-keyring-sync.service
index 6a536cc..a2c14d2 100644
--- a/cce-keyring-sync.service
+++ b/cce-keyring-sync.service
@@ -1,11 +1,20 @@
-# Runs one bidirectional pass of cce-keyring-sync (see KEYRING-SYNC.md).
-# Driven by cce-keyring-sync.timer; harmless to start by hand.
+# The resident cce-keyring-sync daemon (see KEYRING-SYNC.md, "Phase 0
+# results"): keeps gnome-keyring and 1Password in step, ticking every five
+# minutes. Resident because the 1Password CLI's authorization is keyed to
+# the calling process's parent and lapses after ~10 idle minutes — a oneshot
+# timer would raise an Authorize dialog on every tick; this process raises
+# one per login. SIGUSR1 syncs now (cce-secrets sends it from its Sync
+# button and after a save).
 [Unit]
-Description=Sync gnome-keyring with the Dropbox kdbx
+Description=Sync gnome-keyring with 1Password (resident)
+After=graphical-session.target
+PartOf=graphical-session.target
 
 [Service]
-Type=oneshot
-ExecStart=%h/.local/bin/cce-keyring-sync sync
-# Refusals (conflicted copies, Dropbox settling, another run holding the
-# lock) exit nonzero on purpose; the timer just tries again next tick.
-SuccessExitStatus=0 1
+Type=simple
+ExecStart=%h/.local/bin/cce-keyring-sync daemon
+Restart=on-failure
+RestartSec=30
+
+[Install]
+WantedBy=graphical-session.target
diff --git a/cce-keyring-sync.timer b/cce-keyring-sync.timer
deleted file mode 100644
index 6cabda0..0000000
--- a/cce-keyring-sync.timer
+++ /dev/null
@@ -1,10 +0,0 @@
-[Unit]
-Description=Sync gnome-keyring with the Dropbox kdbx every 15 minutes
-
-[Timer]
-OnBootSec=3min
-OnUnitActiveSec=15min
-RandomizedDelaySec=90
-
-[Install]
-WantedBy=timers.target
diff --git a/scripts/e2e-1password.sh b/scripts/e2e-1password.sh
new file mode 100755
index 0000000..af069ea
--- /dev/null
+++ b/scripts/e2e-1password.sh
@@ -0,0 +1,188 @@
+#!/bin/bash
+# End-to-end exercise of cce-keyring-sync's 1Password merge table
+# (KEYRING-SYNC.md): an isolated gnome-keyring (own session bus, own data
+# and state dirs; the live keyring is never touched) against a throwaway
+# vault in the signed-in 1Password account, created at the start and
+# deleted at the end. Every pass runs through one resident daemon, the
+# shape the systemd unit uses.
+#
+#   scripts/e2e-1password.sh            # B=<binary> VAULT=<name> to override
+#   DAEMON_LOG_COPY=/tmp/d.log …        # keep the daemon's output
+#
+# Needs: the 1Password app unlocked with the CLI integration on, op,
+# gnome-keyring-daemon, dbus-launch, gdbus, secret-tool, python3. Raises
+# three Authorize dialogs (this script's own op calls, adopt, the daemon)
+# — click each; an unanswered one costs a 60 s retry. Lessons kept as
+# comments below: `op item get` resolves archived ids, so liveness is
+# checked on the list; `secret-tool store` never edits in place (it makes a
+# second item with the same stamp), so an "edit" deletes the old item by
+# object path first; and a remote edit needs a moment to reach the server
+# before the list reflects it.
+set -u
+B=${B:-$HOME/projects/cce/target/release/cce-keyring-sync}
+VAULT=${VAULT:-cce-sync-test}
+T=$(mktemp -d /tmp/cce-e2e.XXXX)
+# XDG_CONFIG_HOME stays: op finds the app integration through it.
+export XDG_DATA_HOME=$T/data XDG_STATE_HOME=$T/state
+mkdir -p "$XDG_DATA_HOME/keyrings" "$XDG_STATE_HOME"
+pass=0; fail=0
+ok(){ echo "  PASS $1"; pass=$((pass+1)); }
+bad(){ echo "  FAIL $1"; fail=$((fail+1)); }
+step(){ echo; echo "== $1"; }
+
+# --- isolated keyring on a private session bus ---
+eval "$(dbus-launch --sh-syntax)"
+export DBUS_SESSION_BUS_ADDRESS
+mkdir -p "$T/run"; chmod 700 "$T/run"
+KR_ENV=$(printf 'x\n' | XDG_RUNTIME_DIR=$T/run gnome-keyring-daemon --unlock --components=secrets --daemonize 2>&1)
+sleep 1
+KRPID=$(for p in $(pgrep -f gnome-keyring-daemon); do tr "\0" "\n" < /proc/$p/environ 2>/dev/null | grep -q "cce-e2e" && echo $p; done | head -1)
+echo "isolated gnome-keyring pid $KRPID"
+if ! printf "%s" "p" | secret-tool store --label="probe" probe 1 2>/dev/null; then echo "isolated keyring not usable"; exit 2; fi
+secret-tool clear probe 1
+echo "keyring ok at $DBUS_SESSION_BUS_ADDRESS (data in $T)"
+
+# --- throwaway vault ---
+op vault get "$VAULT" --format json >/dev/null 2>&1 || op vault create "$VAULT" --format json >/dev/null || { echo "vault create failed"; exit 2; }
+# start empty
+for id in $(op item list --vault "$VAULT" --format json | python3 -c 'import json,sys;print(" ".join(i["id"] for i in json.load(sys.stdin)))'); do op item delete "$id" --vault "$VAULT"; done
+mk(){ # title user pass url notes -> id
+  op item template get Login | python3 -c '
+import json,sys
+t=json.load(sys.stdin); a=sys.argv[1:]
+t["title"]=a[0]
+for f in t["fields"]:
+    f["value"]={"USERNAME":a[1],"PASSWORD":a[2],"NOTES":a[4]}[f["purpose"]]
+if a[3]: t["urls"]=[{"label":"website","primary":True,"href":a[3]}]
+print(json.dumps(t))' "$@" | op item create --vault "$VAULT" --format json - | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])'
+}
+get(){ op item get "$1" --format json | python3 -c '
+import json,sys;d=json.load(sys.stdin)
+f={x["purpose"]:x.get("value","") for x in d["fields"] if "purpose" in x}
+u=[x["href"] for x in d.get("urls",[]) if x.get("primary")] or [x["href"] for x in d.get("urls",[])] or [""]
+print("|".join([d["title"],f.get("USERNAME",""),f.get("PASSWORD",""),u[0],f.get("NOTES","")]))'; }
+# `secret-tool clear` skips items that lack its xdg:schema attribute (the
+# ones the sync creates), so "delete every item with this stamp" goes
+# through D-Bus by object path.
+kr_clear(){ for p in $(secret-tool search --all op-item "$1" 2>/dev/null | sed -n 's/^\[\(\/[0-9]*\)\]$/\/org\/freedesktop\/secrets\/collection\/login\1/p'); do gdbus call --session --dest org.freedesktop.secrets --object-path "$p" --method org.freedesktop.Secret.Item.Delete >/dev/null; done; }
+kr_path(){ secret-tool search --all op-item "$1" 2>/dev/null | sed -n 's/^\[\(\/[0-9]*\)\]$/\/org\/freedesktop\/secrets\/collection\/login\1/p' | head -1; }
+kr_get(){ # by op-item id → title|user|secret|url|notes
+  local path; path=$(kr_path "$1"); [ -z "$path" ] && { echo "<none>||||"; return; }
+  local attrs; attrs=$(gdbus call --session --dest org.freedesktop.secrets --object-path "$path" --method org.freedesktop.DBus.Properties.Get org.freedesktop.Secret.Item Attributes 2>/dev/null)
+  local label; label=$(gdbus call --session --dest org.freedesktop.secrets --object-path "$path" --method org.freedesktop.DBus.Properties.Get org.freedesktop.Secret.Item Label 2>/dev/null | sed -n "s/^(<'\(.*\)'>,)$/\1/p")
+  local secret; secret=$(secret-tool lookup op-item "$1" 2>/dev/null)
+  python3 - "$label" "$secret" "$attrs" <<'P'
+import sys,re
+label,secret,attrs=sys.argv[1:4]
+def a(k):
+    m=re.search(r"'"+re.escape(k)+r"': '((?:[^'\\]|\\.)*)'",attrs); return m.group(1) if m else ""
+print("|".join([label,a("UserName"),secret,a("URL"),a("Notes")]))
+P
+}
+kr_count(){ secret-tool search --all op-vault "$VAULT" 2>/dev/null | grep -c '^label' ; }
+mkdir -p $T/bin; cat > $T/bin/op <<'W'
+#!/bin/bash
+# test hook: fail `item edit` while the toggle file exists; pass through otherwise
+if [ "$1" = item ] && [ "$2" = edit ] && [ -e "$FAIL_EDIT_TOGGLE" ]; then echo "[ERROR] 2026/01/01 00:00:00 simulated failure" >&2; exit 1; fi
+exec /usr/sbin/op "$@"
+W
+chmod +x $T/bin/op
+export FAIL_EDIT_TOGGLE=$T/fail-edit
+STATE=$XDG_STATE_HOME/cce/keyring-sync/state.json
+last_run(){ python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("last_run",0))' "$STATE" 2>/dev/null || echo 0; }
+# One resident daemon for the whole run: its op children share one parent,
+# so one Authorize dialog covers every pass. SIGUSR1 = pass now.
+PATH=$T/bin:$PATH "$B" daemon > $T/daemon.log 2>&1 &
+DPID=$!
+sleep 1
+dump_kr(){ echo "  -- keyring items:"; secret-tool search --all op-vault "$VAULT" 2>/dev/null | grep -E "^\[|^label|attribute.op-item|^modified" | paste - - - - | sed "s/^/     /"; }
+sync(){
+  local before; before=$(last_run); local n=0
+  local mark; mark=$(wc -l < $T/daemon.log)
+  kill -USR1 $DPID
+  while [ $n -lt 200 ]; do sleep 0.5; n=$((n+1)); [ "$(last_run)" -gt "$before" ] && break; done
+  [ $n -ge 200 ] && echo "  (daemon did not finish a pass in 100s)"
+  tail -n +$((mark+1)) $T/daemon.log | grep -vE '^$' | tail -4
+}
+
+step "0. adopt on an empty keyring seeds the base (vault has 2 items)"
+A=$(mk "Alpha" "alice" "pw-a1" "https://alpha.example" "note a")
+C=$(mk "Gamma" "carol" "pw-c1" "" "")
+"$B" adopt --vault "$VAULT" 2>&1 | tail -4
+"$B" status
+step "1. first sync mirrors 1Password -> keyring"
+sync
+[ "$(kr_count)" = 2 ] && ok "2 items landed in the keyring" || bad "keyring has $(kr_count) items"
+[ "$(kr_get "$A")" = "Alpha|alice|pw-a1|https://alpha.example|note a" ] && ok "fields intact" || bad "fields: $(kr_get "$A")"
+step "2. idempotent: a second pass fetches nothing"
+out=$(sync); echo "$out" | grep -q 'in sync (0 fetched)' && ok "in sync, 0 fetched" || bad "$out"
+step "3. edit in 1Password -> keyring"
+sleep 1; op item edit "$A" password=pw-a2 >/dev/null; sleep 2
+sync
+[ "$(kr_get "$A" | cut -d'|' -f3)" = "pw-a2" ] && ok "password followed" || bad "keyring pw: $(kr_get "$A")"
+step "4. edit in keyring -> 1Password (secret + url)"
+sleep 1
+kr_clear "$A"; printf "%s" "pw-a3" | secret-tool store --label="Alpha" op-item "$A" op-vault "$VAULT" UserName alice URL "https://alpha2.example" Notes "note a"
+sync
+[ "$(get "$A")" = "Alpha|alice|pw-a3|https://alpha2.example|note a" ] && ok "1Password followed" || bad "remote: $(get "$A")"
+step "5. keyring-born entry is created in 1Password and stamped"
+printf "%s" "pw-d1" | secret-tool store --label="Delta" UserName dave URL "https://delta.example" Notes ""
+sync
+D=$(op item list --vault "$VAULT" --format json | python3 -c 'import json,sys;print(next(i["id"] for i in json.load(sys.stdin) if i["title"]=="Delta"))')
+[ -n "$D" ] && [ "$(get "$D")" = "Delta|dave|pw-d1|https://delta.example|" ] && ok "created remotely" || bad "remote Delta: $(get "$D")"
+[ "$(kr_get "$D" | cut -d'|' -f1)" = "Delta" ] && ok "stamped with its id" || bad "stamp missing"
+step "6. new item in 1Password is mirrored"
+E=$(mk "Epsilon" "erin" "pw-e1" "https://eps.example" "n"); sleep 2
+sync
+[ "$(kr_get "$E" | cut -d'|' -f3)" = "pw-e1" ] && ok "mirrored" || bad "not mirrored"
+step "7. delete in keyring -> archived in 1Password"
+kr_clear "$C"
+sync
+op item list --vault "$VAULT" --format json | grep -q "\"$C\"" && bad "Gamma still live" || ok "Gamma gone from the live list"
+op item get "$C" --include-archive --format json >/dev/null 2>&1 && ok "Gamma is in the Archive, not deleted" || bad "Gamma not in archive"
+step "8. archive in 1Password -> deleted from keyring"
+op item delete "$E" --archive; sleep 2
+sync
+[ -z "$(secret-tool search --all op-item "$E" 2>/dev/null)" ] && ok "Epsilon removed from keyring" || bad "Epsilon still in keyring"
+step "9. conflict: both edited, newer wins (keyring, edited last)"
+op item edit "$A" password=pw-a4 >/dev/null; sleep 4
+kr_clear "$A"; printf "%s" "pw-a5" | secret-tool store --label="Alpha" op-item "$A" op-vault "$VAULT" UserName alice URL "https://alpha2.example" Notes "note a"
+sync
+[ "$(get "$A" | cut -d'|' -f3)" = "pw-a5" ] && [ "$(kr_get "$A" | cut -d'|' -f3)" = "pw-a5" ] && ok "keyring won, both sides pw-a5" || bad "remote=$(get "$A" | cut -d'|' -f3) keyring=$(kr_get "$A" | cut -d'|' -f3)"
+step "10. conflict the other way: 1Password edited last"
+kr_clear "$A"; printf "%s" "pw-a6" | secret-tool store --label="Alpha" op-item "$A" op-vault "$VAULT" UserName alice URL "https://alpha2.example" Notes "note a"; sleep 4
+op item edit "$A" password=pw-a7 >/dev/null; sleep 2
+sync
+[ "$(get "$A" | cut -d'|' -f3)" = "pw-a7" ] && [ "$(kr_get "$A" | cut -d'|' -f3)" = "pw-a7" ] && ok "1Password won, both sides pw-a7" || bad "remote=$(get "$A" | cut -d'|' -f3) keyring=$(kr_get "$A" | cut -d'|' -f3)"
+step "11. modification beats deletion: archived remotely, edited locally -> recreated"
+op item delete "$D" --archive; sleep 2
+kr_clear "$D"; printf "%s" "pw-d2" | secret-tool store --label="Delta" op-item "$D" op-vault "$VAULT" UserName dave URL "https://delta.example" Notes "edited"
+sync
+D2=$(op item list --vault "$VAULT" --format json | python3 -c 'import json,sys;print(next((i["id"] for i in json.load(sys.stdin) if i["title"]=="Delta"),""))')
+[ -n "$D2" ] && [ "$D2" != "$D" ] && [ "$(get "$D2" | cut -d'|' -f3)" = "pw-d2" ] && ok "resurrected as a new item" || bad "D2=$D2"
+[ "$(kr_get "$D2" | cut -d'|' -f1)" = "Delta" ] && ok "restamped" || bad "restamp missing"
+dump_kr; echo "  -- state ids: $(python3 -c 'import json,sys;print(list(json.load(open(sys.argv[1]))["entries"]))' "$STATE")"; echo "  -- remote live: $(op item list --vault "$VAULT" --format json | python3 -c 'import json,sys;print([(i["title"],i["id"]) for i in json.load(sys.stdin)])')"
+step "12. mid-apply op failure stops the loop and keeps the base for the rest"
+touch "$FAIL_EDIT_TOGGLE"
+kr_clear "$A"; printf "%s" "pw-a8" | secret-tool store --label="Alpha" op-item "$A" op-vault "$VAULT" UserName alice URL "https://alpha2.example" Notes "note a"
+out=$(sync); echo "$out" | tail -2
+echo "$out" | grep -q 'failed: op item: simulated failure' && ok "reported the failure" || bad "no failure report: $out"
+[ "$(get "$A" | cut -d'|' -f3)" = "pw-a7" ] && ok "remote untouched" || bad "remote changed"
+rm -f "$FAIL_EDIT_TOGGLE"
+sync
+[ "$(get "$A" | cut -d'|' -f3)" = "pw-a8" ] && ok "retried on the next run" || bad "retry did not land"
+dump_kr; echo "  -- state ids: $(python3 -c 'import json,sys;print(list(json.load(open(sys.argv[1]))["entries"]))' "$STATE")"; echo "  -- remote live: $(op item list --vault "$VAULT" --format json | python3 -c 'import json,sys;print([(i["title"],i["id"]) for i in json.load(sys.stdin)])')"
+step "13. quiet again"
+out=$(sync); echo "$out" | grep -q 'in sync (0 fetched)' && ok "in sync, 0 fetched" || bad "$out"
+
+echo; echo "== $pass passed, $fail failed"
+echo "state: $XDG_STATE_HOME/cce/keyring-sync/state.json"
+grep -c '"h"' "$XDG_STATE_HOME/cce/keyring-sync/state.json" | sed 's/^/base entries: /'
+grep -qE 'pw-a|pw-d|pw-e' "$XDG_STATE_HOME/cce/keyring-sync/state.json" && echo "!! a VALUE leaked into the state file" || echo "no values in the state file"
+# --- teardown ---
+kill -TERM $DPID; wait $DPID 2>/dev/null; cp $T/daemon.log "${DAEMON_LOG_COPY:-/dev/null}"; echo "daemon log: $(wc -l < $T/daemon.log) lines"
+op vault delete "$VAULT" >/dev/null && echo "vault $VAULT deleted"
+[ -n "$KRPID" ] && kill "$KRPID" 2>/dev/null && echo "isolated keyring stopped"
+kill "$DBUS_SESSION_BUS_PID" 2>/dev/null
+rm -rf "$T"
+exit $fail
diff --git a/src/bin/cce-keyring-sync/adopt.rs b/src/bin/cce-keyring-sync/adopt.rs
index b927413..070a85e 100644
--- a/src/bin/cce-keyring-sync/adopt.rs
+++ b/src/bin/cce-keyring-sync/adopt.rs
@@ -160,11 +160,20 @@ pub async fn adopt(state_path: &std::path::Path, mut state: State, vault: &str,
             std::process::exit(1);
         }
     };
+    // The state-file hash key: minted once, kept in the keyring so the
+    // state file alone leaks nothing (a fresh keyring has none yet).
     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 (kdbx) first");
-            std::process::exit(1);
+            let mut k = [0u8; 32];
+            getrandom::getrandom(&mut k).expect("entropy");
+            if !dry_run {
+                if let Err(e) = crate::keyring_put(&ss, "state-hash-key", "cce-keyring-sync: state hash key", &k).await {
+                    eprintln!("could not store the hash key: {e}");
+                    std::process::exit(1);
+                }
+            }
+            k
         }
     };
     let col = match ss.get_default_collection().await {
diff --git a/src/bin/cce-keyring-sync/daemon.rs b/src/bin/cce-keyring-sync/daemon.rs
new file mode 100644
index 0000000..dee59ce
--- /dev/null
+++ b/src/bin/cce-keyring-sync/daemon.rs
@@ -0,0 +1,68 @@
+//! `daemon` — the resident parent that keeps the `op` authorization alive.
+//!
+//! Phase 0 (KEYRING-SYNC.md) measured the rule this loop lives by: the
+//! app's authorization is keyed to the calling process's parent and lapses
+//! after ~10 idle minutes, but use extends it indefinitely. So this process
+//! stays up for the session, ticks every [`TICK`], and every tick is one
+//! `op item list` under its own pid. One Authorize dialog per login, then
+//! none, as long as nothing (suspend, the app locking) opens a gap.
+//!
+//! A dialog nobody answers costs a 60-second hang and comes back as
+//! `authorization prompt dismissed`; re-offering one every five minutes to an
+//! empty chair is the annoyance the timer design was rejected for, so after a
+//! dismissal the tick backs off (15 → 30 → 60 minutes) until something asks:
+//! `SIGUSR1`, which cce-secrets sends from its Sync button and after a save.
+
+use std::time::Duration;
+
+use tokio::signal::unix::{signal, SignalKind};
+
+use crate::op::{is_dismissed, OnePassword};
+use crate::sync::sync_remote;
+use crate::{now_unix, State};
+
+/// Inside the ~10-minute idle window with margin.
+pub const TICK: Duration = Duration::from_secs(5 * 60);
+const BACKOFF: [Duration; 3] = [Duration::from_secs(15 * 60), Duration::from_secs(30 * 60), Duration::from_secs(60 * 60)];
+
+pub async fn daemon(state_path: &std::path::Path) {
+    let mut usr1 = signal(SignalKind::user_defined1()).expect("SIGUSR1 handler");
+    let mut term = signal(SignalKind::terminate()).expect("SIGTERM handler");
+    let mut dismissed = 0usize;
+    println!("cce-keyring-sync daemon: tick every {}s, SIGUSR1 syncs now", TICK.as_secs());
+
+    loop {
+        // Re-read every tick: adopt or a manual sync may have moved the base.
+        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 wait = if state.backend != "onepassword" {
+            eprintln!("{}: base is not 1Password's; idling until `adopt` runs", now_unix());
+            TICK
+        } else {
+            let mut remote = OnePassword::new(&state.vault);
+            match sync_remote(&mut remote, state_path, &mut state, false).await {
+                Ok(_) => {
+                    dismissed = 0;
+                    TICK
+                }
+                Err(e) if is_dismissed(&e) => {
+                    let w = BACKOFF[dismissed.min(BACKOFF.len() - 1)];
+                    dismissed += 1;
+                    eprintln!("authorization dialog unanswered; next try in {}m (or SIGUSR1)", w.as_secs() / 60);
+                    w
+                }
+                Err(e) => {
+                    eprintln!("sync: {e}");
+                    TICK
+                }
+            }
+        };
+        tokio::select! {
+            _ = tokio::time::sleep(wait) => {}
+            _ = usr1.recv() => { dismissed = 0; }
+            _ = term.recv() => { println!("cce-keyring-sync daemon: stopping"); return; }
+        }
+    }
+}
diff --git a/src/bin/cce-keyring-sync/main.rs b/src/bin/cce-keyring-sync/main.rs
index 6af163e..be3c5d5 100644
--- a/src/bin/cce-keyring-sync/main.rs
+++ b/src/bin/cce-keyring-sync/main.rs
@@ -17,7 +17,9 @@
 //!   bridge: label=Title, UserName, URL, Notes, plus kdbx-uuid / kdbx-group.
 
 mod adopt;
+mod daemon;
 mod op;
+mod sync;
 
 use std::collections::HashMap;
 use std::io::Cursor;
@@ -57,6 +59,10 @@ pub(crate) struct State {
     /// 1Password only: the vault new entries are created in.
     #[serde(default)]
     pub vault: String,
+    /// The last run's one-line outcome ("in sync", "synced: …", "failed: …"),
+    /// for cce-secrets' status line — the daemon has no stdout anyone reads.
+    #[serde(default)]
+    pub last_result: String,
     /// Per entry id: the last-synced snapshot the merge runs against.
     pub entries: HashMap<String, EntryState>,
 }
@@ -199,7 +205,7 @@ pub(crate) async fn keyring_get(
     }
 }
 
-async fn keyring_put(
+pub(crate) async fn keyring_put(
     ss: &SecretService<'_>,
     purpose: &str,
     label: &str,
@@ -256,8 +262,10 @@ async fn main() {
         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)");
@@ -286,6 +294,9 @@ async fn main() {
             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());
@@ -297,11 +308,25 @@ async fn main() {
         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");
-        eprintln!("(phase 2's daemon is not built yet — see KEYRING-SYNC.md)");
         std::process::exit(1);
     }
 
@@ -566,7 +591,7 @@ 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).
-fn take_lock() -> Option<std::fs::File> {
+pub(crate) fn take_lock() -> Option<std::fs::File> {
     let _ = std::fs::create_dir_all(state_dir());
     let f = std::fs::OpenOptions::new()
         .create(true)
diff --git a/src/bin/cce-keyring-sync/op.rs b/src/bin/cce-keyring-sync/op.rs
index f9b23e8..3c3ac82 100644
--- a/src/bin/cce-keyring-sync/op.rs
+++ b/src/bin/cce-keyring-sync/op.rs
@@ -35,8 +35,11 @@ pub struct RemoteEntry {
     pub notes: String,
     /// Server-side modification time, unix seconds (0 when unparseable).
     pub updated: i64,
-    /// The interchange's own timestamp text, verbatim, so a base snapshot
-    /// compares without re-parsing (1Password: RFC 3339 `updated_at`).
+    /// `updated` as canonical RFC 3339 UTC text (`2026-09-21T16:27:42Z`),
+    /// the form the base snapshot stores. Canonical because `op` itself is
+    /// not consistent: `item list` prints UTC to the second, `item get` and
+    /// `item edit` print local time with an offset and nanoseconds, and a
+    /// base written from one must still match a list read from the other.
     pub updated_raw: String,
 }
 
@@ -54,24 +57,23 @@ pub struct RemoteSummary {
 }
 
 /// The cross-machine store the keyring is mirrored against.
-#[allow(dead_code)] // create/update/recycle are phase 2's callers
 pub trait Interchange {
-    fn name(&self) -> &'static str;
     /// Every login the store holds — no secrets.
     async fn list(&mut self) -> Result<Vec<RemoteSummary>, String>;
     /// One entry in full.
     async fn fetch(&mut self, id: &str) -> Result<RemoteEntry, String>;
-    /// Store a new entry; returns its id. `e.id` is ignored.
-    async fn create(&mut self, e: &RemoteEntry) -> Result<String, String>;
-    /// Overwrite an existing entry's synced fields, leaving the rest alone.
-    async fn update(&mut self, e: &RemoteEntry) -> Result<(), String>;
+    /// Store a new entry; returns its id and its timestamp text. `e.id` is ignored.
+    async fn create(&mut self, e: &RemoteEntry) -> Result<(String, String), String>;
+    /// Overwrite an existing entry's synced fields, leaving the rest alone;
+    /// 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.
     async fn recycle(&mut self, id: &str) -> Result<(), String>;
 }
 
 /// True when the error text is the app's dialog timing out — a refusal to
-/// back off from, not a fault to log as one. Phase 2's daemon is the caller.
-#[allow(dead_code)]
+/// back off from, not a fault to log as one.
 pub fn is_dismissed(err: &str) -> bool {
     err.contains(DISMISSED)
 }
@@ -134,10 +136,6 @@ impl OnePassword {
 }
 
 impl Interchange for OnePassword {
-    fn name(&self) -> &'static str {
-        "onepassword"
-    }
-
     async fn list(&mut self) -> Result<Vec<RemoteSummary>, String> {
         let mut args = vec!["item", "list", "--categories", "Login"];
         if !self.vault.is_empty() {
@@ -153,7 +151,7 @@ impl Interchange for OnePassword {
         Ok(entry_from_json(&v))
     }
 
-    async fn create(&mut self, e: &RemoteEntry) -> Result<String, String> {
+    async fn create(&mut self, e: &RemoteEntry) -> Result<(String, String), String> {
         if self.vault.is_empty() {
             return Err("no vault configured for new entries (adopt --vault <name>)".into());
         }
@@ -161,19 +159,18 @@ impl Interchange for OnePassword {
         let v = self
             .run_json(&["item", "create", "--vault", self.vault.as_str(), "-"], Some(template))
             .await?;
-        v.get("id")
-            .and_then(Value::as_str)
-            .map(str::to_string)
-            .ok_or_else(|| "op item create: no id in reply".to_string())
+        let id = v.get("id").and_then(Value::as_str).ok_or("op item create: no id in reply")?;
+        Ok((id.to_string(), updated_of(&v).1))
     }
 
-    async fn update(&mut self, e: &RemoteEntry) -> Result<(), String> {
+    async fn update(&mut self, e: &RemoteEntry) -> Result<String, String> {
         // Round-trip the whole item so sections, custom fields and tags
         // survive; only the synced fields are rewritten.
         let mut v = self.run_json(&["item", "get", &e.id], None).await?;
         apply_entry(&mut v, e);
         let body = serde_json::to_vec(&v).unwrap();
-        self.run_json(&["item", "edit", &e.id], Some(body)).await.map(|_| ())
+        let reply = self.run_json(&["item", "edit", &e.id], Some(body)).await?;
+        Ok(updated_of(&reply).1)
     }
 
     async fn recycle(&mut self, id: &str) -> Result<(), String> {
@@ -210,15 +207,23 @@ fn field_by_purpose<'a>(v: &'a Value, purpose: &str) -> Option<&'a Value> {
         .find(|f| f.get("purpose").and_then(Value::as_str) == Some(purpose))
 }
 
+/// The (unix, canonical text) pair for an item's `updated_at`.
+pub fn updated_of(v: &Value) -> (i64, String) {
+    match parse_rfc3339(&s(v, "updated_at")) {
+        Some(t) => (t, format_rfc3339(t)),
+        None => (0, String::new()),
+    }
+}
+
 pub fn summary_from_json(v: &Value) -> RemoteSummary {
-    let updated_raw = s(v, "updated_at");
+    let (updated, updated_raw) = updated_of(v);
     RemoteSummary {
         id: s(v, "id"),
         vault: v.get("vault").map(|x| s(x, "name")).unwrap_or_default(),
         title: s(v, "title"),
         username: s(v, "additional_information"),
         url: primary_url(v),
-        updated: parse_rfc3339(&updated_raw).unwrap_or(0),
+        updated,
         updated_raw,
     }
 }
@@ -297,25 +302,57 @@ pub fn apply_entry(v: &mut Value, e: &RemoteEntry) {
     set(v, "NOTES", "notesPlain", "STRING", &e.notes);
 }
 
-/// `2026-09-21T15:41:14Z` (optionally with fraction) → unix seconds. Only
-/// the UTC form 1Password emits; anything else is None, and the caller
-/// treats 0 as "unknown", which the skew tolerance already absorbs.
+/// RFC 3339 → unix seconds: `2026-09-21T15:41:14Z`, or with a fraction,
+/// or with a `±HH:MM` offset (what `op item get`/`edit` print). Fractions
+/// are dropped: the list side only has seconds, and the two must agree.
 pub fn parse_rfc3339(t: &str) -> Option<i64> {
-    let t = t.strip_suffix('Z')?;
-    let (date, time) = t.split_once('T')?;
+    let (date, rest) = t.split_once('T')?;
+    let (time, offset_secs) = if let Some(r) = rest.strip_suffix('Z') {
+        (r, 0)
+    } else {
+        let i = rest.rfind(['+', '-'])?;
+        let (r, off) = rest.split_at(i);
+        let sign = if off.starts_with('-') { -1 } else { 1 };
+        let (oh, om) = off[1..].split_once(':')?;
+        (r, sign * (oh.parse::<i64>().ok()? * 3600 + om.parse::<i64>().ok()? * 60))
+    };
     let mut d = date.split('-').map(|p| p.parse::<i64>().ok());
     let (y, m, day) = (d.next()??, d.next()??, d.next()??);
     let time = time.split('.').next()?;
     let mut c = time.split(':').map(|p| p.parse::<i64>().ok());
     let (h, mi, sec) = (c.next()??, c.next()??, c.next()??);
-    // Howard Hinnant's days-from-civil.
+    Some(days_from_civil(y, m, day) * 86400 + h * 3600 + mi * 60 + sec - offset_secs)
+}
+
+/// unix seconds → `2026-09-21T16:27:42Z`, the canonical base form.
+pub fn format_rfc3339(t: i64) -> String {
+    let days = t.div_euclid(86400);
+    let rem = t.rem_euclid(86400);
+    let (y, m, d) = civil_from_days(days);
+    format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, (rem % 3600) / 60, rem % 60)
+}
+
+// Howard Hinnant's civil-date algorithms.
+fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
     let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
     let era = y.div_euclid(400);
     let yoe = y - era * 400;
-    let doy = (153 * m + 2) / 5 + day - 1;
+    let doy = (153 * m + 2) / 5 + d - 1;
     let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
-    let days = era * 146097 + doe - 719468;
-    Some(days * 86400 + h * 3600 + mi * 60 + sec)
+    era * 146097 + doe - 719468
+}
+
+fn civil_from_days(z: i64) -> (i64, i64, i64) {
+    let z = z + 719468;
+    let era = z.div_euclid(146097);
+    let doe = z - era * 146097;
+    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
+    let y = yoe + era * 400;
+    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+    let mp = (5 * doy + 2) / 153;
+    let d = doy - (153 * mp + 2) / 5 + 1;
+    let m = if mp < 10 { mp + 3 } else { mp - 9 };
+    (if m <= 2 { y + 1 } else { y }, m, d)
 }
 
 #[cfg(test)]
@@ -413,12 +450,26 @@ mod tests {
     }
 
     #[test]
-    fn rfc3339_parses_1password_timestamps_only() {
+    fn rfc3339_parses_both_forms_op_prints_to_the_same_second() {
         assert_eq!(parse_rfc3339("1970-01-01T00:00:00Z"), Some(0));
         assert_eq!(parse_rfc3339("2026-09-21T15:41:14Z"), Some(1790005274));
         assert_eq!(parse_rfc3339("2026-09-21T15:41:14.5Z"), Some(1790005274));
-        assert_eq!(parse_rfc3339("2026-09-21T15:41:14+02:00"), None);
+        // `op item edit` printed this for the item `op item list` showed as 2026-09-21T16:27:42Z.
+        assert_eq!(parse_rfc3339("2026-09-21T12:27:42.39627885-04:00"), parse_rfc3339("2026-09-21T16:27:42Z"));
+        assert_eq!(parse_rfc3339("2026-09-21T18:27:42+02:00"), parse_rfc3339("2026-09-21T16:27:42Z"));
         assert_eq!(parse_rfc3339(""), None);
+        assert_eq!(parse_rfc3339("nope"), None);
+    }
+
+    #[test]
+    fn the_canonical_form_round_trips() {
+        for t in [0i64, 951782400, 1790005274, 1790008062, 4102444799] {
+            assert_eq!(parse_rfc3339(&format_rfc3339(t)), Some(t), "{t}");
+        }
+        assert_eq!(format_rfc3339(1790005274), "2026-09-21T15:41:14Z");
+        assert_eq!(format_rfc3339(951782400), "2000-02-29T00:00:00Z");
+        let v: Value = json!({"updated_at": "2026-09-21T12:27:42.39627885-04:00"});
+        assert_eq!(updated_of(&v).1, "2026-09-21T16:27:42Z", "a get/edit reply stores as the list form");
     }
 
     #[test]
diff --git a/src/bin/cce-keyring-sync/sync.rs b/src/bin/cce-keyring-sync/sync.rs
new file mode 100644
index 0000000..2161720
--- /dev/null
+++ b/src/bin/cce-keyring-sync/sync.rs
@@ -0,0 +1,552 @@
+//! 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
+//! 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:
+//! 1Password records item history on every edit.
+//!
+//! Change detection on the remote side is by `updated_at`: an entry whose
+//! timestamp still equals the base's is unchanged and never fetched, so a
+//! quiet tick is one `op item list` and no secrets.
+
+use std::collections::HashMap;
+
+use secret_service::{EncryptionType, SecretService};
+
+use crate::adopt::{OP_ITEM_ATTR, OP_VAULT_ATTR};
+use crate::op::{Interchange, RemoteEntry};
+use crate::{journal_append, keyring_get, now_unix, take_lock, write_state, EntryState, KrEntry, State, APP};
+
+/// Allowed clock skew before "newer" means anything (the keyring's
+/// `Modified` is local time; the remote's is the server's).
+pub const SKEW_TOLERANCE_SECS: i64 = 3;
+
+/// Items the remote lists that must never reach the keyring: 1Password's
+/// own account item carries the Secret Key and account password.
+pub fn excluded_title(title: &str) -> bool {
+    title.starts_with("1Password Account")
+}
+
+/// What one entry needs done.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum Plan {
+    ToKeyring,
+    ToRemote,
+    /// Born in the keyring (or resurrected there): create remotely, stamp.
+    CreateRemote,
+    /// Both changed: newer wins, tie to the remote.
+    ConflictRemoteWins,
+    ConflictKeyringWins,
+    DeleteKeyring,
+    RecycleRemote,
+    InSync,
+    /// Gone on both sides: drop the base.
+    Forget,
+}
+
+/// One side's view of an entry for planning: its field hash and mtime.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Side {
+    pub hash: String,
+    pub time: i64,
+}
+
+/// The merge table, pure. `base` is the last-synced hash, if any.
+pub fn plan(base: Option<&str>, remote: Option<&Side>, keyring: Option<&Side>) -> Plan {
+    let newer_remote = |r: &Side, k: &Side| (r.time - k.time).abs() <= SKEW_TOLERANCE_SECS || r.time >= k.time;
+    match (base, remote, keyring) {
+        (None, Some(_), None) => Plan::ToKeyring,
+        (None, None, Some(_)) => Plan::CreateRemote,
+        (None, Some(r), Some(k)) => {
+            // Stamped but no base (a run died before writing state).
+            if r.hash == k.hash {
+                Plan::InSync
+            } else if newer_remote(r, k) {
+                Plan::ConflictRemoteWins
+            } else {
+                Plan::ConflictKeyringWins
+            }
+        }
+        (Some(b), Some(r), Some(k)) => match (r.hash != b, k.hash != b) {
+            (false, false) => Plan::InSync,
+            (true, false) => Plan::ToKeyring,
+            (false, true) => Plan::ToRemote,
+            (true, true) => {
+                if newer_remote(r, k) {
+                    Plan::ConflictRemoteWins
+                } else {
+                    Plan::ConflictKeyringWins
+                }
+            }
+        },
+        // Deleted on one side; modification on the other beats deletion.
+        (Some(b), None, Some(k)) => {
+            if k.hash != b {
+                Plan::CreateRemote
+            } else {
+                Plan::DeleteKeyring
+            }
+        }
+        (Some(b), Some(r), None) => {
+            if r.hash != b {
+                Plan::ToKeyring
+            } else {
+                Plan::RecycleRemote
+            }
+        }
+        (Some(_), None, None) | (None, None, None) => Plan::Forget,
+    }
+}
+
+fn remote_to_kr(e: &RemoteEntry, modified: u64) -> KrEntry {
+    KrEntry {
+        title: e.title.clone(),
+        username: e.username.clone(),
+        password: e.password.clone(),
+        url: e.url.clone(),
+        notes: e.notes.clone(),
+        group: e.vault.clone(),
+        modified,
+    }
+}
+
+fn kr_to_remote(k: &KrEntry, id: &str, vault: &str) -> RemoteEntry {
+    RemoteEntry {
+        id: id.to_string(),
+        vault: vault.to_string(),
+        title: k.title.clone(),
+        username: k.username.clone(),
+        password: k.password.clone(),
+        url: k.url.clone(),
+        notes: k.notes.clone(),
+        updated: 0,
+        updated_raw: String::new(),
+    }
+}
+
+fn keyring_attrs<'a>(k: &'a KrEntry, id: &'a str, extra: &'a HashMap<String, String>) -> HashMap<&'a str, &'a str> {
+    // Keep whatever else the item carried (kdbx-uuid, xdg:schema, …).
+    let mut a: HashMap<&str, &str> = extra.iter().map(|(x, y)| (x.as_str(), y.as_str())).collect();
+    a.insert(OP_ITEM_ATTR, id);
+    a.insert(OP_VAULT_ATTR, k.group.as_str());
+    a.insert("UserName", k.username.as_str());
+    a.insert("URL", k.url.as_str());
+    a.insert("Notes", k.notes.as_str());
+    a
+}
+
+struct Local<'a> {
+    item: secret_service::Item<'a>,
+    attrs: HashMap<String, String>,
+    entry: KrEntry,
+}
+
+/// One merge pass. Always writes the state file on a real run (with
+/// `last_result` set to the outcome, success or not) unless it could not
+/// even start. Returns the one-line summary, or the error.
+pub async fn sync_remote<I: Interchange>(
+    remote: &mut I,
+    state_path: &std::path::Path,
+    state: &mut State,
+    dry_run: bool,
+) -> Result<String, String> {
+    let Some(_lock) = take_lock() else {
+        return Err("another cce-keyring-sync is running".into());
+    };
+    if state.backend != "onepassword" {
+        return Err("the sync base is not 1Password's — run `cce-keyring-sync adopt` first".into());
+    }
+    let vault = state.vault.clone();
+
+    let ss = SecretService::connect(EncryptionType::Dh)
+        .await
+        .map_err(|e| format!("Secret Service unavailable: {e}"))?;
+    let hash_key: [u8; 32] = match keyring_get(&ss, "state-hash-key").await {
+        Ok(Some(b)) if b.len() == 32 => b.try_into().unwrap(),
+        _ => return Err("no state hash key — run `cce-keyring-sync adopt` first".into()),
+    };
+    let col = ss.get_default_collection().await.map_err(|e| format!("no default collection: {e}"))?;
+    if col.is_locked().await.unwrap_or(false) && col.unlock().await.is_err() {
+        return Err("collection locked".into());
+    }
+
+    // ---- keyring snapshot ----
+    let mut kr: HashMap<String, Local<'_>> = HashMap::new();
+    let mut born: Vec<Local<'_>> = Vec::new();
+    for item in col.get_all_items().await.map_err(|e| format!("listing collection failed: {e}"))? {
+        let Ok(attrs) = item.get_attributes().await else { continue };
+        if attrs.get("application").map(String::as_str) == Some(APP) {
+            continue;
+        }
+        let id = attrs.get(OP_ITEM_ATTR).cloned();
+        if id.is_none() && !attrs.contains_key("UserName") && !attrs.contains_key("kdbx-uuid") {
+            continue; // some other app's item — never ours to sync
+        }
+        let entry = 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(OP_VAULT_ATTR).cloned().unwrap_or_else(|| vault.clone()),
+            modified: item.get_modified().await.unwrap_or(0),
+        };
+        let local = Local { item, attrs, entry };
+        match id {
+            Some(id) => {
+                // Two items with one stamp (a tool that re-created rather than
+                // edited): the newer one is the person's latest word.
+                let newer = kr.get(&id).is_none_or(|old| local.entry.modified >= old.entry.modified);
+                if newer {
+                    kr.insert(id, local);
+                }
+            }
+            None => born.push(local),
+        }
+    }
+
+    // ---- remote snapshot: the list, then fetches only where needed ----
+    let summaries = remote.list().await?;
+    let mut rs: HashMap<String, crate::op::RemoteSummary> = HashMap::new();
+    for s in summaries {
+        if excluded_title(&s.title) {
+            continue;
+        }
+        rs.insert(s.id.clone(), s);
+    }
+    let mut fetched: HashMap<String, RemoteEntry> = HashMap::new();
+    let mut fetches = 0usize;
+
+    let mut ids: Vec<String> = state.entries.keys().chain(rs.keys()).chain(kr.keys()).cloned().collect();
+    ids.sort();
+    ids.dedup();
+
+    // ---- plan ----
+    let mut plans: Vec<(String, Plan)> = Vec::new();
+    for id in &ids {
+        let base = state.entries.get(id);
+        let k_side = kr.get(id).map(|l| Side { hash: l.entry.hash(&hash_key), time: l.entry.modified as i64 });
+        let r_side = match rs.get(id) {
+            None => None,
+            Some(s) => {
+                let unchanged = base.is_some_and(|b| !b.op_updated_at.is_empty() && b.op_updated_at == s.updated_raw);
+                if unchanged {
+                    Some(Side { hash: base.unwrap().h.clone(), time: s.updated })
+                } else {
+                    let e = remote.fetch(id).await?;
+                    fetches += 1;
+                    let h = remote_to_kr(&e, 0).hash(&hash_key);
+                    fetched.insert(id.clone(), e);
+                    Some(Side { hash: h, time: s.updated })
+                }
+            }
+        };
+        plans.push((id.clone(), plan(base.map(|b| b.h.as_str()), r_side.as_ref(), k_side.as_ref())));
+    }
+
+    // ---- report ----
+    let title_of = |id: &str| -> String {
+        rs.get(id)
+            .map(|s| s.title.clone())
+            .or_else(|| kr.get(id).map(|l| l.entry.title.clone()))
+            .unwrap_or_else(|| id.to_string())
+    };
+    let mut journal = String::new();
+    let mut counts: HashMap<&'static str, usize> = HashMap::new();
+    for (id, p) in &plans {
+        let verb = match p {
+            Plan::ToKeyring => "1Password -> keyring",
+            Plan::ToRemote => "keyring -> 1Password",
+            Plan::CreateRemote => "create in 1Password",
+            Plan::ConflictRemoteWins => "CONFLICT: 1Password wins (loser in item history)",
+            Plan::ConflictKeyringWins => "CONFLICT: keyring wins (loser in item history)",
+            Plan::DeleteKeyring => "delete from keyring",
+            Plan::RecycleRemote => "archive in 1Password",
+            Plan::InSync | Plan::Forget => continue,
+        };
+        *counts
+            .entry(match p {
+                Plan::ToKeyring | Plan::ConflictRemoteWins => "to-keyring",
+                Plan::ToRemote | Plan::ConflictKeyringWins => "to-remote",
+                Plan::CreateRemote => "created",
+                Plan::DeleteKeyring => "deleted",
+                Plan::RecycleRemote => "archived",
+                _ => unreachable!(),
+            })
+            .or_default() += 1;
+        println!("  {verb}: {}", title_of(id));
+        journal.push_str(&format!("{} sync {verb}: {}\n", now_unix(), title_of(id)));
+    }
+    for l in &born {
+        println!("  create in 1Password: {}", l.entry.title);
+        journal.push_str(&format!("{} sync create in 1Password: {}\n", now_unix(), l.entry.title));
+        *counts.entry("created").or_default() += 1;
+    }
+    let c = |k: &str| counts.get(k).copied().unwrap_or(0);
+    let quiet = plans.iter().all(|(_, p)| matches!(p, Plan::InSync | Plan::Forget)) && born.is_empty();
+    let summary = if quiet {
+        "in sync".to_string()
+    } else {
+        format!(
+            "synced: {} -> keyring, {} -> 1Password, {} created, {} deleted, {} archived",
+            c("to-keyring"),
+            c("to-remote"),
+            c("created"),
+            c("deleted"),
+            c("archived")
+        )
+    };
+    if dry_run {
+        println!("{summary} (dry run — nothing changed; {fetches} fetched)");
+        return Ok(summary);
+    }
+
+    // ---- apply ----
+    // `next` starts as the old base and is rewritten entry by entry, so a
+    // remote failure mid-way leaves untouched entries with their old base.
+    let mut next: HashMap<String, EntryState> = state.entries.drain().collect();
+    let mut failure: Option<String> = None;
+    let mut applied = 0usize;
+
+    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,
+    };
+    'apply: for (id, p) in &plans {
+        match p {
+            Plan::InSync => {
+                // Keep the base timestamp on the list's value: it was unknown
+                // after adopt (the drift marker), and the server may stamp a
+                // write a second later than the reply we recorded. Either way
+                // the entry would be fetched every tick until this catches up.
+                if let (Some(s), Some(b)) = (rs.get(id), next.get_mut(id)) {
+                    if b.op_updated_at != s.updated_raw {
+                        b.op_updated_at = s.updated_raw.clone();
+                    }
+                }
+            }
+            Plan::Forget => {
+                next.remove(id);
+            }
+            Plan::ToKeyring | Plan::ConflictRemoteWins => {
+                let e = match fetched.get(id) {
+                    Some(e) => e.clone(),
+                    None => match remote.fetch(id).await {
+                        Ok(e) => {
+                            fetches += 1;
+                            e
+                        }
+                        Err(err) => {
+                            failure = Some(err);
+                            break 'apply;
+                        }
+                    },
+                };
+                let k = remote_to_kr(&e, 0);
+                let empty = HashMap::new();
+                let modified = match kr.get(id) {
+                    Some(l) => {
+                        let attrs = keyring_attrs(&k, id, &l.attrs);
+                        let r = async {
+                            l.item.set_label(&k.title).await?;
+                            l.item.set_attributes(attrs).await?;
+                            l.item.set_secret(k.password.as_bytes(), "text/plain").await?;
+                            l.item.get_modified().await
+                        }
+                        .await;
+                        match r {
+                            Ok(m) => m,
+                            Err(err) => {
+                                eprintln!("  keyring write failed for {}: {err}", k.title);
+                                continue;
+                            }
+                        }
+                    }
+                    None => {
+                        let attrs = keyring_attrs(&k, id, &empty);
+                        match col.create_item(&k.title, attrs, k.password.as_bytes(), true, "text/plain").await {
+                            Ok(item) => item.get_modified().await.unwrap_or(now_unix() as u64),
+                            Err(err) => {
+                                eprintln!("  keyring create failed for {}: {err}", k.title);
+                                continue;
+                            }
+                        }
+                    }
+                };
+                next.insert(id.clone(), snapshot(&k, e.updated_raw.clone(), modified));
+                applied += 1;
+            }
+            Plan::ToRemote | Plan::ConflictKeyringWins => {
+                let l = &kr[id];
+                let e = kr_to_remote(&l.entry, id, &l.entry.group);
+                match remote.update(&e).await {
+                    Ok(updated_raw) => {
+                        next.insert(id.clone(), snapshot(&l.entry, updated_raw, l.entry.modified));
+                        applied += 1;
+                    }
+                    Err(err) => {
+                        failure = Some(err);
+                        break 'apply;
+                    }
+                }
+            }
+            Plan::CreateRemote => {
+                // A keyring entry whose stamp points at nothing any more
+                // (archived remotely, edited locally): create afresh, restamp.
+                let l = &kr[id];
+                let mut e = kr_to_remote(&l.entry, "", &vault);
+                e.vault = vault.clone();
+                match remote.create(&e).await {
+                    Ok((new_id, updated_raw)) => {
+                        let mut k = l.entry.clone();
+                        k.group = vault.clone();
+                        let attrs = keyring_attrs(&k, &new_id, &l.attrs);
+                        let modified = match async {
+                            l.item.set_attributes(attrs).await?;
+                            l.item.get_modified().await
+                        }
+                        .await
+                        {
+                            Ok(m) => m,
+                            Err(err) => {
+                                eprintln!("  could not restamp {}: {err}", k.title);
+                                l.entry.modified
+                            }
+                        };
+                        next.remove(id);
+                        next.insert(new_id, snapshot(&k, updated_raw, modified));
+                        applied += 1;
+                    }
+                    Err(err) => {
+                        failure = Some(err);
+                        break 'apply;
+                    }
+                }
+            }
+            Plan::DeleteKeyring => {
+                let l = &kr[id];
+                match l.item.delete().await {
+                    Ok(()) => {
+                        next.remove(id);
+                        applied += 1;
+                    }
+                    Err(err) => eprintln!("  keyring delete failed for {}: {err}", l.entry.title),
+                }
+            }
+            Plan::RecycleRemote => match remote.recycle(id).await {
+                Ok(()) => {
+                    next.remove(id);
+                    applied += 1;
+                }
+                Err(err) => {
+                    failure = Some(err);
+                    break 'apply;
+                }
+            },
+        }
+    }
+    if failure.is_none() {
+        for l in &born {
+            let mut k = l.entry.clone();
+            k.group = vault.clone();
+            let e = kr_to_remote(&k, "", &vault);
+            match remote.create(&e).await {
+                Ok((new_id, updated_raw)) => {
+                    let attrs = keyring_attrs(&k, &new_id, &l.attrs);
+                    let modified = match async {
+                        l.item.set_attributes(attrs).await?;
+                        l.item.get_modified().await
+                    }
+                    .await
+                    {
+                        Ok(m) => m,
+                        Err(err) => {
+                            eprintln!("  could not stamp {}: {err}", k.title);
+                            l.entry.modified
+                        }
+                    };
+                    next.insert(new_id, snapshot(&k, updated_raw, modified));
+                    applied += 1;
+                }
+                Err(err) => {
+                    failure = Some(err);
+                    break;
+                }
+            }
+        }
+    }
+
+    state.entries = next;
+    state.last_run = now_unix();
+    let result = match failure {
+        None => Ok(summary.clone()),
+        Some(err) => {
+            let changes = plans.iter().filter(|(_, p)| !matches!(p, Plan::InSync | Plan::Forget)).count() + born.len();
+            Err(format!("{err} (after {applied} of {changes} changes; the rest retry next run)"))
+        }
+    };
+    state.last_result = match &result {
+        Ok(s) => s.clone(),
+        Err(e) => format!("failed: {e}"),
+    };
+    write_state(state_path, state);
+    if !journal.is_empty() {
+        journal_append(&journal);
+    }
+    if let Err(e) = &result {
+        journal_append(&format!("{} sync FAILED: {e}\n", now_unix()));
+    }
+    println!("{} ({fetches} fetched)", state.last_result);
+    result
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn side(h: &str, t: i64) -> Side {
+        Side { hash: h.into(), time: t }
+    }
+
+    #[test]
+    fn the_merge_table() {
+        let b = Some("B");
+        assert_eq!(plan(None, Some(&side("R", 0)), None), Plan::ToKeyring);
+        assert_eq!(plan(None, None, Some(&side("K", 0))), Plan::CreateRemote);
+        assert_eq!(plan(b, Some(&side("B", 0)), Some(&side("B", 0))), Plan::InSync);
+        assert_eq!(plan(b, Some(&side("R", 0)), Some(&side("B", 0))), Plan::ToKeyring);
+        assert_eq!(plan(b, Some(&side("B", 0)), Some(&side("K", 0))), Plan::ToRemote);
+        assert_eq!(plan(b, None, Some(&side("B", 0))), Plan::DeleteKeyring);
+        assert_eq!(plan(b, None, Some(&side("K", 0))), Plan::CreateRemote, "modification beats deletion");
+        assert_eq!(plan(b, Some(&side("B", 0)), None), Plan::RecycleRemote);
+        assert_eq!(plan(b, Some(&side("R", 0)), None), Plan::ToKeyring, "modification beats deletion");
+        assert_eq!(plan(b, None, None), Plan::Forget);
+        assert_eq!(plan(None, None, None), Plan::Forget);
+    }
+
+    #[test]
+    fn conflicts_go_to_the_newer_side_and_ties_to_the_remote() {
+        let b = Some("B");
+        assert_eq!(plan(b, Some(&side("R", 100)), Some(&side("K", 50))), Plan::ConflictRemoteWins);
+        assert_eq!(plan(b, Some(&side("R", 50)), Some(&side("K", 100))), Plan::ConflictKeyringWins);
+        assert_eq!(plan(b, Some(&side("R", 98)), Some(&side("K", 100))), Plan::ConflictRemoteWins, "inside the skew tolerance is a tie");
+        assert_eq!(plan(b, Some(&side("R", 100)), Some(&side("K", 100))), Plan::ConflictRemoteWins);
+    }
+
+    #[test]
+    fn a_stamped_entry_without_a_base_is_reconciled_by_hash() {
+        assert_eq!(plan(None, Some(&side("X", 0)), Some(&side("X", 0))), Plan::InSync);
+        assert_eq!(plan(None, Some(&side("R", 10)), Some(&side("K", 0))), Plan::ConflictRemoteWins);
+    }
+
+    #[test]
+    fn the_account_item_is_excluded() {
+        assert!(excluded_title("1Password Account (lsgalante)"));
+        assert!(!excluded_title("Account at 1Password"));
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 3b5a359..367978b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -92,11 +92,71 @@ enum Mode {
     Edit { path: Option<String> },
 }
 
-/// One pass of the external sync tool. Success reports its own summary line
-/// ("synced: … " or "in sync"); refusals — conflicted copies, Dropbox
-/// settling, the flock — arrive as the error text, which is exactly the
-/// guidance the user needs ("run doctor").
+/// cce-keyring-sync's state file: `last_run` (unix seconds) and the last
+/// run's one-line outcome. The daemon's only channel back to this UI.
+fn sync_state_path() -> std::path::PathBuf {
+    std::env::var("XDG_STATE_HOME")
+        .ok()
+        .filter(|s| !s.is_empty())
+        .map(std::path::PathBuf::from)
+        .unwrap_or_else(|| {
+            std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".local/state")
+        })
+        .join("cce/keyring-sync/state.json")
+}
+
+fn read_sync_state() -> Option<(i64, String)> {
+    let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(sync_state_path()).ok()?).ok()?;
+    let last_run = v.get("last_run")?.as_i64()?;
+    let last_result = v.get("last_result").and_then(|r| r.as_str()).unwrap_or("").to_string();
+    Some((last_run, last_result))
+}
+
+/// Ask the resident daemon for a pass now. Its `op` authorization is the
+/// live one (KEYRING-SYNC.md, phase 0), so this raises no dialog; a
+/// one-shot `cce-keyring-sync sync` from here would. Fire-and-forget: a
+/// save does not wait for the mirror. False when no daemon is running.
+async fn poke_sync_daemon() -> bool {
+    let active = tokio::process::Command::new("systemctl")
+        .args(["--user", "is-active", "--quiet", "cce-keyring-sync.service"])
+        .status()
+        .await
+        .map(|s| s.success())
+        .unwrap_or(false);
+    if !active {
+        return false;
+    }
+    tokio::process::Command::new("systemctl")
+        .args(["--user", "kill", "-s", "SIGUSR1", "cce-keyring-sync.service"])
+        .status()
+        .await
+        .map(|s| s.success())
+        .unwrap_or(false)
+}
+
+/// The Sync button. With the daemon up: poke it and wait for its state
+/// file to record a new run, then show that run's outcome. Without it: the
+/// one-shot binary, whose summary line ("synced: …", "in sync") or
+/// refusal text is the status.
 async fn run_sync(tx: &calloop::channel::Sender<AppMessage>) {
+    let before = read_sync_state().map(|(t, _)| t).unwrap_or(0);
+    if poke_sync_daemon().await {
+        // A pass is one `op item list` plus writes; a dialog nobody
+        // answers holds it 60 s. Wait a little past that.
+        for _ in 0..180 {
+            tokio::time::sleep(std::time::Duration::from_millis(500)).await;
+            if let Some((t, result)) = read_sync_state() {
+                if t > before {
+                    let is_error = result.starts_with("failed");
+                    let msg = if result.is_empty() { "synced".to_string() } else { result };
+                    let _ = tx.send(AppMessage::Status(msg, is_error));
+                    return;
+                }
+            }
+        }
+        let _ = tx.send(AppMessage::Status("sync daemon did not report within 90s".to_string(), true));
+        return;
+    }
     let out = tokio::process::Command::new("cce-keyring-sync")
         .arg("sync")
         .output()
@@ -163,7 +223,13 @@ fn spawn_worker(rx: std::sync::mpsc::Receiver<Cmd>, tx: calloop::channel::Sender
                         load_entries(&ss, &tx).await;
                     }
                     op => {
+                        let edits = matches!(op, Cmd::CreateItem { .. } | Cmd::UpdateItem { .. } | Cmd::DeleteItem { .. });
                         let Err(first) = run_secret_op(&ss, &tx, op.clone()).await else {
+                            if edits {
+                                // A saved entry reaches 1Password on the
+                                // daemon's next pass; ask for it now.
+                                poke_sync_daemon().await;
+                            }
                             continue;
                         };
                         // The daemon may have restarted underneath us
@@ -572,24 +638,14 @@ impl SecretsApp {
     }
 
     /// "synced 4m ago" from cce-keyring-sync's state file, refreshed at most
-    /// every 5s — the timer runs every 15 minutes, so staleness is invisible.
+    /// every 5s — the daemon ticks every 5 minutes, so staleness is invisible.
     fn refresh_sync_hint(&mut self) {
         if self.sync_hint_at.is_some_and(|t| t.elapsed().as_secs() < 5) {
             return;
         }
         self.sync_hint_at = Some(std::time::Instant::now());
-        let path = std::env::var("XDG_STATE_HOME")
-            .ok()
-            .filter(|s| !s.is_empty())
-            .map(std::path::PathBuf::from)
-            .unwrap_or_else(|| {
-                std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".local/state")
-            })
-            .join("cce/keyring-sync/state.json");
-        self.sync_hint = std::fs::read_to_string(path)
-            .ok()
-            .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
-            .and_then(|v| v.get("last_run").and_then(|n| n.as_i64()))
+        self.sync_hint = read_sync_state()
+            .map(|(t, _)| t)
             .filter(|&t| t > 0)
             .map(|t| {
                 let ago = (std::time::SystemTime::now()