Skip to content

Device validation plan

Internals, for contributors and reviewers.

Delivery records call this page's scenarios VAL V1–V28 and its journeys J1–J11, distinct from the CLI design's live gates V01–V19, security gates S01–S12 and budgets P01–P10.

Dated 2026-09-27. The MVP is validated when every step below passes on each client platform of the set -- macOS, Windows, Linux, iPhone or iPad, and Android -- against a server on either reference route under "Routes" below: the Kubernetes chart, or Docker Compose. Neither route presumes anybody's own deployment. A Compose server on a spare computer, brought up from Run the server, is a complete reference, and every scenario and journey on this page can be run against one. Which platforms have a recorded run, and which are proven only in CI so far, is in the runs index.

What readiness means (owner ruling, 2026-09-07)

The deployment readiness is judged on is PRIVATE: no public application, no public DNS record, no public route. Readiness is real sync between the maintainer's own devices -- including OFF-LAN connectivity over a private path -- together with the authentication, revocation, isolation and recovery checks below. Public reachability is not an acceptance criterion, and no scenario here passes or fails on whether this server can be reached from the internet.

So the private path is validated FIRST: the devices reach the server over the LAN, or over a VPN back to it, with a certificate the phone trusts. The Compose-plus-Caddy route (deploy/compose, docs/server.md "Any network, no provider") is the template for that path -- a private name, a private certificate authority exported once and installed on each device, HTTPS because mobile Obsidian accepts nothing else. A tunnel, a public hostname, or a publicly trusted certificate are optional conveniences layered on top; each is validated only if it is actually deployed, and never as a condition of readiness.

The MVP set (owner ruling, 2026-09-27). Clients: macOS, Windows, Linux, iPhone or iPad, and Android. Reference routes: the Kubernetes chart and Docker Compose, the second being the one a stranger can stand up without a cluster. "Private" above means no public route; it never means one particular person's deployment.

Install through the production path

On each required platform, open the intended vault and use Settings → Community plugins → Browse → Self Hosted Private Sync → Install → Enable. Record the installed version and match it to the reviewed, immutable release. Directory acceptance is a prerequisite; a manual file copy, development preview or local archive check does not satisfy this installation result. Use normally trusted HTTPS for the production device campaign, with private connectivity when deployed.

Before setup or pairing, save the final Sync folders on this device selection independently on each device. For a staged first sync in one vault, keep personal files in an excluded staging folder and validate only disposable notes inside the selected folder. After acceptance, move the personal files into that selected folder and run Sync now. Both directions are supported afterwards: narrowing keeps the local files and the cursor, and widening replays the history this device skipped so the files under a newly selected folder arrive. Neither needs a state reset or re-pairing.

Validate a subsequent update using Community plugins → Check for updates. Confirm the installed version, preserved pairing and folder selection, then repeat bidirectional note sync. Local bundle equality is not native-update evidence.

Scenarios

# Scenario Pass condition
V1 Setup on the first desktop; recovery phrase shown and confirmed account visible in dashboard
V2 Pair every other device of the set -- phone, tablet, the other desktops -- from the first desktop each shows in Devices with platform; country is shown only when supplied by the deployed edge (a dash is expected with OBSYNC_EDGE=none)
V3 Type in a note on a phone appears on every other device within 3 s
V4 Rename and move a populated folder on Windows, including a folder that IS a selected sync folder on that device mirrored everywhere, no duplicates, no deletions in the journal; the selection names the new path. The note-level half is proven: issue #96 shipped in 1.0.4, and the 2026-09-21 run renamed a synced note in BOTH directions with both devices on 1.0.4 and saw a move, not a deletion. The Windows folder scenario itself is still not attempted: no run has been made on Windows
V5 Edit the same note offline on two devices, reconnect clean merge or a visible conflict copy, never a lost edit
V6 Add a 2 GiB image on one desktop syncs to another desktop; a phone lists it as remote-only under the per-file ceiling
V7 Add a 20 GiB archive on macOS over LAN; kill Obsidian mid-upload; reopen resumes; re-sent: fewer than 32 MiB of large chunks, plus at most 1 MiB of chunks of 256 KiB or less
V8 Delete a file on iPad tombstone everywhere; restorable from history within retention
V9 Revoke the iPad from the dashboard its next request fails; other devices unaffected
V10 Restart the server pod mid-sync clients resume; readiness is unavailable during startup replay and becomes successful only after replay completes
V11 Fill the blob volume to the watermark uploads refused with a visible message; nothing corrupted
V12 Scrub with one blob corrupted by hand on the host chunk quarantined, dashboard alert, client re-uploads
V13 Off-LAN sync from iPhone over cellular, over the private path (VPN back to the network, or the deployed tunnel if one exists) edits sync both ways with no public route in use
V14 Dashboard from a phone browser usable at 390 px wide
V15 Compose path from scratch on a second machine: deploy/compose up, root certificate exported and installed, iPhone paired over the LAN sync works with no provider, no public hostname, and no port reachable from the internet
V16 Native credential persistence on each required platform, after fresh setup and after upgrading legacy paired state restart Obsidian; the same device resumes bidirectional sync without setup or re-pairing, preserving folder selection
V17 Create an EMPTY folder on the desktop it appears on the phone within 3 s, still empty, and no file is created inside it
V18 Create an empty folder on the phone it appears on the desktop; the same result in the other direction
V19 Create a nested empty chain (A/B/C) on one device all three appear on the other, in one tree
V20 Delete a folder holding notes on the desktop, one level and then a three-level nest the notes and the whole empty tree are gone on the phone; a sibling folder that still holds a note is untouched
V21 Delete a folder on the phone it is gone on the desktop; the same result in the other direction
V22 Rename a folder holding notes on one device renamed on the other, notes inside keep their content and their history, no duplicate folder under either name
V23 Rename an EMPTY folder on one device renamed on the other; the old name is gone
V24 On device A put an extra file into a folder that device B then deletes B's deletion removes the notes; A keeps the folder AND the extra file, and says so in the log (folder … decision=kept reason=not_empty)
V25 Two devices already holding a vault made before 1.1.0, both updated, both restarted every existing folder converges without the user doing anything; the folders each device already had appear on the other
V26 A third device left on 1.0.6, the newest shipped 1.0.x, while the other two are on 1.1.0 it shows one refusal notice per folder and keeps syncing NOTES normally; no file is created at any folder's path; no tombstone is published for anything; updating it makes the folders appear and the notices stop
V27 On the 1.0.6 device, delete the last note out of a folder and KEEP the folder the 1.1.0 devices delete the note and keep the folder: a device that says nothing about a folder never deletes it
V28 Rename a folder by CAPITALISATION alone (Team docs to team docs), from the desktop and then from the phone, with notes inside the other device renames the folder itself and shows ONE folder under the new spelling; the notes keep their content and their history; nothing lands in either device's trash; leave both devices running for five minutes and no note gains a version, and no folder gains one, in either direction

V28 is the one scenario no test on a computer can close, and it is the reason that row says "and then from the phone": a capitalisation-only rename made on a phone goes through Obsidian's own adapter.rename rather than through the filesystem, and only a real phone can say what that adapter does with a spelling the vault already holds. The desktop half of it IS covered by tests against a real case-folding filesystem (plugin/test/realfs-case.test.mjs); the phone half is claimed nowhere until this journey is recorded. The five-minute wait is the point of the row: the defect it exists to catch published one version per note per thirty seconds rather than failing outright (review round 1, finding 1).

V17-V28 are the folder-sync scenarios for 1.1.0 (issue #104). Run each in BOTH directions — desktop to phone and phone to desktop — and record which device originated each one, because the two platforms use different host primitives: desktop makes and removes folders through Node's filesystem after the component walk, mobile through the vault adapter. For V20 and V24, record what the file explorer shows on the receiving device AFTER a restart of Obsidian as well, since a stale explorer pane is not a sync result. For V26 and V27, record the exact notice text, the plugin version on each device, and that the old device's own notes still sync in both directions while it is refusing.

V4 covers renames wholly inside the selection; the same rename seen from a second device is J4 below, and a move that leaves the selection is J6.

For V16, record the Obsidian version (at least 1.13.0), plugin version and redacted before/after device identity. Confirm native secret storage is available and ordinary plugin metadata contains references, not the vault key, device secret or edge-token values. Do not enumerate native secret entries or record their contents. Local host stubs prove migration and failure handling only; they do not satisfy native application restart persistence.

V7 and V12 remain unproven acceptance requirements. The configured concurrent uploads do not establish the V7 retransmission bound. The client repair implemented for issue #51 must pass the isolated scrub and required native-device scenarios below before V12 can pass; local synthetic tests alone do not establish that result.

For V8, after verifying the original tombstone on the required devices, use the native Restore from history command, find a retained content version of that deleted note, and select Restore a copy. Verify the recovered bytes under its new sibling name locally and after ordinary sync on the other devices. Preserve and compare any current unsynced original before and after the action. The deletion marker and original history must remain unchanged. A local-copy notice or an operator API script alone does not satisfy this native-device evidence.

For V12, use an isolated disposable file and an explicitly authorized storage fault; never alter an owner's existing blob merely to exercise this scenario. Record whether a healthy mirror repaired the chunk or the server quarantined it and removed its SID from inventory. To prove client restoration, keep a matching, already synchronized local copy unchanged, observe its automatic repair, and verify the restored bytes from another device. Compare the file identity, heads, version history and deletion state before and after. Retain the scrub/quarantine evidence. A source that is absent or edited must remain an explicit unresolved result, not a successful repair.

The background client walk checks one bounded unit per second and rests five minutes between walks; Sync now advances one unit. Desktop range reads can repair chunks of large files. The non-streaming Obsidian adapter, including mobile, refuses automatic content reads when the whole local file exceeds 8 MiB and reports that it needs a matching source on a device with safe range reads. Record that capability limitation explicitly; it does not satisfy large-file restoration using only non-streaming devices, and it does not waive V12 or replace real-device results with a synthetic test.

User journeys

The scenarios above prove the protocol. The 2026-09-20 device run showed what they leave out: the journeys a person performs on day one -- the first note, the first folder rename, the first relaunch -- were not in the plan at all, so a run could pass every V row and still ship a plugin that loses a folder the first time someone reorganises one. The journeys below are that half. They are written for a stranger with no knowledge of any particular deployment: each needs two real devices in one account, one desktop and one phone, and nothing else. The acting device is named first.

# Journey Pass condition Devices
J1 Create a note on the phone the desktop shows the note under the same name with the same bytes, with no action taken on the desktop phone acts, desktop observes
J2 Create a note on the desktop the phone shows the note under the same name with the same bytes, with no action taken on the phone desktop acts, phone observes
J3 Rename the vault on the desktop in Obsidian's own vault manager (the vault's menu, Rename vault...), then reopen that vault. A folder renamed outside Obsidian is a new vault to Obsidian and starts unpaired by design (troubleshooting); that is not this journey the plugin is still paired: no setup token, no pairing code, the same one device in Devices, the same folder selection; an edit made after the reopen reaches the phone desktop acts, phone observes
J4 Rename a selected folder on one device the other device lists that folder under its new name holding the same file names and the same bytes; the file count matches; nothing is deleted and nothing lands in trash either device acts, the other observes
J5 Move a note between two selected folders the other device shows exactly one copy, under the destination folder only, with the same bytes either device acts, the other observes
J6 Move a note out of the selected folders nothing is removed anywhere: the other device keeps the note under its old name with the same bytes and receives no copy under the new one, and the moving device keeps it where it was moved. The moving device says at once, in a notice, how many notes stopped syncing there, that they stay on the other devices, that nothing was deleted, and how to undo it: move them back, or add the new folder under Sync folders. (Obsidian reports a move only after it happened, so there is no prompt before it; the move deletes nothing, so there is nothing to ask, issue #91.) either device acts, the other observes
J7 Create a new top-level folder after pairing, then add it to the selection on that same device the folder is offered in Sync folders on this device and the saved selection survives a restart; its notes then sync, because saving the wider selection replays the history this device skipped, and every already-selected folder keeps every file either device acts, the other observes
J8 Quit and relaunch Obsidian on both devices each device returns to idle on its own, with no tap, no Sync now, and no setup or pairing prompt; record the time each took both devices act
J9 Open a vault that also holds a large non-note folder tree (record the file count and total size) the vault opens and the plugin reaches idle within a recorded time, or it says why not in one visible line naming the budget it exceeded and what it skipped (requirement 12); silence, a hang, or an unexplained partial scan is a fail desktop acts, phone observes
J10 Unpair the device, then pair the same vault again every local note is still on disk with unchanged bytes, the device appears exactly once in Devices, and sync resumes both ways; no duplicate note and no conflict copy either device acts, the other observes
J11 After recovery is registered, close every paired test vault; restore the saved phrase in a fresh vault and use Setup or recover with the server's setup token the new device receives the retained notes without approval from an old device; wrong phrases and wrong setup tokens are refused; an old account without recovery registration explains why a paired device is still needed fresh desktop or phone acts; existing test devices stay closed

Every release whose range touches plugin/ or the server's sync path -- the chunk, change, and file handlers in crates/obsyncd/src/api/ and the storage they call -- runs the affected journeys on real devices before it ships and records their outcomes in docs/validation-runs/, in the existing format: one row per journey with pass, fail, or not attempted, the measured time wherever the pass condition names one, and one sentence of what was observed. Silence is not a pass here either.

Requirement 11 governs those rows as it governs every other line of a run record. A journey row names CLASSES -- "desktop", "phone", an operating-system version, a plugin version, a folder count -- and never a device name, serial, account, hostname, address, vault name, or note title from anybody's real vault. A journey that needs a private fact to be legible is naming the wrong fact: use disposable notes and name the class.

Routes

Two reference routes to a validated MVP, and either one alone satisfies readiness for the scenarios it covers:

Route Terminator Reachability Proven continuously by
The Kubernetes route an in-cluster TLS terminator the deployer trusts, in front of the pod, as docs/kubernetes.md builds one; OBSYNC_EDGE=none. A deployment's own tuple (proxy, route, certificate) stays with whoever runs it, not here private: the LAN, or a private route (a VPN or an overlay network) back to it; no public application, no access broker .github/workflows/helm-e2e.yml on every pull request: the chart, the terminator and an API device flow on a throwaway cluster. Real devices on that route: V1-V14, by hand, in a run record
Docker Compose Caddy in deploy/compose, OBSYNC_EDGE=none private name, private CA, published only on the chosen OBSYNC_BIND_ADDRESS scripts/ci/compose-smoke.sh and .github/workflows/compose-e2e.yml, on every pull request

A Compose server satisfies every V scenario and every J journey on this page. In V10 the server pod is the obsync-obsync-1 container (docker restart obsync-obsync-1), and V15 is the Compose route itself. What the Kubernetes route adds -- the chart, its NetworkPolicy, its claims -- is nothing a device can observe, and helm-e2e.yml proves it on every pull request.

Docker Compose is the no-provider route: it needs no account with anybody and nothing reachable from the internet. Both routes' serving paths are re-proven on every pull request by a synthetic client; neither of those CI jobs drives the real plugin or a real device. .github/workflows/desktop-matrix.yml does drive the real plugin, inside the official Obsidian desktop app on Linux, macOS and Windows, nightly and on plugin changes: setup, pairing, notes both ways, a rename and folders, against the server behind Caddy. That is CI evidence for those three desktops, not a device run, and no CI job drives a phone. Real devices are what the run records are for. The Compose path's "no public exposure" is an assertion and not a hope: the smoke reads back the HostIp Docker published 80 and 443 on and refuses any address but the one OBSYNC_BIND_ADDRESS selected, and refuses the compose file itself if that variable is optional. V15 is where a person confirms on real devices what that smoke proves on a runner.

Every run records device models, OS versions, app versions, the server commit, and timings in one file per run under docs/validation-runs/, named <date>.md for the date the run started; that README holds the required fields and the redaction rules. The quickstart's five captures are taken during the run -- 01 from the production-path install above, 02 and 03 from V1, 04 from V2, 05 from V3 -- and are committed under docs/captures/ by the convention recorded there.