SYNC_STATUS.md

19.1 KB · raw

rendered · source

Peach Sync — Honest Status

Snapshot as of 2026-04-30, HEAD a85d969e6a00, package v2.13.33, native v0.1.12.

This document distinguishes what is implemented and verified, what is implemented but dormant (compiles, tests in isolation, not reachable from any live runtime path), what is stubbed, and what is missing entirely.

It exists because earlier session summaries called Phases 6–9 "done." That is true at the spec-checklist level. It is not true at the "two real devices can pair and sync" level. The system has multiple dead-ends between the parts.


TL;DR — can two devices sync today?

No.

The minimum walking skeleton requires four things, none of which currently work end-to-end:

  1. Some user gesture must call runSyncSession. Nothing does.
  2. First-time pairing must succeed without prior trust. It cannot — the responder hard-rejects un-trusted initiators.
  3. Android must run a Noise NK initiator over WebSocket. There is no JS Noise client.
  4. The native host must actually be advertising. Nothing in the live runtime path calls advertise().

Every individual piece below is real and tested. They just are not connected to one another yet.


What is solid (implemented + verified)

Native Rust host (native/peach-sync-host/, v0.1.12)

Extension TypeScript

Android

Verification matrix all green


What is wired but wrong (live but broken)

B1. INITIATE_PAIR_WITH_PEER cannot work for first-time pairing

Where: src/background/messages.ts INITIATE_PAIR_WITH_PEER → sync.connectToPeer(host, port, x25519PublicKey) → tcp.rs::authenticate_responder_side line ~503.

What happens: the responder rejects any initiator whose public key is not already in trusted_devices:

if !trusted_devices.read().await.is_trusted(&normalized_public_key_hex) {
    anyhow::bail!("initiator is not trusted");
}

The discover-and-tap flow then locally adds the peer to peach_paired_devices on success — but the connect attempt has already failed at the responder. The user sees "pair failed" and the local store ends up out of sync with the peer's reality. First-time pairing cannot succeed via this path.

Why it shipped this way: I built the pairing UI shell against the authenticated transport without first wiring an unauthenticated pairing channel. The spec defines PAIR_REQUEST / PAIR_ACCEPT / PAIR_REJECT messages exactly for this; they are present in the type union but stubbed in the dispatcher.

B2. The pairing protocol is a no-op stub

Where: src/background/sync-coordinator.ts:343-347:

case "PAIR_REQUEST":
case "PAIR_ACCEPT":
case "PAIR_REJECT":
  // Pairing flow is handled outside the data-sync state machine.
  return { outbound: [] };

There is no out-of-band pairing channel either. The comment is a lie — nothing handles these "outside the data-sync state machine" because no such machine exists.

B3. The incoming pairing banner never fires from real traffic

Where: src/background/messages.ts — the only writer to setIncomingPairingRequest is the DEBUG_INJECT_INCOMING_PAIRING test hook. sync.ts::onIncomingConnection exists but no one subscribes to it. The native host emits PEER_CONNECTED over native messaging when an incoming TCP/WS connection completes the handshake; this event is unhandled in the runtime path.

What's missing: a subscriber on onIncomingConnection that, when the connection's pairing flag is set, calls setIncomingPairingRequest and broadcasts INCOMING_PAIRING_REQUEST to the popup.

B4. The extension never advertises

Where: src/background/sync.ts:303 exports advertise(). It is called from nowhere in the runtime path. Search:

grep -rn "advertise\|Advertise" src/popup/ → no matches
grep -rn "sync.advertise\|advertise(" src/background/ → only the export

Consequence: even if Phase 10 ran with two extensions on the same network, neither would be discoverable by the other. mDNS works only because both sides advertise. The native host has the code; the extension never asks it to start.

B5. TRIGGER_SYNC is a Pro-gate stub

Where: src/background/messages.ts:634-637:

case "TRIGGER_SYNC": {
  const gate = await requirePro("sync");
  if (!gate.allowed) return { ok: false, error: "pro_required" };
  return { ok: true };
}

The "Sync now" button in Settings calls this. It does nothing. The coordinator I built in Phase 5 (runSyncSession) is fully formed and tested but has no live caller. No user action currently triggers a sync.


What is implemented but dormant (compiles + tests, not reachable)

These are real and correct; they just have no live caller upstream of them. Once the gaps above are fixed they should light up without further changes.


What is missing entirely (not even started)

M1. JavaScript Noise NK initiator for Capacitor

This is the biggest gap. Android has discovery (SyncPlugin.kt) and the bridge stub (sync-bridge.ts), but no code that:

Until this exists, Android can list peers but cannot connect to one. The desktop-to-desktop case can sidestep this because the native host (Rust) runs Noise on both ends; mobile cannot, because Capacitor's WebView cannot load the native messaging host and cannot open raw TCP.

Rough scope: a src/shared/noise-js.ts using @stablelib/x25519, @stablelib/chacha20poly1305, blakejs (or a maintained noise-js package), plus a CapacitorSyncTransport that satisfies the same SyncSessionTransport interface as createNativeSyncTransport. Probably 500–800 lines plus tests.

M2. Pairing protocol implementation

A pairing-mode connection that:

The native responder needs a pairing_mode: bool flag that, when true, allows one un-trusted handshake to succeed for the purpose of the PAIR exchange (and rejects all data-sync messages on that connection until trust is mutually recorded). This requires coordination between tcp.rs/websocket.rs (skip the trust check), auth.rs (still require proof — pairing should still bind the keypair, just not require prior trust), and the dispatcher in TS.

M3. Live trigger for sync

After pairing, something needs to actually call runSyncSession. Sensible triggers:

None of these exist.

M4. PEER_CONNECTED → incoming pairing banner subscriber

When the native host accepts an incoming pairing-mode connection, it emits PEER_CONNECTED { connection_id, peer_addr }. The extension needs to:

  1. Subscribe via sync.onIncomingConnection.
  2. Read the first frame on that connection (PAIR_REQUEST).
  3. Call setIncomingPairingRequest({ deviceName, publicKeyHex, ... }).
  4. Broadcast INCOMING_PAIRING_REQUEST to the popup.
  5. On user Accept, send PAIR_ACCEPT over the connection and call TRUST_DEVICE. On Reject, send PAIR_REJECT and disconnect.

M5. INITIATE_PAIR_WITH_PEER re-implementation

Currently it locally records the peer as "paired" before the handshake finishes. After M2 it should:

  1. Open a pairing-mode connection.
  2. Send PAIR_REQUEST { our_public_key_hex, our_device_name, code }.
  3. Wait for PAIR_ACCEPT (or PAIR_REJECT / timeout).
  4. On accept, call native TRUST_DEVICE for the peer's pubkey.
  5. Then add to peach_paired_devices.
  6. Disconnect the pairing-mode connection. Subsequent sync uses a fresh trusted connection.

M6. Auto-advertise on Pro+unlocked

Some lifecycle should call advertise() when:

And stopAdvertising on lock or Pro loss. This is what makes the device discoverable to its peers in the first place.

M7. WebSocket-side rate limiting

tcp.rs::AcceptRateLimiter is shared with websocket.rs (good), but each manager has its own instance (bad). A single attacker scanning both ports gets 2 attempts per second per IP instead of 1. Low priority but worth a note.

M8. Connection close from sync.ts

disconnectFromPeer(connectionId) sends DISCONNECT to the native host. The native side handle_disconnect removes the connection from the map but does not actively close the TCP/WS stream — it just drops the write half. The read loop will exit on next failed read, which only happens when the peer also closes. For clean shutdown the native side should explicitly shutdown the TcpStream / send a Close frame on WS.

M9. Phase 10 (manual two-device E2E)

Has not been run. This is the only honest verification that the above is all wrong-or-right. Until M1–M8 land, this can't be run for mobile, and can only be run for desktop↔desktop after M1 (kidding — without M2/M3/M4 desktop↔desktop also doesn't work past pairing).


What changed under SYN-001 that genuinely improved the codebase

Even though the system isn't end-to-end working, these are real wins already on dev:


Suggested next-bite order

If picking this up, the cheapest path to a working E2E is roughly:

  1. M6 + M3 + B5 first — make TRIGGER_SYNC (or popup unlock) call advertise() and a paired-peer sync session. This requires no new protocol; it just connects existing parts. Verifies the desktop↔desktop data-sync path against an already-trusted pair. Lets you see real runSyncSession runs against real native infrastructure.
  2. M2 + B1 + B2 + B3 + M4 + M5 — pairing protocol, in one go since they share the same surface. After this, two unrelated desktops can pair and sync.
  3. M1 — JS Noise NK. After this, an Android emulator can join.
  4. M7, M8 — small cleanups.
  5. M9 / Phase 10 — actually pair and sync two devices end-to-end.

Roughly: 1 is half a day if disciplined; 2 is one to two days; 3 is the biggest single chunk, probably two to four days depending on whether a maintained noise-js package is acceptable or a hand-rolled implementation is required.


How this report differs from earlier session notes

CONTINUITY.md has a sequence of [CODE] outcome entries that mark Phases 3, 5, 6, 7, 8, 9 as DONE. Those are accurate at the spec checklist level — the items the spec asked for in those phases were shipped. They are not accurate at the "system works" level. Specifically:

That's an overstatement on my part. This document is the correction.