← All posts

Plainva 0.8.0: a note that answers back

September 4, 2026

A note used to be something you wrote alone. This release is about what happens when it is not.

The hard part was the place, not the comment

A comment is easy. A comment that still means something next week is not.

The problem is that two kinds of edit work against each other. In Plainva, a marker written into the Markdown is exact — it survives any edit, because it moves with the text. In Obsidian, in vim, in whatever else opens the same folder, that marker does not come back: a paste, a reformat, a search-and-replace, and it is gone.

So both anchors travel together. The hard anchor is an HTML comment pair, <!--pv#7f3a-->, the one Markdown mechanism every conforming renderer hides — and the one Plainva already used for its index marker. The soft anchor is the quote plus forty characters of context on each side. Resolution walks four stages: marker, quote-in-context, nearest of several candidates, orphan.

The fourth stage is the one I care about. A comment without a place is kept, never dropped. It says so on its card, and it says so honestly: where the passage has moved, the card names the move; where it still fits, the card stays quiet, because a state line that always shows says nothing at all.

One detail that took a while to see: the search for the quote runs on the marker-free text. A quote was captured from what the author selected, so it never contains a marker — but the raw text may well have one sitting inside the range when two comments overlap. And half a pair, left behind by a foreign editor that ate the other half, counts as absent: a lone opening marker must not produce a range that runs to the end of the note.

The comment text itself never enters the Markdown. In an encrypted workspace the note lies in the cloud as ciphertext, and what people say about it is often the more sensitive half.

Commenting on things that are not text

Everything a widget draws — an embedded image, a Mermaid diagram, a table — replaces its Markdown with a Decoration.replace. There is no text left to select, so there was nothing to anchor to.

The fix has a shape I like. The anchor learns an optional display hint: what kind of thing was pointed at, and for a table which row and column. It is a hint, not an identity — the anchor is still the marker pair plus the quote, so the protocol did not change and an older client reads these comments fine.

Then the frame. A mark over a replaced range renders nothing, so the framed anchor is skipped where marks are built and its payload rides in a second field that maps through edits like any decoration set. The widgets ask where their own range is and draw an outline — outline rather than border, because outline sits outside layout and leaves the height measurements alone. A border would have moved every table by two pixels the moment somebody commented on it.

A table cell was the last one to get right, and it took a second pass after the maintainer used it. The card had quoted the whole table’s Markdown. Now a cell anchor quotes the cell, the coordinates stay in the hint, and resolution finds the cell at its coordinates, or failing that by its text — so inserting a row above it moves the comment along and the card names the new row.

A suggestion is typed, not described

The first version of suggestions had a toggle in the compose box: write your remark, tick “this is a change”, fill in a field with the replacement. It worked and it was wrong. Describing an edit beside the text is not how anyone edits text.

So suggesting is a mode now, beside Read, Live and Source. You type in a copy of the note; the file stays the base. @codemirror/merge draws what went struck through and what came marked, in the app’s two tones, and nothing is saved or journaled while the mode is on. A band under the toolbar counts the change blocks and takes an optional sentence about the round.

Sending turns every block between base and copy into a proposal. The base text becomes the anchor’s quote — or, where the block adds text and removes none, an insertion point: an anchor with an empty quote that names a place by its context alone. That case had nowhere to stand before. A proposal covering no character is drawn as a widget at its place in the editor and as an ins between two characters in the read view.

And the blocks stay together. One send is a round: it shows as a group, and while more than one block is open it offers Apply all and Decline all. Applying resolves every block against the text as it stands right now, refuses when one has lost its place, and writes the note in one transaction, back to front. Back to front because applying an earlier block moves every later one — the alternative is recomputing offsets four times and being wrong once.

A publication is a workspace of its own

Sharing a slice of a vault with somebody who is not in the vault is not a permission problem. It is a key problem, and the only honest answer I found is separation: a publication gets its own genesis, its own policy, its own group keys. Nothing crosses over. The only link between the two is a derived id.

That is what makes retracting honest. Dropping a publication re-keys nothing the team is still working in, because the team was never in those keys.

Two things surprised me on the way.

The first: the denylist was the wrong shape. The sanitizer decided what leaves the vault by blocking apiKey, password, private, secret, token — and two things Plainva itself writes defeat that list while containing none of those words. The plainva namespace carries a task anchor whose identity is the publisher’s verified Google or Microsoft account. OKF sources carries the RFC Message-ID of the private mail a note was captured from. Frontmatter passes a shared allowlist now. A missing allowlist means that policy rather than publish-everything.

Link projection had the same class of hole: wiki links and inline links were covered, reference links were not — and neither were the definition lines at the bottom of the file carrying their destinations. Neutralising a label while leaving the path where it stands publishes the path.

The second: a red probe proved a guard load-bearing that I would have called defensive. Revoking a recipient reuses the same code that revokes a member. Without a guard, revoking the publication’s own owner throws nothing at all — the publisher is silently revoked out of their own publication, which has exactly one Owner and no second one to restore it.

There is one piece I am quietly pleased with. The published object id is a one-way hash of (publication, source). A recipient holding two publications from me cannot tell which notes overlap. I hold the inputs and the manifest already lists every published object, so the map is built forwards and read backwards — no second piece of stored state that could drift from the first.

Names are claims, not identities

Typing @ in a comment offers the members of the workspace and writes the picked name into the body as ordinary text. That is the entire storage format. Mentions are never a field.

A stored id list would be a second truth the visible text can contradict the moment somebody renames themselves. Derived instead: rename a member and old comments follow the new name; remove them and the highlight falls away while the typed text stays exactly as written.

Which leaves the case where two members share a name. Rendering has to pick one and does so deterministically. The notification path returns every member the name could mean — reaching one of two people while looking like it reached the right one is the worse failure of the two.

A deletion that stays deleted

Not part of the conversation theme, but the report that annoyed me most, so it goes in.

A confirmed deletion never reached the other devices as an intent. They only saw files missing from the remote listing — which is exactly what a broken listing looks like. So the pull-side guard did its job: it held them, kept them locally, and uploaded them again on the next cycle. The deletion came back on every device, and the guard had no exit.

The sideband now carries a deletion journal that every device merges before it reconciles. What the journal explains is mirrored regardless of how many files there are. What it does not explain stays guarded as before — but the guard now asks, with two answers: delete here too, or keep the local copies and upload them again. A guard with no way out is a guess that has been promoted to a rule.

What is not here

The cryptography behind encrypted workspaces has still not been independently reviewed. That was true before, and it matters more now: with publications, content leaves your device towards other people. The feature is marked experimental in the app and I mean it — it is a preview, not a place for anything confidential. The review is a gate I have written down and will not talk my way past.

Also still open: the signature counter-check with a deliberately tampered update artefact, and the SMB kill drill on a network share.

The device acceptance for the external vault folder is running as I publish this. That is a named risk rather than an oversight — but it is named.