Mirror of the 2026-05-05 Wear-OS fix: port the canonical client sync
spec (project_client_sync_semantics.md) into the Rust desktop client.
Before this change, server_sync::run_loop had a `UploadOutcome::Terminal`
branch that deleted the m4a + sidecar pair on any non-2xx + non-5xx
response (401, 413, 415, parse drift, audio read failures) and never
called mark_synced — so the marker stayed `synced_to_server=false` as a
non-removable ghost in the desktop UI. Same shape as the Wear-OS bug.
Changes:
- `UploadOutcome` reduced to two variants (`Succeeded` | `Transient`).
The Terminal arm in `run_loop` is gone; `delete_pair` only fires from
the success path.
- `post_upload` now classifies every failure mode as `Transient`:
4xx incl. 401, parse-ack drift on 200, file-read errors, multipart
build errors, and network errors. Backoff retries forever.
- New `CaseStore::cleanup_orphaned_unsynced(pending_case_ids, cutoff)`:
removes markers that are unsynced AND have no waiting upload pair AND
are older than a small grace window. Self-heals pre-spec ghosts.
- `run_startup_cleanup` gains a third sweep that calls
`cleanup_orphaned_unsynced` with `DEFAULT_ORPHAN_MARKER_GRACE = 5 min`,
matching the Wear-OS StartupCleanup contract.
- Tests inverted/added: `post_upload_transient_on_401`,
`post_upload_transient_on_2xx_unparseable_body`,
`former_terminal_401_keeps_pair_and_retries`, four new
`cleanup_orphaned_unsynced_*` cases, plus `sweeps_orphan_unsynced_marker`
and `keeps_unsynced_marker_with_pending_pair` integration tests.
Followup (out of scope): `UploadEvent::Failed.will_retry` can now only
be `true` and is vestigial. Removing it touches every Failed-event
consumer in app.rs/UI; left for a separate cleanup commit.
The /web/ prefix predated the /api/ split; today it just clutters every URL
without disambiguating anything. All 16 browser routes move to the apex
(/cases, /login, /magic, /events, /audio/...). The 6 /api/* routes are
unchanged. A new /->>/cases redirect closes the apex 404.
The open-redirect guard in magic.rs and case_actions.rs flips from a
positive whitelist (starts_with("/web/")) to a deny-list: same-origin path,
not protocol-relative, not under /api/, no \. The /api/ exclusion is now
load-bearing and covered by tests.
Pre-production: no transition redirects.
Add [workspace] / [workspace.package] / [workspace.dependencies]
blocks to server/Cargo.toml and clients/desktop/Cargo.toml so each
becomes a self-contained workspace with its own Cargo.lock.
Remove the root Cargo.toml and Cargo.lock; the repo now hosts
three independent build worlds: server, clients/desktop, experiments.
Convert common/Cargo.toml to concrete versions (was workspace-
inherited from the root). common/ is consumed via path-dep from
all three worlds, but is not itself a workspace -- consumers
unify versions via their own lockfiles. Add common/Cargo.lock to
.gitignore per Cargo lib-only-crate convention.
Verification:
- server/Cargo.lock has 0 eframe/egui/winit entries
- cargo build --release --locked passes in server/
- test split: server 385 + desktop 89 + common 34 = 508 total
- bin output: clients/desktop/target/debug/doctate (named "doctate")
Move three directories into their target topology:
- doctate-common/ -> common/
- client-desktop/ -> clients/desktop/
- watch/wearos/ -> clients/wearos/
Update doctate-common path-dependency in 3 Cargo.toml files
(server, clients/desktop, experiments) and the workspace
members list in the root Cargo.toml. Crate names unchanged
(doctate-server, doctate-common, doctate-desktop).
Workspace still in single-Cargo.lock form; isolation into
standalone workspaces follows in stage 3. All 508 tests pass,
experiments standalone-workspace also resolves the new path.