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
--forceonce; 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) andtests/test_transfer_lock_scope.py(VAL-LOCK-001..003).