← All posts

Plainva 0.7.0: more than one window, more than one vault

August 24, 2026

A window was never the limit. The limit was that “the vault” was one thing.

What actually had to change

The visible feature is easy to describe: a note, a database, the graph, tasks, the calendar or a mailbox can each live in its own OS window. Second monitor, own taskbar tile, Alt+Tab.

The work underneath was not a new capability but a separation. What used to be called “the vault” is now a registry of runtimes, held per window label. All of them live in the owner’s heap, because every window is its own JavaScript island — one module map therefore covers the whole process.

The thing that made this affordable was already in the code. Of 61 consumers of useVault(), only nine read app-level state; every other one reads services. So the context gets split, not rewritten.

One process, and the central window keeps every background service and every write. Auxiliary windows read locally and delegate changes over a window bus. That is not a detail: it means the whole of the sync hardening from July still works against exactly one writer. The load-bearing rule is a negative one — a piece of content is open app-wide only once. Asking for it again brings its window forward. That removes the token-rotation and save races structurally instead of managing them.

The most expensive line was an ordering, not a function

The reminder scheduler hung in the app shell — and the shell is rendered by the vault a window displays. A vault held open by one window for another would have had a sync worker, an indexer and a watcher, and no clock. Its events would never have been announced.

The same class in miniature: zipRunning was a flag rather than a set, so two archives became a queue; and the sync status store sat under “the vault” instead of under a vault.

A refresh token belongs to a grant

This was the sharp one. A refresh token belongs to a grant — a confirmed identity plus the OAuth client that issued it — not to a vault. Microsoft and Dropbox rotate on every renewal. With two vaults open, renewing one would have invalidated the other’s copy.

Two halves, neither of which replaces the other: a gate per grant, so two windows in the same second share one round trip, and a write-through into every other slot of that grant, which covers the sequential case and heals a vault that was just closed. A label is not an identity; without a confirmed one, nothing is shared.

Two vaults where one lies inside the other are refused at every door, with a named reason, including on restore — folders move between sessions. The same vault twice is still allowed. And the last window to close a vault drains it rather than just stopping: writes settle, then the cycle finishes. That creates a new race, which is closed structurally: React’s cleanup cannot await, so the promise is parked and the next open of that vault waits on it.

Settings travel unless you say otherwise

There is one behaviour change existing users will notice, and it is worth stating plainly.

The settings sync was opt-in, and a fresh vault wrote undefined into the switch — which meant “off”. So the daily-note format, the template folder and the backup rules stayed on each device separately, and nobody was ever asked. The rule is now !== false: never asked means on, switched off stays off.

That is the same semantics the secret transport has always used, with the default the other way round — and sign-ins deliberately keep theirs. Your credentials still do not travel unless you say so.

The first external code contribution reaches users

Pedro Algarvio reported a vault that would not open: an Obsidian vault with a Python venv and a linter cache in it, on Arch Linux, installed from an AUR package. That is a note about reach, by the way — the docs still list AUR as “maybe later”, and people are already installing that way.

Both his pull requests hit the right layer. .attachments/.gitkeep failed the filesystem capability scope, because require_literal_leading_dot is on by default on Unix and no pattern covers two dot segments in a row. And a symlinked directory — lib64 -> lib in every venv — was classified as a file, because readDir does not resolve isDirectory through symlinks.

Three things followed that neither PR could see from outside.

The exclusion list was the real bug, and it sat in the wrong layer. The obvious fix is to add .venv and __pycache__ to it. That would have run into two traps. First, it would have looked like a mass deletion: the full scan reports every file that is in the index and missing on disk, so excluding them makes thousands of venv files suddenly “missing” and the deletions would have gone into the sync queue. The full scan now separates the two — remove from the index, yes; report to sync, only if the path is not internal. Second, the rule only applied in the desktop adapter, so the mobile walk indexed node_modules/**/*.md. It lives in the shared core now.

The cycle guard could not tell two paths to the same directory apart. #72 says so in its own comment and adds a depth cap as an emergency exit, which prevents hanging but not the double indexing: visited held paths, and lib64 looks like a different folder. It now keys on directory identity, which costs no extra filesystem call — the stat() that yields the identity replaces the exists() that guarded the same spot.

And a test pinned the shape of the config rather than its effect. It would survive a rewrite of the capability patterns — exactly the change coming with the trusted-roots work — while the coverage it stands for breaks silently. There is now a Rust test that matches the real patterns against the real flag, with the same glob and the same match options the plugin uses.

Two watchdogs that learned something about themselves

The boot watchdog makes one claim — “nothing rendered after eight seconds” — and never revisited it. A slow start disproves it a moment later, and the full-screen message stayed up: “Plainva didn’t start” over a running app, swallowing every click. It was found by a push in which 47 of 265 end-to-end tests clicked exactly there, and the element underneath was visible, enabled and stable every time.

A user on a busy machine would have seen the same thing and believed it. That is why the obvious fix — raising the grace period — was the wrong one: it trades one arbitrary threshold for the next and still buries a working app, just later. The branch now knows its own premise and takes the sign back down once the app has demonstrably mounted. The error branch is untouched and keeps its screen forever: there the premise was never disproved.

The second one is the frozen budget. A guard held twelve files with forty findings — the shape that shipped a white window twice — and its own comment said it was not an acquittal, only that no chunk order had hit them yet. Multi-window is that order. So the budget is now down to the three entry points, and that is a decision rather than a remainder: an entry module is the start of the graph, and there is no chunk order that puts it before itself.

What is not here

Everything in this release is a first release; none of it shipped before. What is deliberately still open: the encryption has no independent review, the signature counter-check with a tampered artifact has not been run, and there is no macOS machine here, so the folder-deletion report from an earlier issue stays unreproduced.

And the acceptance run for the window work is still going. Three reported findings are fixed but unverified at a real window; they are checked at this build. That is a stated risk, not an oversight — a fault in an auxiliary window costs no data, because the central window keeps every write.