Treat a placeholder as the photograph, not as a one-byte file

The folder connector was pointed at a Nextcloud VFS tree and got three
things wrong, the first of which loses work.

**A dehydrated sidecar read as absent.** `a.drsc` does not exist when the
client has dehydrated it — only `a.drsc.nextcloud` does — so `get` missed,
`.ok()` swallowed the `NotFound`, and the sidecar writer took that for
"there is no sidecar yet" and wrote a fresh document over the existing
one. Every edit another device had put there went with it. That function's
own doc comment calls this the exact loss the format's unknown-key
preservation exists to prevent.

**A stub was catalogued as a 1-byte image**, and ARCH §9.0 measured this
machine at 121,785 placeholders against 10,267 real files — so a folder
library on a synced tree was ~92% broken rows.

**Identity changed on hydration**, so downloading a photograph looked like
a delete and an add, orphaning its thumbnail and its face rows.

Entries now carry the photograph's own name and a `materialised` flag;
`get` on a stub returns the new `RemoteError::NotMaterialised`, which is
distinct from `NotFound` precisely because the sidecar writer must treat
them differently — it fetches the sidecar and merges, or leaves the entry
queued.

Hydration is a **borrow**. `BorrowPool` records what was on disk before it
asked, so `release_all` dehydrates only what a pass brought and leaves
what the user already had. Reference counted: the thumbnail pass and the
face pass meet on the same RAW, and without counting the first to finish
dehydrates the file the second is reading. A borrow against a plain folder
or a server does nothing, so a pass written for VFS runs everywhere.

Releasing means asking the client to dehydrate and never deleting: a
deletion inside a synced tree propagates to the server and removes the
photograph from every device.

Not a second backend — the capability is per *connection*, not per type,
since the same folder hydrates only while the client runs. The convention
arrives through a detector the registry supplies, so `dr-sync-folder`
still knows nothing about any client's protocol.

ARCH §9.0a records this as an amendment: finding 3 rejected hydration
because it costs 100× a range read, and that comparison assumed a
connector was available. A folder library has none.
This commit is contained in:
2026-08-29 09:57:52 +02:00
parent 6c363cee97
commit c102ba9df2
22 changed files with 1555 additions and 53 deletions
+33
View File
@@ -876,6 +876,39 @@ remains the only mechanism that satisfies FR-NC-3.
- Never depend on it: the socket is Linux-only, absent on Android, and absent when the client is
not running. The direct connector remains the primary path.
#### 9.0a Amendment, 2026-08-29 — VFS as a managed tier for *folder* libraries
The rejection above was written when the only backend was the direct Nextcloud connector, and it
assumed the alternative to hydration was a range read. Since `dr-sync-folder` exists (§8.4a) a
library can be opened as a directory with **no server connection at all**, and that changes what
finding 3 is comparing against.
**What finding 3 actually says.** Hydration transfers ~100× what a preview needs *when a range read
is available*. On a folder library there is no connector, so there are no range reads: the choice is
not hydrate-versus-range-read, it is hydrate-once or never have a thumbnail. The finding stands
unamended for the direct connector, which must still never hydrate to browse.
**What makes the cost acceptable on a folder library**, and all three are required:
1. **Hydration is a borrow, not an acquisition.** A file is returned to the state it was found in —
what the pass downloaded is released, what the user already had is left alone. Peak disk is the
working set, not the library.
2. **It is paid once.** Thumbnails are kept, and `derived_sync` pushes the shards to the server, so
a second device downloads 200 MB of shards instead of hydrating 340 GB of RAWs.
3. **It is quoted and consented to.** Never automatic, never on the browsing path, always
resumable and cancellable (FR-NC-6c).
**What this does not change.** Findings 1 and 2 stand and are now implemented rather than merely
noted: a stub is not transparent, so `RemoteEntry` carries `materialised` and the backend reports
`RemoteError::NotMaterialised` rather than a miss; and the socket remains the only way to hydrate,
so it stays Linux-only, optional, and absent on Android.
**The one thing that must never be got wrong.** Releasing content means asking the client to
dehydrate — never deleting the file. A deletion inside a synced tree propagates to the server and
removes the photograph from every device the user owns. `RemoteBackend::dematerialise` says so, the
`Vfs` trait says so, and an implementation that cannot dehydrate returns `Unsupported` rather than
approximating it with `remove_file`.
### 9.1 The three tiers, restated as policy
| Tier | Content | Default |