There's a rule in my filesystem tooling that I wrote down after a bad incident in the old Java version: partial edits are surgical splices into the original bytes, never parse-and-re-render. The function that updates frontmatter properties in a Markdown file carried a doc comment saying it followed that rule. It didn't. It parsed the whole frontmatter block into entries and rebuilt it from scratch on every call, and it had always done that.
Why a re-render is lossy by definition
A parser models what it knows about. Anything it doesn't model has nowhere to live in the parsed representation, so when you render back out, it's gone.
For a frontmatter block that meant a YAML block-scalar continuation line, a comment line, a blank separator - all dropped, silently, on every real call. Not an edge case in some unusual file. Every call, on any file containing one of those.
This is a general property and it's worth stating in the general form: any edit implemented as parse-then-render loses everything the parser doesn't understand. Not everything it parses wrong. Everything it doesn't have a concept for. And the set of things a lenient parser doesn't have a concept for is much larger than the set of things it gets wrong, because getting something wrong requires having tried.
The doc comment was the reason this survived so long. Someone reviewing the call site reads "byte-range splice" and moves on. The comment made the code look like it had already been audited against exactly the failure it had.
The second bug in the same path
While fixing that, a second problem fell out. The parser wasn't indentation-aware. A nested mapping line - an indented key underneath a parent key - parsed as its own top-level entry.
The consequence only appears in combination with the first bug. Updating a completely unrelated top-level key triggers the re-render, and the re-render writes back the parser's wrong idea of the structure, corrupting the nested mapping. Two bugs where either alone is nearly harmless and together they destroy data in a file the caller never asked to touch.
I keep running into this shape. A lenient parser plus a lossy write path is much worse than the sum of its parts, because leniency means the parser doesn't complain about the thing it's about to mangle.
What the fix required
The entry type had to stop carrying reconstructed text and start carrying a range into the original content. That's the whole structural change, and everything else follows from it. Merging updates is now a single forward pass over the original string: replace only the ranges of the keys being updated, copy every other byte straight through - untouched entries, unmodeled lines, both delimiters. New keys that don't match an existing entry get appended in sorted order immediately before the closing delimiter.
The word "copy" is doing real work there. Not re-serialize. Not normalize. Copy the bytes.
The indentation rule that a review caught
Making the parser indentation-aware needed a rule for where a top-level entry may start, and my first attempt was wrong in a way I want to record because it's the kind of wrong that looks right.
I used the indent width of the first non-blank line. A review pass caught the failure: one stray-indented outlier line, a typo'd leading space at the top of the file, would set the baseline too deep and drag every genuinely top-level key out of view. Then updating one of those now-invisible keys wouldn't find it, so it'd be appended as a new line, leaving a duplicate.
The rule that works is the minimum indent width among all non-blank body lines - zero for an ordinary file. A single outlier can't move the baseline, because it can only ever be deeper than the minimum. A more-deeply-indented line is absorbed into the entry above it and carried along uninterpreted inside that entry's range: nested mappings, block-scalar continuations, whatever it is. The parser doesn't need to understand it. It needs to not lose it, and carrying it inside a range achieves that without understanding.
"Preserve without interpreting" turned out to be the useful idea in this whole fix, and it's what a range-based model gives you for free.
The behavior change I had to admit to
A nested-mapping line no longer appears as a top-level property in the read tools, the bulk-listing tool, or the filter-matching tool. It used to, because the parser wrongly promoted it.
That's a fix, and it's also an observable change on three tools. Some caller somewhere might be filtering on a property that only existed because of the bug. I wrote it into the tool contract doc with a date rather than letting it ride as an implementation detail, for the same reason I did with a different backward-incompatible default change a few days earlier. A fix that changes what a tool returns is an API change even when the old return value was wrong.
The tests that were worth writing
The interesting assertions here are byte-preservation tests, and their names are the specification:
A block scalar survives byte for byte when an unrelated key is updated. A comment line and a blank separator survive byte for byte when an unrelated key is updated. A block scalar with dash-bullet-shaped continuation lines parses as a string, not an array. A stray leading space on the first body line doesn't hide subsequent top-level keys. Updating a key that would have vanished under the old baseline bug replaces the original line rather than appending a duplicate. Updating two existing entries in one call replaces both and preserves everything else.
Every one of those is phrased as the property, not as the mechanism. That's deliberate. If I rewrite the internals again in a year, those names still describe what has to remain true, and a test named testMergingProperties would not have.
Two gaps stayed open and are filed with their reproduction cases rather than being quietly not-fixed. I'd rather ship a known boundary than an unknown one.
The lesson I'd actually apply
If you have a tool that edits part of a file, go find out whether it edits part of the file. The test is simple: put something in the file that the tool has no reason to understand - a comment, an odd continuation, an unusual construct - update an unrelated field, and diff.
If anything but the field you asked for changed, you have a re-render wearing a splice's name. And check the doc comment while you're in there, because on my code the comment was the most confident thing in the file and the only part that was wrong.