Came out of an architecture review of the core/shell seam. There are two things here: a duplication problem, and a latent correctness bug that falls out of it.
The duplication
Five rules are written out once per shell — web, Android, Windows, Apple — and enforced only by comment:
| Rule |
Copies |
Sites |
| 30 s sync threshold |
4 |
useSync.ts:10 · SyncManager.kt:182 · AppViewModel.cs:42 · AppStore.swift:28 |
| Re-entrancy guard + 401 sign-out |
4 |
useSync.ts:92-120 · SyncManager.kt:138-160 · AppViewModel.cs:229-259 · AppStore.swift:147-167 |
"syncedThroughMs is the value you sent" |
4 |
same sites |
| device_id generation + storage |
4 |
useSync.ts:6,44 · AccountStore.kt:22,49 · AccountStore.cs:29,74 · AccountStore.swift:15,39 |
Rotate device_id alongside ResetListeningData |
4 |
useSync.ts:231 · SyncManager.kt:112,127 · AppViewModel.cs:329,343 · AppStore.swift:218,230 |
The core computes unsynced_ms and then states no policy at all. crates/cascade-core/src/command.rs:73 and listening.rs:98 document an obligation ("the shell must rotate its device_id") that the core has no way to discharge.
Adjacent, same cause: the server echoes syncedThroughMs on the listening PUT (server/src/main.rs:424-426) and all four shells ignore the echo, substituting their own local deviceTotal. Apple's response type doesn't even decode the field. Today those agree, so nothing is broken — but nothing prevents a shell from passing serverTotalMs there, which would silently corrupt the G-Counter high-water mark.
The latent bug
device_id rotation and resetListeningData are a pair that must be atomic, and aren't. Every shell does:
await deleteListening(token) // HTTP
rotateDeviceId() // persisted write #1
dispatch(resetListeningData) // persisted write #2, via PersistListening
Three separate persisted writes, no transaction. Die between #1 and #2 and you get a fresh device id holding the old non-zero device_total_ms — the next sync writes the deleted total into a new server slot. That is exactly the resurrection the rotation exists to prevent (T8 in server/docs/threat-model.md:46, rationale in ADR-0001 decision 6). The reverse crash order is harmless.
Windows has a second path to the same state: AccountStore.cs:72-78 swallows the file-write exception and returns the new id anyway, so a failed write leaves the core reset and the old id on disk, with no error surfaced.
The server cannot help — both deletes are by user_id only, device_id is an opaque half of a primary key, and a non-rotating client is indistinguishable from a rotating one. This invariant is enforceable only client-side, and the core is the one place that already owns the state transition it must be atomic with.
Proposal
Move the policy into the core; leave transport in the shells.
- The core owns
device_id, inside the existing listening blob. Note it needs no new storage on any shell — PersistListening → RestoreListening already round-trips that blob verbatim. Rotation then becomes part of the same reset() that zeroes the slot, in one persisted write, and the pair stops being breakable.
- The core answers "is there anything to sync, and what exactly do I send" and "here is what came back" — so
syncedThroughMs is computed once, in Rust, rather than reconstructed four times.
- Each shell keeps its HTTP client and its lifecycle trigger — the thing it alone knows (reachability, backgrounding, auth state).
device_id is cheap to move: it has exactly one read site per shell (the listening PUT body). It is never sent on auth, never on either delete, never in a URL, never logged.
⚠️ Conflicts with ADR-0001 decision 5
"Sync cadence lives in the shells, not the core."
Worth reopening, because decision 5 conflates two things. Its stated rationale is reachability, lifecycle and auth state — knowledge only a shell has, which should stay there and which this proposal keeps there. The threshold, the payload semantics, and the device-id lifecycle are not that: they're protocol rules the core already has every input for. The likely amendment is to narrow it to "the shell decides when it can talk; the core decides whether there is anything to say, and what."
Six shells have now run the experiment decision 5 proposed. The result is ~20 copies of four decisions, no test on any shell, and one non-atomic invariant. That's a finding worth carrying into Clave rather than repeating at ten times the size.
Open design questions
Not settled — these need answers before anyone writes code:
- Kata vs product. Is the duplication the lesson working as intended, or a result to act on?
- Is the shell set closed at six? Changes how much interface generality is worth buying, not whether to do it.
- Does transport move too, or only policy? Recommendation: policy only — a Rust HTTP client drags reqwest/tokio into the wasm build and fights each platform's lifecycle.
- Fix
syncedThroughMs semantics now, or fold it into the refactor? Recommendation: fold in, and use it as the acceptance test.
- Is "the first shell test in this repo" a goal? Every shell currently has zero tests and CI calls the build "the hard gate". Without a fake transport at the new seam, this trades four untested copies for one tested module plus four still-untested adapters.
Acceptance
- One definition of the threshold, the payload, and the 401 rule.
- Rotation and reset cannot be separated by a crash.
- A test proves
syncedThroughMs is what was sent, without a network.
- The four
SyncApi clients keep their own transport and lifecycle triggers.
Came out of an architecture review of the core/shell seam. There are two things here: a duplication problem, and a latent correctness bug that falls out of it.
The duplication
Five rules are written out once per shell — web, Android, Windows, Apple — and enforced only by comment:
useSync.ts:10·SyncManager.kt:182·AppViewModel.cs:42·AppStore.swift:28useSync.ts:92-120·SyncManager.kt:138-160·AppViewModel.cs:229-259·AppStore.swift:147-167syncedThroughMsis the value you sent"useSync.ts:6,44·AccountStore.kt:22,49·AccountStore.cs:29,74·AccountStore.swift:15,39ResetListeningDatauseSync.ts:231·SyncManager.kt:112,127·AppViewModel.cs:329,343·AppStore.swift:218,230The core computes
unsynced_msand then states no policy at all.crates/cascade-core/src/command.rs:73andlistening.rs:98document an obligation ("the shell must rotate itsdevice_id") that the core has no way to discharge.Adjacent, same cause: the server echoes
syncedThroughMson the listening PUT (server/src/main.rs:424-426) and all four shells ignore the echo, substituting their own localdeviceTotal. Apple's response type doesn't even decode the field. Today those agree, so nothing is broken — but nothing prevents a shell from passingserverTotalMsthere, which would silently corrupt the G-Counter high-water mark.The latent bug
device_idrotation andresetListeningDataare a pair that must be atomic, and aren't. Every shell does:Three separate persisted writes, no transaction. Die between #1 and #2 and you get a fresh device id holding the old non-zero
device_total_ms— the next sync writes the deleted total into a new server slot. That is exactly the resurrection the rotation exists to prevent (T8 inserver/docs/threat-model.md:46, rationale in ADR-0001 decision 6). The reverse crash order is harmless.Windows has a second path to the same state:
AccountStore.cs:72-78swallows the file-write exception and returns the new id anyway, so a failed write leaves the core reset and the old id on disk, with no error surfaced.The server cannot help — both deletes are by
user_idonly,device_idis an opaque half of a primary key, and a non-rotating client is indistinguishable from a rotating one. This invariant is enforceable only client-side, and the core is the one place that already owns the state transition it must be atomic with.Proposal
Move the policy into the core; leave transport in the shells.
device_id, inside the existing listening blob. Note it needs no new storage on any shell —PersistListening→RestoreListeningalready round-trips that blob verbatim. Rotation then becomes part of the samereset()that zeroes the slot, in one persisted write, and the pair stops being breakable.syncedThroughMsis computed once, in Rust, rather than reconstructed four times.device_idis cheap to move: it has exactly one read site per shell (the listening PUT body). It is never sent on auth, never on either delete, never in a URL, never logged.Worth reopening, because decision 5 conflates two things. Its stated rationale is reachability, lifecycle and auth state — knowledge only a shell has, which should stay there and which this proposal keeps there. The threshold, the payload semantics, and the device-id lifecycle are not that: they're protocol rules the core already has every input for. The likely amendment is to narrow it to "the shell decides when it can talk; the core decides whether there is anything to say, and what."
Six shells have now run the experiment decision 5 proposed. The result is ~20 copies of four decisions, no test on any shell, and one non-atomic invariant. That's a finding worth carrying into Clave rather than repeating at ten times the size.
Open design questions
Not settled — these need answers before anyone writes code:
syncedThroughMssemantics now, or fold it into the refactor? Recommendation: fold in, and use it as the acceptance test.Acceptance
syncedThroughMsis what was sent, without a network.SyncApiclients keep their own transport and lifecycle triggers.