Skip to content

Refuse to overwrite remote files that changed behind forest's back

Date: 2026-07-03

A push overwrote whatever was at the destination keys. Two forests pushing the same unit, a colleague uploading by hand, or any out-of-band change to the remote was silently destroyed by the next push. docs/research/dud-rclone-lessons.md (§1, §7) traces how dud prevents this with rclone's --immutable — but dud never updates an object (content-addressed storage), while forest legitimately re-pushes changed files, so --immutable would break normal use. The doc also warns against --size-only, which is only sound under content-addressing; forest passes neither flag. The adapted rule is git's force-with-lease.

Before transferring, push lists the unit's remote scope and checks every key it is about to overwrite against the last recorded sync entry for that unit (push or pull — a pull entry proves forest has seen that remote state). A remote file is only overwritten when the recorded snapshot covers it with a matching size; otherwise the unit refuses with exit 1:

  • remote changed since last sync: <key> — the size drifted from the recorded snapshot;
  • remote already has '<key>' with no recorded sync history — a first push into an occupied prefix, where forest cannot prove it is not clobbering foreign data.

push --force overrides both refusals. The previously deprecated no-op --force flag is repurposed for this (supersedes the VAL-PUSH-003 no-op contract); it never forces a re-push of already-synced content — the SKIP decision is unchanged (VAL-PUSH-042). Keys missing from the remote never refuse (re-push after remote deletion stays additive), an unlistable remote fails closed, and --dry-run keeps its no-network contract (the lease is checked only when a transfer is about to happen).

The lease token is the size from rclone lsjson — same-size remote tampering slips past the lease but is corrected by the push itself and caught by default post-transfer verification (ADR 0019); lsjson --hash per backend is noted as a future upgrade if size proves too coarse.

Transfer-scoped checkout lock (2026-07-03)

The lease is only as good as its window: the per-checkout flock (checkout_lock, sync_state.json.lock) used to be held twice per unit — once to read the state, once to record — with the transfer itself running unlocked, so two concurrent pushes of the same checkout could both pass the skip/lease checks and interleave on the remote (docs/research/dud-rclone-lessons.md §3). The lock now spans each unit's whole read-state → skip/lease/dirty-check → transfer → verify → record span (push and pull), so concurrent commands on one checkout serialize per unit. File collection and content hashing stay outside the lock (local reads only), and the lock is per unit, not per command — a long --all does not starve a second terminal for its whole duration. flock on a second file descriptor of the same lock file blocks even within one process, so the helpers that used to take the lock themselves (_last_synced_entry, _record_transfer_locked) now require the caller to hold it.

Consequences

  • Divergent-writer data loss on the remote now requires an explicit --force; the refusal names the conflicting keys.
  • Every transferring unit pays one remote listing before the transfer (skipped units and dry runs pay nothing).
  • Pushing into a prefix that already has foreign data requires --force once; the recorded snapshot then carries the lease forward.
  • Concurrent pushes/pulls of the same checkout serialize per unit; a crashed process releases the flock automatically (kernel-owned, no stale lock files).
  • Contract locked in tests/test_push_core_lock.py (VAL-PUSH-045..049; VAL-PUSH-003 rewritten) and tests/test_transfer_lock_scope.py (VAL-LOCK-001..003).