Skip to content

[Spec #130] Sync multi-machine emdash - #145

Merged
64ix merged 65 commits into
fork-mainfrom
spec/130-multi-machine-sync
Aug 11, 2026
Merged

[Spec #130] Sync multi-machine emdash#145
64ix merged 65 commits into
fork-mainfrom
spec/130-multi-machine-sync

Conversation

@64ix

@64ix 64ix commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Summary

Fork-only personal multi-machine sync for emdash: a self-operated Cloudflare Worker + D1 relay (apps/sync-relay) that orders encrypted, versioned row-bodies per space; the desktop app syncs an allowlist of Portable Data (projects minus local paths, task states, portable settings, prompt library, conversation metadata, automations configs) through a new main-process SyncEngine with injectable RelayTransport, last-write-wins by server-assigned versions, tombstones, and strict push-then-pull. Row bodies are AES-256-GCM encrypted end-to-end (per-row HKDF keys, AAD binding table/pk/client_version/key_id) with two-half pairing secrets (join_half + K0) so the relay never reads content; device-to-device pairing via copy-pasted secret, deep link emdash://join, and a Settings Devices screen. Synced projects arrive Unattached and are auto-attached by live-remote matching or attached/merged manually; automations arrive disabled with a source badge and resolve repository-instance targets at run time (workspace-config v3); a sidebar status widget shows syncing/up-to-date/offline-with-pending/error with an always-visible Sync now, plus a first-run Join-vs-start-from-scratch prompt and a not-resumable state for imported conversations.

Spec

Tickets Included

Closing References

Closes #130
Closes #131
Closes #132
Closes #133
Closes #134
Closes #135
Closes #136
Closes #137
Closes #138

Acceptance / Success Criteria

Scope Criterion Verdict Evidence
Spec Relay exposes space/join/devices/revoke + pull/push + long-poll; secret single-use/TTL/attempt-limited; per-space monotonic version in the same transaction; pull cursor semantics; stale push accepted-and-overwritten; SHA-256 token auth with constant-time compare + space scope; generic rows with (space, version) index, bodies never parsed apps/sync-relay (54 tests): counter monotonicity + batch rollback, cursor ordering/limit, LWW never-reject, tamper/revocation/space-scope auth, attempt budget
Spec projects.path nullable, hand-written migration without wiping children, unique path index preserved, data-preservation test with seeded children migration 0025 + 0025_sync_schema.test.ts (seeded tasks/conversations/settings/remotes/terminals/editor buffers/messages/automation runs preserved; PRAGMA foreign_key_check empty)
Spec sync_ts clock maintained by triggers; sync_row_state side table; tombstones propagate deletes; strict push-then-pull; local dirty rows survive pulls; LWW by server versions; versioned JSON raw + guarded; exclusions (boardRank, dead, machine-specific); repositoryWorkspaceId nulled at import; project_remotes initial-only sync-engine.db.test.ts: two-engine convergence, echo-loop (no re-push of applied rows), stale push, initial-only no-churn, raw future-version round-trips, exclusion list asserted
Spec AES-256-GCM bodies, per-row HKDF keys, random nonce, versioned envelope with key_id, AAD binds table/pk/version/key_id; K0 in safeStorage; clean decrypt failures incl. unknown key_id; relay sees only plaintext metadata + never K0 crypto.test.ts (all tamper directions), space-key-store.db.test.ts, encrypting-transport.test.ts, no-plaintext-on-wire engine tests, two-client identical-K0 invariant
Spec Device-to-device pairing: create space shows secret, second machine joins, deep link handled, single-use/TTL/attempts surfaced as clear errors; Devices screen lists/adds/revokes with confirmation; token in safeStorage, deviceId in KV pairing.test.ts + join-credential.test.ts (drives the real relay), sync-credentials.db.test.ts, devices-settings-card.test.tsx (browser, 8 tests)
Spec Synced project Unattached (distinct from path-not-found) with Attach; auto-attach by live remotes in default projects dir; attach merges same-type projects; local/SSH ambiguity prompts; SSH re-attach per machine (path travels); re-attach dedupes on path index; tasks provisionable after attach attachProject.db.test.ts + auto-attach.db.test.ts + remote-matching.test.ts; engine tests for path strip/travel/collision-null; renderer Unattached panel + attach modal
Spec Sidebar widget with 4 states + Sync now + popover (last sync/errors), driven by sync:status event + sync RPC; first-run Join (primary) vs Start from scratch; offline local-first with pending badge and reconnect push+pull; imported conversations not resumable marked explicitly sync-service.db.test.ts (14 tests vs fake relay: offline/reconnect/backoff/single-flight), sync-status-widget.test.tsx + sync-onboarding-prompt.test.tsx (browser), conversation resumability tests + badge
Spec Imported automations disabled by default with imported badge + source field; repository-instance target stored without machine-local workspace id (schema v3), resolved at run time against the project's repository workspace; Unattached project fails cleanly before task creation migration 0027 + 0027_automation_source.test.ts, workspace-config.test.ts (v3, future-version preserved), createTask.test.ts + taskCreate.test.ts (no transaction/no zombie task), engine LWW test (enabled/source local both directions)
Spec Relay tombstone GC: collected only after all devices pulled (90-day safety cap); debounced push-on-local-write so edits reach the other machine while idle final review additions: pull_cursors-based GC (4 relay tests), idle-poll push test

Modifications Par Intention

Zone Files Why / What to review
Relay (Cloudflare Worker + D1) apps/sync-relay/src/index.ts
apps/sync-relay/src/service.ts
apps/sync-relay/src/store.ts
apps/sync-relay/src/schema.ts
apps/sync-relay/src/crypto.ts
apps/sync-relay/src/db.ts
apps/sync-relay/src/types.ts
apps/sync-relay/test/
apps/sync-relay/wrangler.jsonc
apps/sync-relay/README.md
apps/sync-relay/package.json
New self-operated relay: spaces/tokens/join_secrets/sync_rows with per-space transactional version counter, pull/push/long-poll, two-half pairing (only SHA-256 digests stored), tombstone GC against per-device pull cursors. Deployable by the fork owner (free tier); protocol documented in README.

Review: Start with service.ts + store.ts (LWW, counter atomicity) and crypto.ts (secret/token formats) — the client must match these exactly.
Local schema apps/emdash-desktop/drizzle/0025_concerned_jetstream.sql
apps/emdash-desktop/drizzle/0026_nice_hex.sql
apps/emdash-desktop/drizzle/0027_old_junta.sql
apps/emdash-desktop/drizzle/0028_cloudy_nemesis.sql
apps/emdash-desktop/src/main/db/schema.ts
apps/emdash-desktop/src/main/db/tests/migrations/
apps/emdash-desktop/tooling/fixtures/
Hand-written children-first rebuild making projects.path nullable (avoids the FK cascade trap), monotonic sync_ts clocks + tombstones via triggers, sync_row_state/sync_tombstones side tables, automations.source and conversations.source machine-local columns, regenerated fixtures.

Review: 0025 is the dangerous migration (cascade-free ordering); 0026 the trigger/clock design (echo-loop prevention); check the migration tests seed children.
Sync engine + crypto (main process) apps/emdash-desktop/src/main/core/sync/engine.ts
apps/emdash-desktop/src/main/core/sync/allowlist.ts
apps/emdash-desktop/src/main/core/sync/row-state.ts
apps/emdash-desktop/src/main/core/sync/transport.ts
apps/emdash-desktop/src/main/core/sync/encrypting-transport.ts
apps/emdash-desktop/src/main/core/sync/crypto.ts
apps/emdash-desktop/src/main/core/sync/space-key-store.ts
apps/emdash-desktop/src/main/core/sync/sync-secrets.ts
apps/emdash-desktop/src/main/core/sync/sync-engine.db.test.ts
SyncEngine: push-then-pull over an injectable RelayTransport, per-table watermarks + pull cursor, LWW by server versions, tombstones, FK-orphan skip, initial-only project_remotes, column exclusions + raw versioned-JSON guarded writes; EncryptingRelayTransport wraps bodies in AES-256-GCM envelopes; per-row HKDF keys with AAD [table, pk, client_version, key_id].

Review: engine.ts dirty/echo-loop guards; allowlist.ts exclusions (machine-specific columns must never leak); crypto.ts AAD + key derivation; the no-plaintext-on-wire and two-client-K0 tests.
Pairing + Devices UI apps/emdash-desktop/src/main/core/sync/pairing.ts
apps/emdash-desktop/src/main/core/sync/auth-api.ts
apps/emdash-desktop/src/main/core/sync/sync-credentials.ts
apps/emdash-desktop/src/main/core/sync/sync-controller.ts
apps/emdash-desktop/src/main/core/sync/deep-link.ts
apps/emdash-desktop/src/renderer/features/settings/components/DevicesSettingsCard.tsx
apps/emdash-desktop/src/renderer/features/settings/components/join-sync-space-modal.tsx
apps/emdash-desktop/src/renderer/features/settings/components/pairing-secret-modal.tsx
apps/emdash-desktop/src/renderer/features/settings/sync-deep-link-handler.tsx
PairingService (create/join/mint/revoke), safeStorage credential + K0 stores, emdash:// join deep link (macOS open-url, Win/Linux second-instance), Settings Devices tab with list/add/revoke, copy button and single-use/15-min warnings.

Review: Secret handling (no logging, no persistence after use, no auto-join on deep link); the join-credential test drives the REAL relay service.
Unattached projects apps/emdash-desktop/src/main/core/projects/operations/attachProject.ts
apps/emdash-desktop/src/main/core/projects/auto-attach.ts
apps/emdash-desktop/src/main/core/projects/remote-matching.ts
apps/emdash-desktop/src/main/core/projects/operations/getProjects.ts
apps/emdash-desktop/src/renderer/features/projects/components/attach-project-modal.tsx
apps/emdash-desktop/src/renderer/features/projects/components/UnattachedProjectPanel.tsx
apps/emdash-desktop/src/shared/projects.ts
Unattached as a first-class state (path NULL vs path-not-found), auto-attach by live-remote pair-set matching in the default projects directory, attach with merge into same-type projects, local/SSH ambiguity resolution, SSH connection re-attach keyed by (host, port, username)+path, path-index dedupe, engine projectAttachHook wiring point.

Review: attachProject.ts merge/ambiguity logic; the engine import transforms (path strip/travel, collision nulling).
Sync UX + service lifecycle apps/emdash-desktop/src/main/core/sync/sync-service.ts
apps/emdash-desktop/src/main/core/sync/sync-service-instance.ts
apps/emdash-desktop/src/main/core/sync/sync-controller.ts
apps/emdash-desktop/src/shared/core/sync/status.ts
apps/emdash-desktop/src/shared/events/syncEvents.ts
apps/emdash-desktop/src/renderer/features/sync/sync-store.ts
apps/emdash-desktop/src/renderer/features/sync/sync-status-widget.tsx
apps/emdash-desktop/src/renderer/features/sync/sync-onboarding-prompt.tsx
apps/emdash-desktop/src/main/index.ts
apps/emdash-desktop/src/main/shutdown.ts
SyncService: launch sync after window creation, 25s long-poll loop with 2s→60s backoff, OS online/offline hooks, single-flight syncNow, debounced push on local writes; sync:status typed event + getSyncStatus/syncNow RPC; sidebar widget (4 states + always-visible Sync now + popover), first-run Join vs Start-from-scratch prompt, not-resumable conversation badge.

Review: sync-service.ts status transitions + backoff; the always-encrypted transport decision; widget browser tests.
Automations + conversations apps/emdash-desktop/src/main/core/automations/
apps/emdash-desktop/src/shared/core/automations/config.ts
apps/emdash-desktop/src/shared/core/workspaces/workspace-config.ts
apps/emdash-desktop/src/main/core/tasks/operations/createTask.ts
apps/emdash-desktop/src/renderer/features/automations/
apps/emdash-desktop/src/main/core/conversations/
apps/emdash-desktop/src/renderer/features/conversations/
workspace-config v3 (repository-instance.workspaceId optional, resolved at run time in prepareCreateTask, failing before task creation for Unattached projects), automation source badge + imported-disabled semantics, conversation source + not-resumable state, whole-row LWW engine tests with enabled/source staying local.

Review: createTask.ts fail-before-task-create path; workspace-config v3 migrator + future-version preservation; the two-engine LWW test.

Review Guardrails

Per-unit reviews

Final integration review

  • passed: Integration review walked the full seam chain (relay↔client↔engine↔schema↔service↔UI↔attach↔automations). Added the missing relay tombstone GC (per-device pull cursors, 90-day cap) and debounced push-on-local-write; flaky-suite triple-runs stable; browser suite green except one pre-existing fork-main failure.

Fixed after review

  • Relay tombstone GC (pull_cursors + 90-day cap) with 4 behavioral tests — spec-mandated, was missing
  • Debounced push-on-local-write so edits reach the other machine on idle poll cycles
  • Security: plaintext push fallback removed when no space key (K0-less machines fail with re-join guidance)
  • FK-orphan pull wedge fixed (skip + record instead of aborting the pull batch)
  • Join protocol mismatch fixed (raw secret vs pre-hashed) — pairing now works end-to-end against the real relay
  • Version-replay wire-seam test (relay rewriting client_version fails AEAD)

Points to double-check

  • The relay is not deployed: set EMDASH_SYNC_RELAY_URL and deploy apps/sync-relay (wrangler) before real use; createSpace mints K0 relay-side (never stored) — documented deviation
  • Devices offline >90 days lose tombstones (GC safety cap) — documented in relay README
  • A machine paired before this PR (dev-stage only) has no K0 and must re-pair
  • Root-level pnpm exec oxlint . fails on fork-main itself (nested typeAware config, oxlint 1.69.0) — pre-existing, app-dir oxlint is green

QA Plan

Automated

  • pnpm exec vitest run --project node --project main-db --project migrations --project scripts (apps/emdash-desktop) — expected: 411 files / 3654 tests pass (real PTY) (Full app gate incl. PTY integration)
  • pnpm --filter @emdash/sync-relay test — expected: 54 tests pass (Relay protocol, LWW, auth, pairing, GC)
  • pnpm typecheck && pnpm exec oxlint . (apps/emdash-desktop) — expected: 0 errors (Static checks)
  • pnpm run test:migrations — expected: 53 tests pass (Migrations 0025-0028 incl. data preservation)

Manual

  • Deploy the relay: cd apps/sync-relay && wrangler d1 create + wrangler deploy (set database_id in wrangler.jsonc) — expected: Worker reachable at the configured URL (First-run of the whole feature needs the relay)
  • On machine A: Settings > Devices > create a space — expected: Pairing secret shown with copy button + deep link; token stored (Story 1, 21)
  • On machine B: paste the secret (or open emdash://join link) — expected: B joins, Devices list shows both machines, K0 identical (rows decrypt) (Stories 1-2, 18)
  • Create a task + edit settings on A; wait one poll cycle — expected: Task state + settings appear on B (status widget up-to-date) (Stories 2, 6, 12)
  • Kill the network on B, edit a task locally — expected: Widget shows offline-with-pending with badge; reconnect → auto push+pull (Story 17)
  • On B, open a synced project with no local directory — expected: Unattached badge + Attach action; auto-attach when a matching repo exists in the default projects dir; merge prompt on ambiguity (Stories 3-5, 24)
  • Import an automation on B (synced from A) — expected: Shows 'imported, disabled' badge; enabling stays local; config edits sync (Stories 8-9)
  • Open a synced conversation with no session on this machine — expected: 'not resumable on this device' until opened (session created) (Story 11)
  • Settings > Devices > add device, then revoke one — expected: Fresh secret minted; revoked device refused on next request (Stories 21-22)

Arbitrations / Decisions

  • Problem: AAD must bind a version, but the encrypting machine cannot know the relay-stamped server version -> Resolution: client_version travels verbatim through the relay (stored/returned) and is bound into the AAD; the server counter still orders LWW; a relay rewriting client_version breaks decryption (tested).
  • Problem: spec's HKDF-halves phrasing is ambiguous against the literal [join_half||K0] secret format -> Resolution: raw halves in the secret (join_half 16B + K0 32B, base32), HKDF used for per-row keys only; both devices derive identical K0 (tested).
  • Problem: relay minted the pairing secret server-side in wave 1, which cannot keep K0 constant across devices -> Resolution: client-side minting; an existing device composes [new join_half || K0] and registers only sha256(join_half); K0 never reaches the relay.
  • Problem: K0 at space creation is minted by the relay (returned once, never stored) -> Resolution: accepted — no escrow at rest; documented deviation from 'generated locally' (would need a protocol change).
  • Problem: pairing secret format/pipeline -> Resolution: emdj1_<join b32 26><k0 b32 52> (108 chars); join presents the bare base32 join half.
  • Problem: spec text said join_hash = sha256(full credential) while the wave-1 relay accepted the raw secret -> Resolution: the relay (ticket Relais : Worker + D1 (serveur de sync) #131, already reviewed) is ground truth; client matches the server (raw secret → relay hashes).
  • Problem: imported conversations need a discriminator for the not-resumable state, but sessionId is null for local ACP conversations too -> Resolution: machine-local conversations.source column (mirrors automations), stamped 'imported' at import, never synced.
  • Problem: sync_ts triggers re-stamp on engine applies (echo loop) and same-ms edits collide -> Resolution: monotonic triggers (UPDATE: CASE NEW>OLD THEN NEW ELSE OLD+1; INSERT: MAX(wall, recorded+1, watermark+1)) + sync_row_state equality guard; no re-push of applied rows (tested across cycles).

Risks / Follow-ups

  • Two relay HTTP clients exist (HttpRelayTransport for sync, HttpRelayAuthApi for pairing) — works end-to-end; consolidation is a worthwhile follow-up.
  • The spec's sync.enabled kill switch (app setting) is unimplemented — sync runs whenever a space is paired; users pause only by removing the space.
  • Rekey is an admin operation not yet exposed: a machine that loses K0 keeps last-good content but cannot decrypt new patches until it re-joins with a fresh secret.
  • Devices offline past the 90-day GC cap miss tombstones (their stale local row resurfaces on next edit) — documented relay behavior.
  • Long-poll fetch has no client-side AbortController timeout; a black-holed TCP connection can stall the loop until the OS timeout (status stays honest).
  • Root oxlint red on fork-main itself (pre-existing config issue); browser suite has one pre-existing failure (sidebar-card-list.test.tsx, spec [Spec] Sidebar Project Groups (prototype B) #120).

@64ix

64ix commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

Review — 7 area reviewers + adversarial verification (2026-08-10)

Reviewed at b811b6228..cd7ddea25 (164 files, +26.5k). This PR was implemented against the pre-amendment spec #130, so several findings below are gaps against the 2026-08-09 amendments, not mistakes in the original work — flagged as such.

Reassurance, with one correction: K0 never transits at join (verified end-to-end — only a SHA-256 of the join half reaches the relay). But at space creation the relay Worker currently mints K0 and returns it in the secret (service.ts createSpacemakeSpaceSecret), so K0 does cross the wire relay→client once, and Cloudflare could observe it — contrary to the amended design (client generates K0 locally). See the amendment-gaps section.

✅ Fixed and pushed (7f4d4e8fc)

Five confirmed defects, each adversarially verified, with tests; gate green (relay 55 ✓, sync-engine main-db 35 ✓, app node 3238 ✓, typecheck ✓, oxlint ✓, oxfmt ✓):

  1. Relay join single-use was a TOCTOU race (service.ts/store.ts) — read used_at, then wrote it in a separate round trip, so two interleaved /v1/join calls could each mint a token from one secret. consumeJoinSecret is now an atomic guarded UPDATE ... WHERE used_at IS NULL AND expires_at > now AND attempts_left > 0 RETURNING; join() mints only if it wins. +store-level single-use test.
  2. project_remotes added after a project's first sync never synced (engine.ts) — the initial-only carry was gated on the parent project's first push, but remotes are populated on task provision, so a project that first-synced with none had an empty auto-attach hint forever. Now gated per remote row (own row-state null). Updated the initial-only test + added a zero-remotes-then-added regression test.
  3. Relay URL silently defaulted to sync-relay.emdash.sh (relay-config.ts) — upstream product infra the fork must not use for sync identity; a forgotten env var would send tokens there. Now defaults to a reserved unresolvable .invalid host (fails fast, non-sync users unaffected) + a configured flag.
  4. use-existing automations lost their workspaceId (useAutomationFormState.ts) — v3 stripped it from every repository-instance target, silently redirecting a user-picked workspace to the project root. Now stripped only for the repo-root preset.
  5. A secondary error in runLoop's failure path killed background sync forever (sync-service.ts) — markPollFailure (async buildEngine + sqlite pendingCount) could throw inside the catch, and runLoop is fire-and-forget. Wrapped the failure path + last-resort .catch on loopPromise.

⚠️ Confirmed bugs still open (deliberate work — not safe to patch blind)

  • HIGH — parent tombstone cascade-deletes children outside the dirty guard (engine.ts applyPatch delete branch). Applying a projects tombstone runs deleteRow, and SQLite FK CASCADE (foreign_keys=ON) deletes tasks/conversations/settings/remotes — and their grandchildren — bypassing the per-row isDirty/LWW machinery. A child with unpushed local edits (or a brand-new never-synced local child) is destroyed. Violates story 17 ("my local edits are never lost"). Also (MEDIUM) the cascade's own child tombstones aren't cleared, so they're echoed back to the relay. Fix needs to walk children through the same guard (flush dirty first / delete per-row) instead of relying on DB cascade — with grandchild chains, this deserves its own careful PR + tests. (spawned a task chip)
  • HIGH — synced tasks are unopenable: tasks.workspace_id syncs verbatim (allowlist.ts) — it references a per-machine workspaces row that never exists on the receiver (workspaces isn't synced). Needs importNullIfMissingFk (like the sibling assigned_pr_url) plus the "provisionable on demand" behavior (Projets non attachés : ré-attachement + fusion + auto-attach #136 AC) so a null workspace mints a local one on open. (chip)
  • HIGH — revoking a device (incl. self) traps the machine in a permanent error loop (DevicesSettingsCard.tsx + sync-service.ts) — PairingService.getState() never validates the token, so paired stays true; the card discards the real unauthorized error for a generic "check the relay" message, and the loop treats 401 as generic. Needs auth-failure detection (401/unauthorized → clear credential + K0, drop to onboarding) and threading the real error. (chip)
  • MEDIUM — "Join" is offered while already paired and silently overwrites the current space's credential/K0 (DevicesSettingsCard.tsx + pairing.ts joinSpace) — a mis-paste switches spaces with no warning, losing sync with the original devices. Needs a guard/confirm when a different-space credential exists. (chip, bundled with the above)
  • MEDIUM — mergeInto() re-parents tasks/conversations/automations via raw SQL with no events (attachProject.ts) — an already-mounted target project won't show merged children until restart (the common auto-attach case). Needs created/refresh events for the reparented rows. (chip)
  • MEDIUM — no concurrent-pusher test for the per-space version counter (amended Relais : Worker + D1 (serveur de sync) #131). My store-level TOCTOU test covers join atomicity; the counter still needs a Promise.all test, which also needs the MemoryD1.batch() fake to serialize (today it races nested BEGINs).

📋 Amendment gaps (spec #130 was amended after this PR was coded — align before merge, or fold into a follow-up)

👍 Sound alternatives — recommend the spec bless these rather than "fix" them

  • Children-first migration ordering (0025) is a verified, tested alternative to the amended spec's mandated non-transactional foreign_keys=OFF runner step — it avoids the cascade trap without a runner change. Recommend amending Schéma local : path nullable + clock de sync + identité device #132 to allow it.
  • apps/sync-relay vs the amended spec's packages/sync-relayapps/ is defensible (it's a deployable, not a library); just wire its tests into CI either way.
  • Endpoint naming (/v1/devices/join-secret vs /v1/pairings, /v1/devices/revoke vs /remove) — functionally equivalent; naming-only.

Full per-finding detail (evidence, repro, suggested fix) is in the review artifacts; happy to open any of the open-bug items as its own issue.

@64ix

64ix commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

Fix pushed to make it testable (3d4e9f3a9)

Unblocked the headline flow "pick up work on the second machine" (stories 2 / 25, ticket #136), which was broken because a synced task carried the origin machine's tasks.workspace_id verbatim → a dangling reference to a per-machine workspaces row → the task was permanently unopenable.

  • allowlist.ts: workspace_id is now importPreserveLocalColumns (same as projects.repository_workspace_id) — never applied at import. Fresh import → NULL; an already-provisioned local row keeps its own id across later syncs. (Note: the earlier review-suggested importNullIfMissingFk would have clobbered the receiver's own workspace on every pull — preserve-local is the correct mechanism.)
  • workspace-bootstrap-service.ts: when a task has no local workspace and its project is attached, mint a local/SSH worktree on demand and re-point the task; the worktree checks out the task's own branch (pushed from the origin machine). Until the project is attached it still reports missing-workspace (attach first).

Tests: on-demand mint (main-db) + workspace_id NULL-on-fresh-import / preserved-on-update convergence (sync-engine main-db); node 3238, typecheck/oxlint/oxfmt clean.

To test end to end now

  1. Deploy the relay (done via wrangler deploy) and launch both instances with EMDASH_SYNC_RELAY_URL=<your worker url> (required — the default is an unresolvable .invalid host by design).
  2. A: Settings → Devices → create space, copy secret. B: sidebar onboarding → Join → paste.
  3. Create a task on A → it appears on B; the project arrives Unattached → attach it to your local clone; then the task opens and provisions its worktree on demand.

Still open (footguns to avoid during a test, tracked): don't delete a synced project (tombstone cascade data-loss) and don't revoke a device (permanent error loop) until those land; merge-on-attach into an already-open project may need a restart to show children.

64ix added 28 commits August 11, 2026 11:15
…for multi-machine sync

Hand-written migration 0025 (spec #130, ticket #132):
- projects.path becomes nullable with the unique index on path preserved
  (SQLite treats NULLs as distinct, so multiple NULL paths are legal).
- The runner executes with foreign_keys=ON inside one transaction, so a
  naive rebuild would cascade-delete children. The rebuild instead copies
  projects + every referencing table (and their own children, messages and
  automation_runs) to _new tables, drops the originals, renames into place
  and recreates all indexes. Verified empirically against sqlite 3.51.
- Portable tables (projects, project_settings, project_remotes, tasks,
  conversations, automations) gain a sync_ts INTEGER (ms) clock, backfilled
  with the migration-time epoch and maintained by AFTER INSERT/UPDATE
  triggers (trg_<table>_sync_ts_ins/upd) for push detection
  (WHERE sync_ts > lastPushed). app_settings/kv stay kv-style without a
  clock; the sync engine handles their keys selectively.
- Machine-local device identity (device:id, device:name KV namespace) and
  reserved app_secrets keys for the sync credential and encryption key are
  added; nothing is wired into behaviour yet.

Migration tests seed project children (tasks, conversations, project
settings, remotes, terminals, editor buffers, messages, automation runs)
and assert preservation, FK integrity, NULL-path handling and the sync
clock triggers.
… for multi-machine sync

Machine-local device identity lives in the kv table under the device:
namespace (device:id UUID, device:name hostname), deliberately separate
from the telemetry instanceId. app_secrets keys sync-token and
sync-encryption-key are reserved for the sync engine (ticket #133) via
the encryptedAppSecretsStore idiom; nothing wires them into behaviour
yet. (spec #130, ticket #132)
Commit 6c24c70 added sync_ts (integer NOT NULL DEFAULT 0) to projects,
tasks and conversations in src/main/db/schema.ts. Drizzle includes the
column in generated INSERT statements (it has a default), so the
legacy-port importer tests, which build a minimal destination schema by
hand, failed with 'table projects has no column named sync_ts' — 8 tests
across service.test.ts and relational.test.ts.

Mirror the production schema by adding the sync_ts column to the
hand-written projects/tasks/conversations DDL in both test files.
New @emdash/sync-relay workspace package: the server side of the
multi-machine sync spec (#130). The relay is an ordering relay for two
personal devices: it stores opaque, client-encrypted row bodies plus
plaintext metadata and never parses body content.

- Space & devices: POST /v1/space (create space, first device token,
  pairing secret), POST /v1/join (pair a second device; single-use,
  15-minute TTL, per-secret 5-attempt budget enforced in the Worker),
  GET /v1/devices, POST /v1/devices/revoke, POST /v1/devices/join-secret
  (existing device mints a fresh pairing secret).
- Sync: POST /v1/sync/push stamps a per-space monotonic version via
  INSERT ... ON CONFLICT DO UPDATE ... RETURNING version inside the
  same db.batch() transaction as the row upsert; last-write-wins by
  server receipt order, stale pushes accepted, never rejected.
  POST /v1/sync/pull returns rows with version > cursor, ordered;
  tombstones are rows with deleted=1. POST /v1/sync/poll is the
  long-poll channel (re-check D1 each second, timeout clamped to 25s).
- Auth: bearer device tokens (32 random bytes base64url, prefix +
  checksum verified with a constant-time comparison); only SHA-256 of
  the token is stored; revoked tokens are refused; every query is
  scoped to the token's space.
- Schema bootstraps idempotently at startup: spaces, tokens,
  join_secrets, sync_rows (indexed on (space_id, version)),
  version_counters.
- Tests: in-process D1-compatible harness on node:sqlite (no workerd
  download), 35 tests covering the version counter, cursor semantics,
  LWW, tombstone supersession, opaque body passthrough, token auth,
  revocation, space scoping, and the pairing attempt budget.
- wrangler.jsonc with the D1 binding plus a README documenting the
  protocol, storage, security notes, and how the fork owner deploys it.
  No secrets, no CI deploy; deployment is manual.

Refs: #131
readJson previously passed JSON null and primitives through to service
functions, where property access threw a TypeError and surfaced as a 500.
Normalize to a 400 at the request boundary, and default a non-string
device name in createSpace instead of crashing.
…ecreate

The 0026 INSERT triggers stamped plain wall-clock ms, so a delete plus a
same-millisecond re-create of a row could reproduce the clock recorded in
sync_row_state (or equal the per-table watermark). The engine's
"applied-untouched" guard then mistook the new row for an untouched copy
and never pushed it (or the sync_ts > lastPushed filter excluded it), so
resurrections and delete+recreate cycles were dropped intermittently.

INSERT stamps are now MAX(wall clock, recorded row_sync_ts + 1,
watermark + 1): a fresh row always outranks any clock the engine has seen
for it and any watermark, while applied-untouched rows still match their
recorded clock exactly and stay silent. Adds deterministic migration
regression tests for both monotonicity guarantees and regenerates the
fixtures against the final 0026 schema.
Also extends the raw-transport test to assert conversations.config and
automations.trigger_config future-version blobs travel verbatim, not just
tasks.linked_issue.
…batch

A child upsert whose in-scope FK parent is absent locally (parent tombstone
already applied here, or never present) aborted the whole pull transaction
with a FOREIGN KEY violation. Because the relay replaces rows in place, the
parent's creation upsert can be gone from the relay by the time a child edit
from a machine that had not yet applied the tombstone lands — a fresh machine
joining after a project delete pulled the carried project_remotes upserts
with no project row, and a task edit racing a project delete wedged the
deleting machine's sync permanently (cursor never advanced past the failing
patch).

Add importSkipIfMissingParent to the allowlist (tasks, conversations,
automations, project_settings, project_remotes -> projects/tasks): such
patches record the server version and are skipped (new skippedOrphan counter)
so the pull cursor keeps moving; the deleting machine's cascade tombstone
converges the row. Regression tests cover both scenarios.
Extend the repositoryWorkspaceId import test: a fresh import nulls the
out-of-scope ssh_connections reference too, and each machine keeps its own
ssh_connection_id when a remote row wins LWW (same treatment as path and
repository_workspace_id).
…ials

PairingService attaches this machine to a relay sync space (create/join),
mints fresh pairing secrets for additional devices, and lists/revokes
devices. The relay device token and space id are stored machine-locally in
one safeStorage-encrypted app_secrets entry (SYNC_TOKEN_SECRET_KEY); the
join credential is derived exactly as the relay verifies it (SHA-256 of the
full emdj1_ secret). Errors from the relay's 4xx JSON bodies are mapped to
typed PairingError codes with user-facing messages.

Ticket #135.
The app registers itself as handler for the emdash URL scheme (macOS
open-url, Windows/Linux second-instance argv) and forwards an
emdash://join?secret=... link to the renderer, which opens the Settings
Devices tab with the join modal pre-filled. The packaged builds declare the
scheme via electron-builder protocols. Joining still requires explicit user
confirmation in the modal.

Ticket #135.
DevicesSettingsCard (following the SshConnectionsSettingsCard pattern)
shows the not-paired state with create/join actions when this machine has
no credential, and otherwise lists the space's devices with self/revoked
badges and last-seen times. Add device mints a fresh single-use secret and
displays it with a copy button, deep link, and 15-minute/single-use
warning; revoking goes through the house confirmation dialog. Relay
failures surface as user-facing toasts, never raw JSON.

Ticket #135.
… and Devices UI

- PairingService against a fake RelayAuthApi implementing the relay's
  pairing semantics: create/join/mint/list/revoke, single-use, TTL, and
  attempt-limit rejections, not_paired/unauthorized, and error mapping that
  never leaks raw relay JSON.
- Join credential derivation verified against the relay's own crypto
  (apps/sync-relay/src/crypto.ts imported directly; the package entry is
  dist/index.mjs which the gate does not build).
- Token storage round-trip through the real EncryptedAppSecretsStore with a
  fake safeStorage and fixture DB (main-db project).
- Deep-link parsing/event forwarding with mocked electron.
- Browser (Playwright) test for the Devices card: unpaired state,
  create-space secret modal, device listing, minting, toasts, and revoke
  confirmation.

Ticket #135.
…y protocol

The relay's join() (apps/sync-relay/src/service.ts) parses the presented
join_hash as the raw emdj1_ secret — it needs the embedded space id and
checksum — then compares SHA-256 of what the client sent against the stored
digest. The client was pre-hashing the secret (sha256 hex), which the relay
rejects with 401 'invalid join secret': pairing could never succeed
end-to-end. deriveJoinHash now returns the trimmed secret itself; the
join-credential tests exercise the real relay join service (createSpace ->
derive -> join -> token, plus pre-hash and single-use rejection), and the
pairing fake mirrors the relay's digest comparison.
Previously a paired machine whose listDevices call failed (relay unreachable)
fell through to the 'Not paired with any device' branch, misleadingly
offering to create/join a space. The paired+list-failure state now renders
the error block with a Retry button. Browser tests cover the retry state and
the deep-link pre-fill flow (modal opens pre-filled, nothing sent until the
user confirms).
Retrofit the relay to the spec #130 two-half pairing model (#134): pairing
secrets are now emdj1_<space>_<join_half b32>_<k0 b32> with only SHA-256 of
the join credential stored (createSpace mints both halves, the join-secret
endpoint registers a client-minted digest, join carries space_id and the
bare credential). sync_rows gains client_version, stored verbatim so
decrypting clients can bind it into the AES-GCM AAD.
Ticket #134. Bodies are encrypted in the main process before upload and
decrypted on pull; the relay only sees plaintext metadata plus an opaque
versioned envelope.

- crypto.ts: CryptoHelper over node:crypto — HKDF-SHA256 per-row keys
  (salt = [table, pk], info 'row-v1'), AES-256-GCM with a fresh 96-bit
  nonce per encryption, envelope {alg, key_id, nonce, ct}, AAD =
  [table, pk, client_version, key_id]. key_id = first 8 bytes of
  SHA-256(K0). Two-half pairing secret format
  emdj1_<space>_<join b32>_<k0 b32> with the join credential = the bare
  base32 join half.
- space-key-store.ts: K0 + key_id in safeStorage (app_secrets) under
  SYNC_ENCRYPTION_KEY_SECRET_KEY; rekey = new K0, new key_id.
- encrypting-transport.ts: RelayTransport decorator — encrypts at push,
  decrypts on pull, flags undecryptable patches (decryptError) instead of
  throwing; missing key fails pushes with a clear error.
- engine.ts: upserts carry client_version (last-known server version, 0
  for new rows); patches with decryptError are recorded and counted as
  skippedUndecryptable, the pull continues.
- pairing.ts/auth-api.ts/transport.ts: two-half pairing — createSpace and
  joinSpace extract and store K0, mintSecret composes the secret
  client-side (fresh join half + constant K0) and registers only the
  SHA-256 digest with the relay.
- Client + relay tests cover the envelope format, per-row derivation,
  AAD tamper directions, decrypt failure modes, the two-client K0
  invariant, mint-join flows, no-plaintext-on-the-wire, and pull
  continuation past undecryptable patches.
…ate, auto-attach, merge, SSH re-attach)

Ticket #136 (spec #130). A synced Project with no local anchor is surfaced as
Unattached (distinct from path-not-found): local projects have a NULL path,
SSH projects a NULL connection. Auto-attach scans the default projects
directory for a repo with matching live remotes and attaches silently; manual
attach re-anchors a local project to a picked directory (deduping against the
unique path index by merging into an existing same-type project when remotes
match, prompting on a local/SSH ambiguity) or re-attaches an SSH connection
(fingerprint (host, port, username) + path, not connectionId).

Engine: SSH remote paths now travel in the payload (local paths stay
machine-local), an import path colliding with the unique index is nulled
instead of wedging the pull, and an injectable projectAttachHook fires after
fresh project imports for the auto-attach service. Attach re-runs
ensureRepositoryWorkspace so tasks are provisionable on demand.
… holds the picked attach path (ticket #136)

The unique path index made the direct attach UPDATE throw an uncaught
SQLITE_CONSTRAINT when the synced project carried no remotes (the
remote-mismatch guard is skipped for empty synced remotes) and the picked
directory belonged to another remotes-less local project. findMergeCandidates
now treats a non-mergeable local holder at the picked path as a hard
path-conflict, mirroring the SSH-holder branch.
…ry-instance workspaceId

A repository-instance target stored in a synced automation task config must
not carry a machine-local workspace reference (the workspace id belongs to
the creating machine only). v3 makes workspaceId optional; v2 blobs keep
their id through the upgrade chain. Producers emit version 3 configs.

Issue: #138
…st the project workspace

prepareCreateTask resolves a repository-instance target without a stored
workspaceId from the mounted project's repository workspace. An Unattached
project (no repository workspace on this machine) fails with a typed
workspace-not-resolved error BEFORE any task row is committed; automation
runs surface it as a project_unattached run failure instead of a crash or
half-created task.

Issue: #138
…nd v3 target storage

- automations.source ('local' | 'imported') is a machine-local column like
  enabled: never transported in the sync payload; fresh imports are stamped
  'imported' (importInsertColumns) while LWW conflict updates keep each
  machine's own value. Migration 0027 adds the column with DEFAULT 'local'.
- The Automation type carries source; the automations list row and detail
  sheet show an 'Imported, disabled' badge for fresh imports.
- The automation form now stores repository-instance workspace targets
  without the machine-local workspace id (v3), resolved at run time.

Issue: #138
64ix added 27 commits August 11, 2026 11:20
…0-day cap

The spec (#130) assigns tombstone GC to the relay: record each device
token's last-pull cursor and hard-delete a tombstone once every
non-revoked device has pulled past its version, with a 90-day safety
cap. The relay previously retained tombstones forever.

- pull_cursors(space_id, token_id, cursor, updated_at): advanced (never
  rewound) on every pull that returns rows; a device that never pulled
  counts as behind everything, a revoked device never blocks collection.
- gcTombstones() runs opportunistically on pulls that returned rows,
  deleting tombstones whose version is at or below the smallest active
  cursor or whose age exceeds the 90-day cap.
- relay tests: retention until every active device has pulled, revoked
  devices not blocking, the age cap collecting behind devices, and
  resurrection at a fresh version after collection.
…e push)

The spec (#130) says the sync service pushes on local writes (debounced)
so both machines stay up to date without intervention, but the poll loop
only ran a push+pull when the relay woke it with patches, on reconnect,
or on manual sync. Edits made while the other machine was quiet sat
unpushed until a relaunch or a manual Sync now.

The poll cadence now doubles as the debounce: when a poll returns no
patches and the engine reports pending rows, the loop runs a sync cycle
instead of sleeping.
…loop safety

Five confirmed defects from the PR review, all adversarially verified:

- relay: single-use join was a TOCTOU race — read used_at, then wrote it in a
  separate round trip, so two interleaved /v1/join calls could both mint a
  token from one secret. consumeJoinSecret is now an atomic guarded
  UPDATE ... WHERE used_at IS NULL AND expires_at > now AND attempts_left > 0
  RETURNING, and join() only mints when it wins the consume. +store-level test.

- engine: project_remotes (initial-only) was gated on the parent project's
  first push, so a remote added after the project first synced (remotes are
  populated on task provision, not at creation) never synced at all — an empty
  auto-attach hint forever. Gate the carry per remote row (own row-state null)
  instead. Updated the initial-only test to the corrected per-row semantics and
  added a regression test (project synced with zero remotes, remote added
  later, carried once).

- relay-config: EMDASH_SYNC_RELAY_URL unset silently defaulted to
  sync-relay.emdash.sh — upstream product infra the fork must not use for sync
  identity. Default to a reserved unresolvable .invalid host (fails fast,
  non-sync users unaffected) and expose `configured`.

- automations: v3 stripped the machine-local workspaceId from EVERY
  repository-instance target, silently redirecting a 'use-existing' automation
  to the project root. Strip only for the 'repo-root' preset.

- sync-service: the runLoop catch block called markPollFailure (async
  buildEngine + sqlite pendingCount) with no inner guard, and runLoop is
  fire-and-forget — a secondary throw killed background sync forever with no
  trace. Wrap the failure path and attach a last-resort .catch to loopPromise.
…nd machine

A synced task arrived on the second machine carrying the origin machine's
`tasks.workspace_id` verbatim — a reference to a per-machine `workspaces` row
that never exists on the receiver (worktrees are machine-local, not synced), so
the task was permanently unopenable (`ensureWorkspaceSetupForTask` →
missing-workspace). This blocked the headline flow "pick up work on the other
machine" (spec #130 stories 2 / 25, ticket #136).

- allowlist.ts: `workspace_id` is now `importPreserveLocalColumns` (the same
  treatment `projects.repository_workspace_id` already gets) — never applied at
  import. A fresh import leaves it NULL; an already-provisioned local row keeps
  its own id across every later sync. (The reviewer-suggested
  `importNullIfMissingFk` would instead clobber the receiver's own workspace on
  every pull, orphaning its worktree — preserve-local is correct.)
- workspace-bootstrap-service.ts: when a task has no local workspace and its
  project is attached, mint a local/SSH worktree row on demand (location/SSH
  derived from the project like createTask) and re-point the task; `config` is
  left NULL so the existing intent resolution falls back to the task's own
  branch (`use-branch`) and checks out the branch pushed from the origin
  machine. Until the project is attached there is nothing to provision against,
  so it still reports missing-workspace.

Tests: bootstrap on-demand mint (main-db); workspace_id NULL-on-fresh-import /
preserved-on-update convergence (sync-engine, main-db). node 3238, main-db
sync+bootstrap green; typecheck, oxlint, oxfmt clean.
Proves the headline multi-machine flow with two independent client databases
(two "machines") syncing through the actual relay code (apps/sync-relay
service + store + schema over an in-process D1) — not a fake transport:

- pairing derives one shared K0 from the pasted secret (createSpace on A,
  join on B, real relay functions);
- a project + task created on A push through the real EncryptingRelayTransport
  (AES-256-GCM) and converge on B: task name, workflow stage and branch all
  arrive, and the project lands Unattached (its machine-local path never
  travels);
- the relay stores only ciphertext — the plaintext task name never appears in
  sync_rows, and the stored body is a versioned AES-256-GCM envelope.

Runs in the main-db vitest project. The relay service is imported directly
(not via the HTTP handle() router) so no Cloudflare Workers types leak into
this project's typecheck; token-auth resolution is covered by the relay's own
suite.
The relay URL is public (workers.dev names are discoverable) and space
creation / join are unauthenticated by design, so anyone who finds the URL
could create spaces and push data — burning the operator's Cloudflare free-tier
quota (self-DoS). The fork is public, so nothing secret can ship in the binary.

Gate the relay with a pre-shared key the operator sets per machine by hand:

- Relay (apps/sync-relay): every request must carry `X-Relay-Key`, checked in
  constant time (SHA-256 digests) before routing — including the space/join
  endpoints — else 401. `handle()` takes the expected key; the `fetch` entry
  point reads it from a `RELAY_KEY` Worker secret and FAILS CLOSED (500) when
  unset, so a forgotten secret never leaves the relay open. Existing 3-arg
  `handle()` test callers stay ungated (no behaviour change for them).
- App: `SYNC_RELAY_CONFIG` reads `EMDASH_SYNC_RELAY_KEY` alongside the URL;
  both HTTP clients (HttpRelayTransport sync + HttpRelayAuthApi pairing) send
  `X-Relay-Key` when configured. `configured` now requires both URL and key.
  No default URL or key ships (public repo) — each machine is configured by
  hand; unset ⇒ unresolvable .invalid host, sync refuses to run.
- README: deployment now sets `wrangler secret put RELAY_KEY`; documents the
  gate, the free-tier-quota rationale, the app env config, and self-hosting.

The key gates the operator's infrastructure/quota; it is not a data secret
(row bodies are already E2E-encrypted) and travels only over TLS. Tests: relay
gate (401 without/with-wrong key, 200 with, ungated when unset), client sends
the header when configured and omits it otherwise. relay 60, transport 6, node
3240, e2e 1; typecheck/oxlint/oxfmt clean both projects.
… env

The relay URL and pre-shared key were env-only (EMDASH_SYNC_RELAY_URL /
EMDASH_SYNC_RELAY_KEY), which is fine for dev but unusable for a packaged app
(GUI apps don't inherit the shell env). Let each machine be configured by hand
in the app, with env vars still overriding for dev/power users.

- RelaySettingsStore: URL + key in one safeStorage `app_secrets` entry
  (machine-local, never synced — app_secrets is out of the sync allowlist), the
  same idiom as SyncCredentialsStore. The key is never returned to the renderer.
- resolveRelayEndpoint: env → stored → unresolvable `.invalid` fallback, with
  `configured`/`envManaged` flags. Transports (HttpRelayTransport sync +
  HttpRelayAuthApi pairing) now take an async endpoint provider and resolve it
  per request, so settings entered in the app take effect without a restart.
- sync RPC: getRelaySettings / setRelaySettings (validates https URL + non-empty
  key, kicks a sync) / clearRelaySettings.
- UI: a "Sync relay" Settings card (URL + password-masked key + Save;
  read-only when env-managed); Devices pairing (create/join) is gated until the
  relay is configured.

The key never ships (public fork, no baked default): unset ⇒ sync refuses to
run against `.invalid`. Tests: resolveRelayEndpoint precedence (env/stored/
unconfigured), RelaySettingsStore round-trip + malformed/clear (main-db),
transport sends X-Relay-Key from the resolved endpoint. node 3244, main-db sync
69, typecheck/oxlint/oxfmt clean. Devices/RelaySettings card UI is browser-suite
coverage (not run in the agent gate).
…asks

attachProject.mergeInto re-parented tasks/conversations/automations via raw
SQL with no events, so a target project already mounted in the renderer did not
show the merged-in tasks until an app restart (the common auto-attach case).
Capture the re-parented task rows in the merge transaction and emit
taskCreatedChannel for each under the target project id after commit, so a
mounted TaskManagerStore ingests them live. +regression assertion in the merge
test (with an @main/lib/events mock, since the real bus needs Electron).
Two pairing footguns from the review:

- Revoke → permanent error loop: PairingService.getState only checks whether a
  local credential blob exists, so `paired` stayed true after a device was
  revoked, and SyncService treated the relay's 401 as a generic failure and
  looped in 'error' forever. SyncService now detects a permanent auth rejection
  (RelayHttpError 401/403) in the poll loop and calls a new onAuthRevoked dep
  (clears the credential + space key), dropping to idle so onboarding reappears.
- Join-while-paired: PairingService.joinSpace unconditionally overwrote the
  credential/K0, so a mis-pasted secret silently switched spaces and dropped
  sync with the current devices. It now refuses with 'already_paired' when a
  credential for a *different* space exists (re-joining the same space is fine);
  the guard runs before any relay call.

Tests: pairing already_paired guard (no relay call made); sync-service clears
credentials + settles idle on a revoked poll (new 'revoked' fake mode).
…hild

Applying a projects/tasks tombstone ran the raw SQLite ON DELETE CASCADE, which
removed synced children (tasks/conversations/settings/remotes) outside the
engine's per-row dirty guard — destroying a child that carried an unpushed local
edit (spec #130 story 17: a pull must never clobber local edits).

Before applying a parent delete, walk its synced descendants (the allowlist's FK
graph) and, if any has an unpushed local edit, skip the delete (record the
version as seen so it is neither re-fetched nor re-pushed); the next push
flushes the child and a later delete applies cleanly once nothing is dirty. The
row's own dirty tombstone is already handled by the existing per-row guard.

Cascade-created child tombstones are intentionally still pushed, not cleared:
the child may be alive on the relay (this machine's own upsert won an earlier
LWW), so pushing its tombstone genuinely converges it, and a redundant push is
an idempotent, harmless delete-of-a-delete. +regression test (dirty child
survives a pulled parent delete).
apps/sync-relay was excluded from fork-ci, so its handlers (auth gate, LWW,
counter, pairing, tombstone GC) had no CI coverage. Its tests run against an
in-process node:sqlite D1 fake (no workerd), so add typecheck + oxlint + vitest
steps for it to the gate.
Relay (apps/sync-relay):
- Add authenticated POST /v1/space/delete, routed after the authenticate
  gate like the other authed endpoints.
- Add deleteSpace(db, auth, now) in service.ts, backed by a new
  store.deleteSpaceRows() that wipes sync_rows, pull_cursors, tokens,
  join_secrets, version_counters, and the spaces row itself in one
  db.batch() call, matching createSpace's transactional pattern.
- Tests: deleting a space wipes exactly that space's rows across every
  table and leaves a second space untouched; the endpoint 401s without a
  valid device token.
- Document the endpoint in the README's protocol table plus a short
  "Deletion" section.

Desktop client (apps/emdash-desktop):
- RelayAuthApi + HttpRelayAuthApi: add deleteSpace(token), POSTing
  /v1/space/delete with the bearer token.
- PairingService.deleteSpace(): calls the relay, then clears the local
  device credential and space key (un-pairs this machine) only after the
  relay confirms the delete, so a network/relay failure leaves the
  machine paired and free to retry.
- Expose deleteSpace via the sync RPC controller; kicks the sync service
  so the status widget reflects "unpaired" immediately.
- DevicesSettingsCard: add a destructive "Delete sync space" action
  behind the existing confirm-action modal, wired to rpc.sync.deleteSpace,
  refreshing state on success.
- Tests: pairing.test.ts covers deleteSpace clearing the local credential
  and key after a successful relay call, leaving them intact on relay
  failure, and requiring a stored token.
The pasted pairing secret (`emdj1_<space>_<join>_<k0>`) had no integrity
check: a mistyped or OCR'd character inside k0 or the join half still
"parsed" successfully with silently wrong key material, surfacing only
much later as an opaque decrypt failure (or a relay 401 for the join
half) on the joining machine.

Append a checksum segment covering the whole payload (space id bytes,
join half, k0): `emdj1_<space 22>_<join b32 26>_<k0 b32 52>_<checksum
b32 7>` (116 chars). The checksum reuses the device token's exact
approach (truncated SHA-256, see checksumOf) but base32-encoded, since
the rest of the secret already speaks base32.

Relay (apps/sync-relay/src/crypto.ts):
- composeSpaceSecret/makeSpaceSecret now compute and append the
  checksum; both become async (crypto.subtle.digest has no sync form),
  so service.ts's createSpace now awaits makeSpaceSecret.
- Add CHECKSUM_B32_CHARS and grow SPACE_SECRET_CHARS.

Client (apps/emdash-desktop/src/main/core/sync/crypto.ts):
- composeSpaceSecret appends the checksum (node:crypto stays sync).
- parseSpaceSecret recomputes the checksum from the parsed space
  id/join half/k0 and returns null on any mismatch, so joinSpace
  surfaces invalid_secret_format for a corrupted secret without
  calling the relay.
- Add CHECKSUM_BYTES/CHECKSUM_B32_CHARS and grow SPACE_SECRET_CHARS.

The join credential derivation (SHA-256 of the base32 join half, as
stored by the relay) is unchanged; only the pasted secret gains a
checksum.

Tests: update every format regex/length assumption that assumed the
old no-checksum layout (relay's crypto.test.ts + relay.test.ts,
client's crypto.test.ts + pairing.test.ts + join-credential.test.ts).
Add a cross-side test in join-credential.test.ts proving a
relay-minted secret's checksum verifies on the client (and that a
corrupted copy is rejected), plus flip-one-character tests in the
client's crypto.test.ts and pairing.test.ts proving a typo anywhere in
the payload is caught by parseSpaceSecret/joinSpace instead of
surfacing later.
… replay

Spec #130 amendment (anti-replay hardening), two parts:

- Bind space_id into the AEAD AAD. crypto.ts's RowAad/aadOf now build
  [space_id, table, pk, version, keyId] instead of [table, pk, version,
  keyId], so a body encrypted for one space fails authentication if
  replayed into another. EncryptingRelayTransport takes the machine's
  own paired space id as a constructor argument (threaded from
  SyncCredential via SyncService) and binds it on both encrypt and
  decrypt — never the `space` field a pulled patch carries, which is
  exactly as untrusted as its `key_id` and would defeat the check if
  used to build the very AAD meant to authenticate it. Old envelopes
  need not decrypt (the relay is not deployed with data yet).

- Guard against a relay replaying an old (but validly-encrypted) body
  under a fabricated newer server version: the existing serverVersion
  check alone accepts it, since server version is not part of the AAD.
  sync_row_state gains a client_version column (migration 0029) that
  tracks the client_version of the last genuinely pulled-and-applied
  patch per row. applyPatch now drops a pulled patch whose
  client_version regresses relative to the recorded one even though
  its server version is newer (counted in a new skippedReplayed
  field), while still advancing the recorded server version so the
  cursor never wedges. A NEVER_PULLED_CLIENT_VERSION sentinel keeps
  push-acks (which never touch client_version) from being confused
  with a genuine pulled baseline of 0 — otherwise two machines
  independently pushing the very same never-before-synced row (both
  legitimately client_version 0) would make each other's later edits
  look like replays of each other.
PairingService.deleteSpace() only cleared the local credential + space key
on a relay success, so an 'unauthorized'/'device_not_found' response — the
space was already deleted by another device, or a delete that committed
server-side but whose response was lost — returned an error and left this
machine paired. Retrying always 401s (the token's space is gone), so the
explicit action could never un-pair, even though the background poll loop's
onAuthRevoked already treats the same 401 as 'revoked, un-pair'.

Treat unauthorized/device_not_found from the delete as 'already gone' and
clear local state anyway; a genuine network/relay error still leaves the
machine paired and free to retry. Adds the already-deleted test case.

Review fix (spec #130 amendment, item: space deletion).
The single-character-flip test already proves the checksum catches
substitutions; add an adjacent-transposition case (which preserves the
character multiset, so only a position-sensitive checksum catches it) to
lock in that guarantee too.

Review nit (spec #130 amendment, item: pairing-secret checksum).
…ata loss

Two spec #130 hardening changes to the E2E pull path.

1) Decrypt-failure quarantine + retry (new). Before, a pulled row whose
   body could not be decrypted was recorded as seen at its version and
   never retried — so a row encrypted under a space key this machine does
   not hold yet (a rekey whose new key has not propagated here) was lost
   forever, even after the key arrived. Now the encrypting transport tags
   each failure retryable (unknown key id / no key stored) or permanent
   (tampered/corrupt envelope, unsupported alg). Retryable failures are
   PARKED in a new sync_row_state.quarantined_version column (migration
   0030, a plain ADD COLUMN — no rebuild) WITHOUT advancing server_version,
   and re-attempted by rewinding the pull cursor to the quarantine floor
   the next time the space key id changes (bounded to once per key change,
   never a per-cycle re-pull). A decrypted or deleted patch lifts the
   quarantine. Permanent failures keep the old drop-and-record-seen path.
   The standing quarantined count is surfaced in SyncStatus and the sidebar
   sync widget ('N rows can't be decrypted yet — waiting for the space
   key'). The engine stays crypto-free: it only receives the opaque key id.

2) Replay-guard data-loss fix (blocker, from review of the client_version
   anti-replay commit). The guard dropped a pulled patch whose
   client_version was <= the recorded baseline. But client_version is a
   last-observed server version, not a per-writer counter, so two machines
   that edited the same already-synced row both legitimately push the SAME
   client_version; the later one (higher server version) is a genuine
   concurrent edit LWW must apply, not a replay. '<=' silently lost it.
   Changed to '<': strict regressions are still caught (an equal-version
   replay of the identical body is a harmless idempotent re-apply). Adds a
   concurrent-tie regression test.

Tests: migration 0030 (column added, client_version preserved, no rebuild),
quarantine-then-retry-after-key-change, permanent-failure-not-retried,
concurrent-edit-tie, updated the existing rekey/tamper undecryptable tests,
and decryptRetryable classification assertions. Fixtures regenerated
(pre-0030 frozen at 0029).
Merging fork-main brought in the external-content FTS5 workspace-file index
(#147), whose `workspace_files_ad/ai/au` shadow triggers now co-exist with the
sync clock/tombstone triggers. The 0025 sync-schema test asserted the FULL
trigger set, so it broke on the merge. Filter to the `_sync_ts_` triggers this
migration actually owns — matching the test's own 'exactly the portable tables'
intent — so unrelated subsystems' triggers can't make it brittle.
CI (pinned Node 24) failed 'stores opaque row bodies verbatim': the test
harness (node:sqlite) truncates a TEXT value at an embedded NUL byte
(C-string semantics), so the '\u0000binary…' body came back as '' — a
harness artefact, not relay behaviour. Node 26 (local) preserves it, which
hid the failure. Swap the NUL-leading body for a NUL-free control-character
body: the 'stored verbatim, never parsed as JSON' intent is preserved, and
production bodies are always the E2E-encrypted base64url envelope (never a
NUL byte), so no real coverage is lost.
The pairing client mapped every 404 from the relay to device_not_found
('That device was not found in this sync space'). The relay only returns
that error for a cross-space revoke; a bare 404 (unknown route, wrong
base URL, or a web page that is not the relay) was reported as if the
device had been deleted, sending users on a wild goose chase when their
relay URL was simply misconfigured.

Only map 404 to device_not_found when the relay says 'device not found
in this space'; everything else becomes relay_error with a message that
points at the relay URL in Settings.
The pairing client read space_id/device_id/device_token as
spaceId/deviceId/deviceToken straight off the wire. The relay always
answers snake_case, so createSpace received a valid 116-char secret but
an undefined spaceId — parseSpaceSecret succeeded, the
parts.spaceId !== spaceId guard fired, and every space creation failed
with 'That doesn't look like a pairing secret'.

Map the raw wire shapes to the camelCase DTOs at the API boundary
(createSpace, joinSpace, listDevices) and add regression tests that
assert the mapping with snake_case fixtures — the previous tests used a
camelCase fake and never exercised the real wire format.
CI's tsgo flagged result.error.status on union-typed RelayApiError; add
explicit type guards. Also record in AGENTS.md that every push must have
its CI checked and any introduced failures fixed.
…s widget

The first-run onboarding prompt and the status bar were two stacked sync
surfaces that disagreed about the machine's state, and the relay
not-configured case (no URL + key) was invisible outside Settings. Make
SyncStatusWidget the single sync surface:

- SyncStatus now carries relayConfigured/relayEnvManaged, resolved by
  SyncService each cycle from the injected endpoint resolver, so every
  status snapshot can say whether sync can run at all.
- When the relay is unconfigured the widget row itself shows
  'Sync isn't set up' and navigates to Settings -> Devices, where the
  relay form lives (covers paired machines whose relay was cleared too;
  clearRelaySettings kicks a status refresh).
- The idle popover inherits the onboarding actions (Join an existing
  space / Start from scratch) and the SyncOnboardingPrompt is removed.
@64ix
64ix force-pushed the spec/130-multi-machine-sync branch from c660341 to 424ba17 Compare August 11, 2026 09:21
The sync engine writes pulled rows straight into the local DB, but the ProjectManagerStore loaded the project list once at boot and never re-read it, so projects synced from another machine stayed invisible for the whole session. Subscribe to the sync:status event and, on an up-to-date cycle, re-query and merge in any rows the boot load missed (spec #130 story 3: synced projects must appear without a restart). Merge-only: remotely deleted rows are reaped on the next launch.
@64ix
64ix merged commit 827f9e8 into fork-main Aug 11, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment