Skip to main content
← Blog

Lineage in the Letters

VauDium·

A document born from a template soon gets mixed with usage notes. The day I made documents remember which letters came from the template — planting one invisible mark across five editors with the same two rules.

Lineage in the Letters

Documents in Fecit have two layers. A template holds how this work is done; a record generated from it holds how it went this time. The better the template, the more thoroughly the two layers blend inside a record — this run’s notes slip in between the procedure steps, and sentences that don’t fit get rewritten in place.

That blending kept tormenting one feature: improve suggestions, the flow that carries fixes made in a record back to its template. It compares the record’s body against the template’s and produces “this step changed — apply it to the template?”. But the record body it compares is already full of usage notes. To a diff, “fixed the procedure” and “jotted down this run’s memo” look identical, so the suggestions picked up noise. Until now a heuristic filtered it — and a heuristic is, in the end, a guess.

Today I decided to stop guessing.

Let the letters remember, not the document

What I needed wasn’t “this document came from a template.” I already know that. What I needed was this letter came from the template. Document-level provenance loses its meaning the moment things blend; letter-level provenance survives the blending.

So at generation time, the server paints the entire body with an invisible inline mark. It’s not formatting — it isn’t there to be seen like bold or underline. It exists only as data: “these characters came from the template.” From then on, improve suggestions stitch together only the marked spans and compare those against the template. Usage notes never enter the comparison, so there’s no noise to filter.

The pleasant surprise is that the editing rules fit in two lines.

First, marks travel with their letters. Edit a marked sentence, move it, split it in half — the surviving letters keep their marks. Paste and undo are movements of existing content, not new writing, so marks come along.

Second, newly typed letters don’t inherit the mark. Put your caret in the middle of a marked span and type — the new letters are unmarked; they’re usage. Formatting works the opposite way, and should: type between bold letters and you expect bold to continue. Lineage is the inverse. Provenance is not inherited.

The same rules, five times

Fecit’s body editor isn’t one editor. Desktop has a web editor; iOS and Android each have their own native text engine; the server has its own parser; and there’s a shared serialization layer. Two lines of rules had to be planted in five places with identical meaning.

Each engine has its own temperament, so the same rule becomes opposite code. iOS’s text engine doesn’t copy custom attributes into typing — “new letters don’t inherit” holds for free. Android is the opposite: spans auto-extend across interior insertions, so I had to write logic that splits spans back out of newly inserted ranges. The desktop editor sits in between — you can declare that marks don’t spread at span boundaries, but an insertion in the middle of a span still inherits the mark, so a plugin tracks fresh input ranges and strips them after the fact. During Korean IME composition, nobody touches anything; cleanup waits until composition ends. Manipulating text mid-composition is the express lane to broken Hangul input.

Then there are old clients. An app that doesn’t know the mark would render the tags as literal text, so new clients declare “I can preserve marks” on each request, and for requests without that declaration the server strips marks from the response. If an old client re-saves such a document, that one document’s marks are lost — and improve suggestions fall back to the old heuristic. Degrade, don’t break.

Data-only marks show the user nothing, though. So I added a toggle: switch it on in a generated document and the template’s share glows softly; type while it’s on and the new letters become the template’s share. It’s an eye for “how far does the template reach in this document” — and a hand for revising that share.

Invisible data dies quietly

Reviewing the day’s commits in the evening, I found a hole.

Besides the user’s typing, an editor has paths where the whole body gets re-injected: when another device’s save arrives through live sync, when an edit is reverted, when a parked draft is restored. Every one of these is “erase the document, insert it anew” — which, to the mark-cleanup plugin, looked like one enormous burst of typing. Rule number two fired dutifully and stripped every mark from the freshly arrived body. Touch one character after that and save, and the lineage is gone for good.

If this were formatting, I’d have noticed instantly. If one sync pass unbolded an entire document, anyone would report the bug within five seconds. But this mark is invisible by design. The screen stays fine; only the data dies, quietly. Data the eye can’t see won’t be guarded by users, so the code has to guard it — the programmatic injection paths now carry a flag that says “this is not typing,” and the cleanup plugin steps aside.

That’s today’s lesson. If you design invisible data, the paths where it dies are invisible too. An invariant that can’t be verified by looking at the screen can only be verified by walking the paths one by one — which is how an evening commit review saved the feature. Half of engraving lineage into letters was the code that engraves it; the other half was hunting down the code that erases it.