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.