Sync hazard: stale sync-state → silent "server deleted" divergence
Summary
trip2g-sync.mjs keeps a client-side manifest .sync-state.json
({path: lastSyncedHash}) in the vault root. When the server loses notes the
client still believes it synced (server DB reset, restore from an older backup,
or pointing the same vault at a different/blank server instance), every such note
is classified server_deleted. The current handler (onServerDeleted) then keeps
the local copy and does NOT re-upload it — silently, with only a ⚠️ N files deleted on server, keeping local copies line. The notes stay local-only, invisible
to the server (search, embeddings, federation), with no error and no commit failure.
Real impact observed: a vault of ~186 notes (wiki/topics, calls) existed locally but
never reached the server. search returned nothing for content that was plainly in
the vault. Data was not lost (the vault is the source of truth), but it was
silently absent from the server until the sync-state was cleared.
Mechanism
Classification (at(localHash, remoteHash, lastSyncedHash)):
| local | server | state remembers it? | verdict | action |
|---|---|---|---|---|
| ✓ | ✗ | yes (lastSyncedHash) |
server_deleted |
keep local, skip upload |
| ✓ | ✗ | no | local_only |
push |
So the same local file with the same "absent on server" gets the opposite
action based solely on whether .sync-state.json remembers it. The state is the
single source of this decision; there is no reconciliation against server identity.
How it triggers
The hazard needs the server to diverge from what the client's state records:
- Asymmetric reset (dev/test): the server DB/volume is wiped or recreated while
the client vault +.sync-state.jsonpersist. The most common way to hit it. - Restore from an older backup (prod DR): the server rolls back behind the
client's synced state → client notes read as "deleted on server". - Instance migration with a reused vault: the same vault dir is pointed at a new
or blank server. The state describes the old server. - Server-side data loss / partial corruption.
In steady-state operation (one stable, persistent server, never reset) it does not
trigger — client state and server stay consistent.
Why it's dangerous
- Silent. No error, no failed commit — just a
⚠️line and a benign-looking
summary. The KB looks healthy locally while the server has none of it. - Indistinguishable intents. "User deleted this note on the server on purpose"
and "the server lost it" are identical to the client (in-state + absent). The tool
guesses deleted-on-purpose and protects local-without-uploading — the opposite of
what you want when the vault is the source of truth. - Worst at the worst time. It fires precisely during restore / migration / data
loss — high-stakes moments where silent divergence is most harmful.
Why --conflict-resolution does NOT fix it
--conflict-resolution {local|remote|skip|fail} only governs conflicts (both
sides changed since last sync). server_deleted is a different branch, handled by a
hardcoded onServerDeleted() → false (keep local, never upload). No CLI flag changes
it. An agent choosing --conflict-resolution cannot influence this case.
Recommended fixes (server + sync tool)
- Server identity / epoch in the protocol (best, automatic). Have the server
expose an instance id or vault epoch (e.g. inFetchServerHashes). Store it in
.sync-state.json. On sync, if the server epoch ≠ the stored one, the state is for
a different/reset server → invalidate it and treat all local files aslocal_only
(re-push) rather thanserver_deleted. This removes the guesswork entirely. - Explicit "server-missing" policy, separate from
--conflict-resolution, e.g.
--on-server-missing {push|delete|warn}(defaultwarn).pushmakes the vault
authoritative (re-upload);deleteis the current keep-local-skip behavior. - Loud, actionable reporting.
server_deletedwith a present local file should
be a warning that requires a decision (or fails under--conflict-resolution=fail),
not a silent skip buried in the summary.
Operational workaround (until fixed)
Whenever the server is reset, restored, or swapped while reusing a vault:
rm <vault>/.sync-state.json # forget stale state → next sync treats local as new
/opt/data/sync # re-pushes everything (vault = source of truth)
Document this in any DR / migration runbook. An agent that drives sync should, on
seeing N files deleted on server for files it did not delete, clear the
sync-state and re-sync rather than accept the silent divergence — but the structural
fix (server epoch) should make that unnecessary.